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
+5
View File
@@ -0,0 +1,5 @@
---
'@techdocs/cli': minor
---
Techdocs CLI serve supports automatic refresh, relying on `mkdocs` `watch` feature.
@@ -7,3 +7,7 @@ backend:
techdocs:
builder: 'external'
sanitizer:
# Allow live reload locally. Added in this config to avoid updating the main techdocs plugin.
allowedCustomElementTagNameRegExp: '^live-reload$'
allowedCustomElementAttributeNameRegExp: '^live-reload-(epoch|request-id)$'
@@ -36,6 +36,7 @@ import * as plugins from './plugins';
import { configLoader } from './config';
import { Root } from './components/Root';
import { techDocsPage, TechDocsThemeToggle } from './components/TechDocsPage';
import { TechDocsLiveReload } from './LiveReloadAddon';
const app = createApp({
apis,
@@ -54,6 +55,14 @@ const ThemeToggleAddon = techdocsPlugin.provide(
}),
);
const LiveReloadAddon = techdocsPlugin.provide(
createTechDocsAddonExtension({
name: 'LiveReloadAddon',
component: TechDocsLiveReload,
location: TechDocsAddonLocations.Content,
}),
);
const routes = (
<FlatRoutes>
<Navigate key="/" to="/docs/default/component/local/" />
@@ -71,6 +80,7 @@ const routes = (
>
{techDocsPage}
<TechDocsAddons>
<LiveReloadAddon />
<ThemeToggleAddon />
</TechDocsAddons>
</Route>
@@ -0,0 +1,111 @@
/*
* 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 { render } from '@testing-library/react';
import { TechDocsLiveReload } from './LiveReloadAddon';
jest.mock('@backstage/plugin-techdocs-react', () => ({
useShadowRootElements: jest.fn(() => [
{
querySelector: jest.fn((selector: string) => {
if (selector === 'live-reload') {
return {
getAttribute: (name: string) => {
if (name === 'live-reload-epoch') return '10';
if (name === 'live-reload-request-id') return '1';
return null;
},
};
}
return null;
}),
},
]),
}));
describe('TechDocsLiveReload', () => {
const originalXHR = global.XMLHttpRequest;
let originalLocation: Location;
let openSpy: jest.Mock;
let sendSpy: jest.Mock;
beforeEach(() => {
originalLocation = window.location;
openSpy = jest.fn();
sendSpy = jest.fn(function (this: any) {
// simulate long-poll response that does NOT trigger reload (epoch unchanged)
setTimeout(() => {
(this as any).status = 200;
(this as any).responseText = '10';
(this as any).onloadend?.call(this);
}, 0);
});
class MockXHR {
onloadend: ((this: any) => void) | null = null;
status = 0;
responseText = '';
open = openSpy;
send = sendSpy as any;
abort = jest.fn();
}
global.XMLHttpRequest = MockXHR as any;
// Replace window.location with a mutable object for tests
delete (window as any).location;
(window as any).location = { ...originalLocation, reload: jest.fn() };
jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
jest.spyOn(window, 'removeEventListener').mockImplementation(() => {});
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
configurable: true,
});
});
afterEach(() => {
global.XMLHttpRequest = originalXHR;
jest.restoreAllMocks();
// restore original window.location
delete (window as any).location;
(window as any).location = originalLocation;
});
it('polls livereload endpoint and does not reload when epoch unchanged', async () => {
const reloadSpy = window.location.reload as unknown as jest.Mock;
render(<TechDocsLiveReload enabled />);
expect(openSpy).toHaveBeenCalledWith('GET', '/.livereload/10/1');
// give microtask queue a tick
await new Promise(res => setTimeout(res, 0));
expect(reloadSpy).not.toHaveBeenCalled();
});
it('reloads when server epoch increases', async () => {
const reloadSpy = window.location.reload as unknown as jest.Mock;
sendSpy.mockImplementation(function (this: any) {
setTimeout(() => {
(this as any).status = 200;
(this as any).responseText = '11';
(this as any).onloadend?.call(this);
}, 0);
});
render(<TechDocsLiveReload enabled />);
await new Promise(res => setTimeout(res, 0));
expect(reloadSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,121 @@
/*
* 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 { useShadowRootElements } from '@backstage/plugin-techdocs-react';
import { useEffect, useRef } from 'react';
interface TechDocsLiveReloadProps {
/** Whether to enable livereload (default: true in development) */
enabled?: boolean;
}
/**
* LiveReload addon for Techdocs CLI.
*
* Support mkdocs built-in livereload, in a TechDocs CLI preview environment.
* See https://github.com/backstage/backstage/issues/30514 for more details.
*/
export const TechDocsLiveReload = ({
enabled = true,
}: TechDocsLiveReloadProps) => {
const body = useShadowRootElements<HTMLBodyElement>(['body']);
const reqRef = useRef<XMLHttpRequest | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
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';
useEffect(() => {
if (!enabled || !body[0]) {
return undefined;
}
const liveReloadElement = body[0].querySelector(LIVE_RELOAD_ELEMENT);
if (!liveReloadElement) {
return undefined;
}
const epoch = parseInt(
liveReloadElement.getAttribute(LIVE_RELOAD_ATTR_EPOCH) || '0',
10,
);
const requestId = parseInt(
liveReloadElement.getAttribute(LIVE_RELOAD_ATTR_REQUEST_ID) || '0',
10,
);
if (!epoch || !requestId) {
return undefined;
}
const livereloadUrl = CLI_LIVERELOAD_PATH;
const poll = () => {
reqRef.current = new XMLHttpRequest();
reqRef.current.onloadend = function handleLoadEnd(this: XMLHttpRequest) {
if (parseFloat(this.responseText) > epoch) {
window.location.reload();
} else {
timeoutRef.current = setTimeout(poll, this.status === 200 ? 0 : 3000);
}
};
reqRef.current.open('GET', `${livereloadUrl}/${epoch}/${requestId}`);
reqRef.current.send();
};
const stop = () => {
if (reqRef.current) {
reqRef.current.abort();
reqRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Stop when tab is inactive
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
poll();
} else {
stop();
}
};
const handleBeforeUnload = () => {
stop();
};
// Start polling if page is visible
if (document.visibilityState === 'visible') {
poll();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
stop();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [body, enabled]);
return null;
};
+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);
}