diff --git a/.changeset/brown-symbols-create.md b/.changeset/brown-symbols-create.md new file mode 100644 index 0000000000..d4a62b1280 --- /dev/null +++ b/.changeset/brown-symbols-create.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': minor +--- + +Techdocs CLI serve supports automatic refresh, relying on `mkdocs` `watch` feature. diff --git a/packages/techdocs-cli-embedded-app/app-config.yaml b/packages/techdocs-cli-embedded-app/app-config.yaml index 6ede05b587..c8dfa1683c 100644 --- a/packages/techdocs-cli-embedded-app/app-config.yaml +++ b/packages/techdocs-cli-embedded-app/app-config.yaml @@ -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)$' diff --git a/packages/techdocs-cli-embedded-app/src/App.tsx b/packages/techdocs-cli-embedded-app/src/App.tsx index 273a828b4a..13d5b925f0 100644 --- a/packages/techdocs-cli-embedded-app/src/App.tsx +++ b/packages/techdocs-cli-embedded-app/src/App.tsx @@ -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 = ( @@ -71,6 +80,7 @@ const routes = ( > {techDocsPage} + diff --git a/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.test.tsx b/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.test.tsx new file mode 100644 index 0000000000..1a80def9e9 --- /dev/null +++ b/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.test.tsx @@ -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(); + 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(); + await new Promise(res => setTimeout(res, 0)); + expect(reloadSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.tsx b/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.tsx new file mode 100644 index 0000000000..905a339c72 --- /dev/null +++ b/packages/techdocs-cli-embedded-app/src/LiveReloadAddon.tsx @@ -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(['body']); + const reqRef = useRef(null); + const timeoutRef = useRef(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; +}; diff --git a/packages/techdocs-cli/src/lib/httpServer.ts b/packages/techdocs-cli/src/lib/httpServer.ts index c38bc86d7a..d7e8f62ea0 100644 --- a/packages/techdocs-cli/src/lib/httpServer.ts +++ b/packages/techdocs-cli/src/lib/httpServer.ts @@ -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, diff --git a/packages/techdocs-cli/src/lib/livereload.test.ts b/packages/techdocs-cli/src/lib/livereload.test.ts new file mode 100644 index 0000000000..b21251c45a --- /dev/null +++ b/packages/techdocs-cli/src/lib/livereload.test.ts @@ -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 = {}; + 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 = + ''; + const result = injectLivereloadParameters(html); + expect(result).toContain(''); + }); + + it('returns original html when mkdocs script is absent', () => { + const html = '

No livereload

'; + 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 = {}; + 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(''); + } + 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(' { + 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 = {}; + 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 = {}; + 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(); + }); + }); +}); diff --git a/packages/techdocs-cli/src/lib/livereload.ts b/packages/techdocs-cli/src/lib/livereload.ts new file mode 100644 index 0000000000..c108971b82 --- /dev/null +++ b/packages/techdocs-cli/src/lib/livereload.ts @@ -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: ``. + * - 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 = /]*>/; +// 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 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}">`; + + // 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 `` + 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); +}