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:
@@ -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;
|
||||
};
|
||||
@@ -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