feat(techdocs): POC livereload for techdocs-cli serve (#30541)

* feat(techdocs): POC livereload for techdocs-cli serve

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>

* chore: techdocs reload tests, refactor

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>

---------

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>
This commit is contained in:
Gabriel Dugny
2025-10-07 19:29:14 +02:00
committed by GitHub
parent fe187e68a7
commit 43afbe50aa
8 changed files with 636 additions and 2 deletions
+30 -2
View File
@@ -18,6 +18,10 @@ import serveHandler from 'serve-handler';
import http from 'http';
import httpProxy from 'http-proxy';
import { createLogger } from './utility';
import {
proxyHtmlWithLivereloadInjection,
proxyMkdocsLivereload,
} from './livereload';
export default class HTTPServer {
private readonly proxyEndpoint: string;
@@ -59,8 +63,8 @@ export default class HTTPServer {
const proxyHandler = this.createProxy();
const server = http.createServer(
(request: http.IncomingMessage, response: http.ServerResponse) => {
// This endpoind is used by the frontend to issue a cookie for the user.
// But the MkDocs server doesn't expose it as a the Backestage backend does.
// This endpoint is used by the frontend to issue a cookie for the user.
// But the MkDocs server doesn't expose it as a the Backstage backend does.
// So we need to fake it here to prevent 404 errors.
if (request.url === '/api/techdocs/.backstage/auth/v1/cookie') {
const oneHourInMilliseconds = 60 * 60 * 1000;
@@ -72,6 +76,19 @@ export default class HTTPServer {
}
if (request.url?.startsWith(this.proxyEndpoint)) {
// Handle HTML files with livereload parameter injection
if (request.url?.endsWith('.html')) {
proxyHtmlWithLivereloadInjection({
request,
response,
mkdocsTargetAddress: this.mkdocsTargetAddress,
proxyEndpoint: this.proxyEndpoint,
onError: (error: Error) => reject(error),
});
return;
}
// Handle non-HTML files with regular proxy
const [proxy, forwardPath] = proxyHandler(request);
proxy.on('error', (error: Error) => {
@@ -93,6 +110,17 @@ export default class HTTPServer {
return;
}
// This endpoint is used by the frontend to pass livereload requests to the mkdocs server.
if (request.url?.startsWith('/.livereload')) {
proxyMkdocsLivereload({
request,
response,
mkdocsTargetAddress: this.mkdocsTargetAddress,
onError: (error: Error) => reject(error),
});
return;
}
serveHandler(request, response, {
public: this.backstageBundleDir,
trailingSlash: true,
@@ -0,0 +1,180 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'http';
import {
injectLivereloadParameters,
proxyHtmlWithLivereloadInjection,
proxyMkdocsLivereload,
} from './livereload';
// Note: This mock returns a singleton proxy object so tests can access the
// registered event handlers (e.g. `proxyRes`) from code under test.
jest.mock('http-proxy', () => {
const handlers: Record<string, Function> = {};
const fakeProxy = {
on: jest.fn((event: string, cb: Function) => {
handlers[event] = cb;
}),
web: jest.fn((_req: unknown, _res: unknown) => {
// no-op; tests will manually trigger handlers['proxyRes'] when needed
}),
__handlers: handlers,
};
const create = jest.fn(() => fakeProxy);
return {
__esModule: true,
default: { createProxyServer: create },
createProxyServer: create,
};
});
describe('livereload helpers', () => {
describe('injectLivereloadParameters', () => {
it('injects live-reload element when mkdocs script is present', () => {
const html =
'<html><body><script>livereload(123, 456);</script></body></html>';
const result = injectLivereloadParameters(html);
expect(result).toContain('<live-reload');
expect(result).toContain('live-reload-epoch="123"');
expect(result).toContain('live-reload-request-id="456"');
expect(result).toContain('</body>');
});
it('returns original html when mkdocs script is absent', () => {
const html = '<html><body><h1>No livereload</h1></body></html>';
const result = injectLivereloadParameters(html);
expect(result).toBe(html);
});
});
describe('proxyHtmlWithLivereloadInjection', () => {
it('injects parameters into HTML responses and sets CORS headers', () => {
const { createProxyServer } = jest.requireMock('http-proxy') as any;
const proxy = createProxyServer();
const req = {
url: '/api/techdocs/some/path/index.html',
} as unknown as http.IncomingMessage;
const headers: Record<string, string> = {};
const res = {
setHeader: (k: string, v: any) => {
headers[k] = String(v);
},
end: jest.fn(),
} as unknown as http.ServerResponse;
proxyHtmlWithLivereloadInjection({
request: req,
response: res,
mkdocsTargetAddress: 'http://localhost:8000',
proxyEndpoint: '/api/techdocs/',
onError: () => {},
});
// Simulate mkdocs proxy response with HTML
const proxyRes: any = {
headers: { 'content-type': 'text/html; charset=utf-8' },
on: (event: string, cb: Function) => {
if (event === 'data') {
cb('<html><body><script>livereload(1, 2);</script></body></html>');
}
if (event === 'end') {
cb();
}
},
pipe: jest.fn(),
};
(proxy as any).__handlers.proxyRes(proxyRes, {} as any, res);
expect(res.end).toHaveBeenCalled();
const injectedHtml = (res.end as jest.Mock).mock.calls[0][0] as string;
expect(injectedHtml).toContain('<live-reload');
expect(headers['Access-Control-Allow-Origin']).toBe('*');
expect(headers['Access-Control-Allow-Methods']).toBe('GET, OPTIONS');
// Ensure proxyEndpoint was stripped from request url
expect(req.url).toBe('some/path/index.html');
});
it('passes through non-HTML responses without injection', () => {
const { createProxyServer } = jest.requireMock('http-proxy') as any;
const proxy = createProxyServer();
const req = {
url: '/api/techdocs/some/path/asset.css',
} as unknown as http.IncomingMessage;
const headers: Record<string, string> = {};
const res = {
setHeader: (k: string, v: any) => {
headers[k] = String(v);
},
end: jest.fn(),
} as unknown as http.ServerResponse;
proxyHtmlWithLivereloadInjection({
request: req,
response: res,
mkdocsTargetAddress: 'http://localhost:8000',
proxyEndpoint: '/api/techdocs/',
onError: () => {},
});
const proxyRes: any = {
headers: { 'content-type': 'text/css' },
on: jest.fn(),
pipe: jest.fn(),
};
(proxy as any).__handlers.proxyRes(proxyRes, {} as any, res);
expect(res.end).not.toHaveBeenCalled();
expect(proxyRes.pipe).toHaveBeenCalledWith(res);
expect(headers['Access-Control-Allow-Origin']).toBe('*');
expect(headers['Access-Control-Allow-Methods']).toBe('GET, OPTIONS');
});
});
describe('proxyMkdocsLivereload', () => {
it('rewrites path and sets CORS headers', () => {
const { createProxyServer } = jest.requireMock('http-proxy') as any;
const proxy = createProxyServer();
const req = {
url: '/.livereload/1/2',
} as unknown as http.IncomingMessage;
const headers: Record<string, string> = {};
const res = {
setHeader: (k: string, v: any) => {
headers[k] = String(v);
},
} as unknown as http.ServerResponse;
proxyMkdocsLivereload({
request: req,
response: res,
mkdocsTargetAddress: 'http://localhost:8000',
onError: () => {},
});
expect(req.url).toBe('/livereload/1/2');
expect(headers['Access-Control-Allow-Origin']).toBe('*');
expect(headers['Access-Control-Allow-Methods']).toBe('GET, OPTIONS');
expect(headers['Access-Control-Allow-Headers']).toBe('Content-Type');
expect((proxy as any).web).toHaveBeenCalled();
});
});
});
+175
View File
@@ -0,0 +1,175 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'http';
import httpProxy from 'http-proxy';
/**
* Livereload support for techdocs-cli.
*
* Context:
* - MkDocs implements autoreload using a long-poll endpoint `/livereload` and a script that injects
* a call like: `livereload(epoch, request_id)`, where `epoch` is derived from Python's
* `time.monotonic()`.
* - Node.js monotonic clocks (`process.hrtime`/`performance.now`) are not compatible with Python's
* value across processes and platforms. We therefore CANNOT reliably re-create the same epoch on
* the frontend or in this CLI, and must read the values produced by MkDocs itself.
* - The MkDocs script tag is removed by DOM sanitization (DomPurify) in TechDocs, so we can't rely
* on the script being present in the embedded app. To bridge this, we extract the parameters on
* the server side while proxying HTML and inject them as a safe custom element that survives
* sanitization: `<live-reload live-reload-epoch="…" live-reload-request-id="…"/>`.
* - The frontend addon reads that element and polls `/.livereload` (served by techdocs-cli), which
* this module maps to MkDocs `/livereload` with permissive CORS headers.
* - Quality-of-life: if extraction fails or the endpoint is unavailable, normal docs still work.
*
* See issue for background and rationale: https://github.com/backstage/backstage/issues/30514
*/
const LIVE_RELOAD_ELEMENT = 'live-reload';
const LIVE_RELOAD_ATTR_EPOCH = 'live-reload-epoch';
const LIVE_RELOAD_ATTR_REQUEST_ID = 'live-reload-request-id';
const CLI_LIVERELOAD_PATH = '/.livereload';
const MKDOCS_LIVERELOAD_PATH = '/livereload';
const CONTENT_TYPE_HTML = 'text/html';
const HEADER_CONTENT_LENGTH = 'content-length';
const BODY_START_RE = /<body\b[^>]*>/;
// Matches mkdocs injected call livereload(epoch, requestId)
const MKDOCS_LIVERELOAD_CALL_RE = /livereload\(\s*(\d+)\s*,\s*(\d+)\s*\)\s*;?/;
/**
* Extract livereload parameters from mkdocs HTML and inject them as a custom element.
* The injected element will later be read by the frontend addon even after DOM sanitization.
*
* Note:
* - we don't add to <head> because of DomPurify sanitization.
* - we add close to the body opening to avoid reading too far into the body.
* - we should use streamed injection to improve performance.
*/
export function injectLivereloadParameters(html: string): string {
const livereloadMatch = html.match(MKDOCS_LIVERELOAD_CALL_RE);
// If we couldn't find livereload parameters, return original HTML untouched.
if (!livereloadMatch) {
return html;
}
const [, epoch, requestId] = livereloadMatch;
// Insert a minimal custom element that the frontend addon can discover post-sanitization.
// Note: embedded app needs a custom config to allow the element to survive sanitization.
const liveReloadTag = `<${LIVE_RELOAD_ELEMENT} ${LIVE_RELOAD_ATTR_EPOCH}="${epoch}" ${LIVE_RELOAD_ATTR_REQUEST_ID}="${requestId}"></${LIVE_RELOAD_ELEMENT}>`;
// Naively find where to insert the livereload tag.
const bodyStart = html.match(BODY_START_RE);
const bodyStartIndex = bodyStart?.index ?? 0;
const bodyStartLength = bodyStart?.[0]?.length ?? 0;
if (bodyStartIndex === 0 || bodyStartLength === 0) {
return html;
}
const bodyEndIndex = bodyStartIndex + bodyStartLength;
return html.slice(0, bodyEndIndex) + liveReloadTag + html.slice(bodyEndIndex);
}
function setCorsHeaders(response: http.ServerResponse) {
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
}
/**
* Proxies a mkdocs HTML response, injecting livereload parameters into the HTML body.
*/
export function proxyHtmlWithLivereloadInjection(options: {
request: http.IncomingMessage;
response: http.ServerResponse;
mkdocsTargetAddress: string;
proxyEndpoint: string;
onError: (error: Error) => void;
}): void {
const { request, response, mkdocsTargetAddress, proxyEndpoint, onError } =
options;
const htmlProxy = httpProxy.createProxyServer({
target: mkdocsTargetAddress,
selfHandleResponse: true,
});
htmlProxy.on('error', onError);
// Intercept HTML responses to inject `<live-reload …>`
htmlProxy.on('proxyRes', (proxyRes, _req, res) => {
const contentType = proxyRes.headers['content-type'];
const contentEncoding = proxyRes.headers['content-encoding'];
const isHtml =
contentType &&
typeof contentType === 'string' &&
contentType.startsWith(CONTENT_TYPE_HTML);
if (isHtml && !contentEncoding) {
const chunks: Buffer[] = [];
proxyRes.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
proxyRes.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
const modifiedHtml = injectLivereloadParameters(body);
res.statusCode = (proxyRes.statusCode as number | undefined) ?? 200;
Object.keys(proxyRes.headers).forEach(key => {
if (key.toLowerCase() !== HEADER_CONTENT_LENGTH) {
res.setHeader(key, proxyRes.headers[key]!);
}
});
setCorsHeaders(res);
res.end(modifiedHtml);
});
} else {
res.statusCode = (proxyRes.statusCode as number | undefined) ?? 200;
Object.keys(proxyRes.headers).forEach(key => {
res.setHeader(key, proxyRes.headers[key]!);
});
setCorsHeaders(res);
proxyRes.pipe(res);
}
});
const forwardPath =
request.url?.replace(new RegExp(`^${proxyEndpoint}`, 'i'), '') || '';
request.url = forwardPath;
htmlProxy.web(request, response);
}
/**
* Proxies mkdocs livereload long-polling requests, mapping the CLI path to mkdocs path.
*/
export function proxyMkdocsLivereload(options: {
request: http.IncomingMessage;
response: http.ServerResponse;
mkdocsTargetAddress: string;
onError: (error: Error) => void;
}): void {
const { request, response, mkdocsTargetAddress, onError } = options;
const proxy = httpProxy.createProxyServer({ target: mkdocsTargetAddress });
proxy.on('error', onError);
setCorsHeaders(response);
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
request.url = request.url?.replace(
CLI_LIVERELOAD_PATH,
MKDOCS_LIVERELOAD_PATH,
);
proxy.web(request, response);
}