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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user