From 48d777acdaf5320190b4d5a9f64060adf9d0a76d Mon Sep 17 00:00:00 2001 From: Andrei Sitsko Date: Fri, 6 Nov 2026 17:32:01 +0100 Subject: [PATCH] DISCO-246: Report localhost preview app navigation --- src/commands/sdk/preview-proxy.ts | 31 +++++++--- src/commands/sdk/preview-route-observer.ts | 31 ++++++++++ tests/commands/sdk/preview-proxy.spec.ts | 67 +++++++++++++--------- 3 files changed, 94 insertions(+), 35 deletions(-) create mode 100644 src/commands/sdk/preview-route-observer.ts diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index cca68f30..802601e2 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as http from "node:http"; import * as https from "node:https"; import * as net from "node:net"; @@ -6,6 +7,9 @@ import * as tls from "node:tls"; import { FatalError, logger } from "../../core/utils/logger"; import { PreviewCertificate } from "./preview-certificate"; import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts"; +import { PREVIEW_ROUTE_OBSERVER } from "./preview-route-observer"; + +const ROUTE_OBSERVER_HASH = `'sha256-${createHash("sha256").update(PREVIEW_ROUTE_OBSERVER).digest("base64")}'`; export interface PreviewProxyOptions { target: URL; @@ -90,24 +94,35 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise${PREVIEW_ROUTE_OBSERVER}`; + const body = Buffer.from( + /)/i.test(html) + ? html.replace(/]*)>/i, `${bootstrap}`) + : bootstrap + html + ); + responseHeaders["content-length"] = body.length; + delete responseHeaders["transfer-encoding"]; + delete responseHeaders.etag; const csp = headers["content-security-policy"]; res.writeHead(proxyRes.statusCode, { ...responseHeaders, ...headers, - ...(csp === undefined || !allowInline ? {} : { - "content-security-policy": allowInlineScripts(csp, scripts.map(script => script.hash)), - }), + ...(csp === undefined + ? {} + : { + "content-security-policy": allowInlineScripts(csp, [ + ROUTE_OBSERVER_HASH, + ...(allowInline ? scripts.map(script => script.hash) : []), + ]), + }), }); res.end(body); }); diff --git a/src/commands/sdk/preview-route-observer.ts b/src/commands/sdk/preview-route-observer.ts new file mode 100644 index 00000000..1f6d9fe7 --- /dev/null +++ b/src/commands/sdk/preview-route-observer.ts @@ -0,0 +1,31 @@ +// Keep in sync with ems-frontend fragments/sandbox route-observer.ts. +export const PREVIEW_ROUTE_OBSERVER = `(() => { + const delivery = document.getElementById('fragment-delivery'); + const base = delivery ? JSON.parse(delivery.textContent).baseHref.slice(0, -1) : ''; + let last; + let queued = false; + const report = (initial = false) => { + const path = location.pathname; + const url = (path === base ? '/' : path.startsWith(base + '/') ? path.slice(base.length) : path) + + location.search + location.hash; + if (parent === window || url === last) return; + last = url; + parent.postMessage({type: 'ems-fragment-route-report-v1', url, initial}, '*'); + }; + const schedule = () => { + if (queued) return; + queued = true; + queueMicrotask(() => { queued = false; report(); }); + }; + for (const name of ['pushState', 'replaceState']) { + const original = history[name]; + history[name] = function (...args) { + const result = Reflect.apply(original, this, args); + schedule(); + return result; + }; + } + addEventListener('popstate', schedule); + addEventListener('hashchange', schedule); + report(true); +})();`; diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 8d85b8c1..901d048d 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -6,9 +6,20 @@ import { Duplex } from "node:stream"; import { loadOrCreatePreviewCertificate, PreviewCertificate } from "../../../src/commands/sdk/preview-certificate"; import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers"; import { PreviewProxy, PreviewProxyOptions, startPreviewProxy } from "../../../src/commands/sdk/preview-proxy"; +import { PREVIEW_ROUTE_OBSERVER } from "../../../src/commands/sdk/preview-route-observer"; import { loggingTestTransport } from "../../jest.setup"; import { makeTempDir, rmTempDir } from "../../utls/fs-utils"; +const observerHash = "'sha256-" + createHash("sha256").update(PREVIEW_ROUTE_OBSERVER).digest("base64") + "'"; +const observerCsp = LOCAL_PREVIEW_HEADERS["content-security-policy"].replace( + "script-src 'self'", + "script-src 'self' " + observerHash +); +function withObserver(html: string): string { + const script = ""; + return html.includes("") ? html.replace("", "" + script) : script + html; +} + interface ProxyResponse { status: number; headers: http.IncomingHttpHeaders; @@ -143,16 +154,16 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); + expect(response.headers["content-security-policy"]).toBe(observerCsp); expect(response.headers["content-security-policy-report-only"]).toBeUndefined(); expect(response.headers["x-frame-options"]).toBeUndefined(); expect(response.headers["access-control-allow-origin"]).toBe("*"); expect(response.headers["content-type"]).toBe("text/html"); for (const [name, value] of Object.entries(LOCAL_PREVIEW_HEADERS)) { expect(headerCount(response.rawHeaders, name)).toBe(1); - expect(response.headers[name]).toBe(value); + expect(response.headers[name]).toBe(name === "content-security-policy" ? observerCsp : value); } - expect(response.body).toBe(""); + expect(response.body).toBe(withObserver("")); }); it("Should add the preview headers to responses without any headers of their own", async () => { @@ -215,7 +226,7 @@ describe("Preview proxy", () => { it("Should cancel the target request when the client disconnects", async () => { let targetRequestClosed: () => void; - const closed = new Promise(resolve => targetRequestClosed = resolve); + const closed = new Promise(resolve => (targetRequestClosed = resolve)); let clientRequest: http.ClientRequest; const target = await startTarget((req, res) => { res.writeHead(200); @@ -239,8 +250,8 @@ describe("Preview proxy", () => { it("Should not report the target as unreachable when the client disconnects before it answers", async () => { let requestReceived: () => void; let targetRequestClosed: () => void; - const received = new Promise(resolve => requestReceived = resolve); - const closed = new Promise(resolve => targetRequestClosed = resolve); + const received = new Promise(resolve => (requestReceived = resolve)); + const closed = new Promise(resolve => (targetRequestClosed = resolve)); const target = await startTarget((req, res) => { res.on("close", () => targetRequestClosed()); requestReceived(); @@ -307,9 +318,10 @@ describe("Preview proxy", () => { .map(entry => entry.message.trim()); } - const reactPreamble = "import { injectIntoGlobalHook } from \"/@react-refresh\";"; - const reactPage = `` + - "Grüße"; + const reactPreamble = 'import { injectIntoGlobalHook } from "/@react-refresh";'; + const reactPage = + `` + + 'Grüße'; function startHtmlTarget(html: string): Promise { return startTarget((req, res) => { @@ -328,13 +340,13 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(reactPage); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(reactPage))); + expect(response.headers["content-security-policy"]).toBe(observerCsp); + expect(response.body).toBe(withObserver(reactPage)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(withObserver(reactPage)))); } expect(warnings()).toEqual([ `Blocking an inline script in /index.html: "${reactPreamble}". Inline scripts are disabled for published apps. ` + - "You can allow inline scripts during development by passing --allow-inline-scripts.", + "You can allow inline scripts during development by passing --allow-inline-scripts.", ]); }); @@ -347,16 +359,17 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(scriptSrc(response)).toEqual(["'self'", sha256(inline)]); - expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")) - .toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(html); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(html))); + expect(scriptSrc(response)).toEqual(["'self'", observerHash, sha256(inline)]); + expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")).toBe( + observerCsp + ); + expect(response.body).toBe(withObserver(html)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(withObserver(html)))); expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); } expect(warnings()).toEqual([ `Allowing an inline script in /index.html for the local preview: "${inline}". ` + - "Inline scripts are disabled for published apps, so they can only be used during development.", + "Inline scripts are disabled for published apps, so they can only be used during development.", ]); }); @@ -370,22 +383,22 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]); - expect(response.body).toBe(""); - expect(response.headers["content-length"]).toBeUndefined(); + expect(scriptSrc(response)).toEqual(["'self'", observerHash, sha256("a()")]); + expect(response.body).toBe(withObserver("")); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(response.body))); }); it("Should leave the CSP of non-HTML responses unchanged", async () => { const target = await startTarget((req, res) => { res.writeHead(200, { "content-type": "text/javascript" }); - res.end("document.write(\"\");"); + res.end('document.write("");'); }); const proxy = await startProxy(targetUrl(target)); const response = await send(proxy, "/main.js"); expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe("document.write(\"\");"); + expect(response.body).toBe('document.write("");'); expect(warnings()).toEqual([]); }); @@ -396,7 +409,7 @@ describe("Preview proxy", () => { res.end(); return; } - res.writeHead(304, { "content-type": "text/html", etag: "\"v1\"" }); + res.writeHead(304, { "content-type": "text/html", etag: '"v1"' }); res.end(); }); const proxy = await startProxy(targetUrl(target)); @@ -409,7 +422,7 @@ describe("Preview proxy", () => { expect(head.body).toBe(""); expect(head.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); expect(notModified.status).toBe(304); - expect(notModified.headers.etag).toBe("\"v1\""); + expect(notModified.headers.etag).toBe('"v1"'); expect(notModified.body).toBe(""); }); @@ -431,7 +444,7 @@ describe("Preview proxy", () => { const next = await send(proxy, "/"); expect(next.status).toBe(200); - expect(next.body).toBe(""); + expect(next.body).toBe(withObserver("")); expect(warnings()).toEqual([]); });