diff --git a/docs/command-graph.html b/docs/command-graph.html
index a79bd426..309b4b34 100644
--- a/docs/command-graph.html
+++ b/docs/command-graph.html
@@ -444,6 +444,9 @@
{ id: "sdk_publish", label: "publish (beta)", group: "command", path: "sdk publish",
description: "Publish a build of a custom app as a new version of its Codespace asset.",
options: ["-p, --profile ", "--appId ", "--dist ", "-h, --help"] },
+ { id: "sdk_preview", label: "preview (beta)", group: "command", path: "sdk preview",
+ description: "Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules",
+ options: ["--target ", "--port ", "--allow-inline-scripts", "-h, --help"] },
// git profile
{ id: "git_profile_area", label: "profile", group: "subarea", path: "git profile",
@@ -546,7 +549,7 @@
["deployment_list_area","deployment_list_history"],["deployment_list_area","deployment_list_active"],
["deployment_list_area","deployment_list_deployables"],["deployment_list_area","deployment_list_targets"],
- ["area_sdk","sdk_download"],["area_sdk","sdk_publish"],
+ ["area_sdk","sdk_download"],["area_sdk","sdk_publish"],["area_sdk","sdk_preview"],
["area_git","git_profile_area"],["git_profile_area","git_profile_list"],
["git_profile_area","git_profile_create"],["git_profile_area","git_profile_default"],
diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md
index bbbfb491..0ee2531f 100644
--- a/docs/user-guide/sdk-commands.md
+++ b/docs/user-guide/sdk-commands.md
@@ -27,3 +27,19 @@ content-cli sdk publish --appId --dist
```
The command compresses the given build directory and uploads it for the app. On success, the version assigned to the build is printed, which can then be selected on the Codespace asset.
+
+## Preview App
+
+Serve a local dev server to the Local preview of a Codespace asset, with the same sandbox rules as the published app.
+
+```
+content-cli sdk preview [--target ] [--port ] [--allow-inline-scripts]
+```
+
+- `--target` is the origin of your app's dev server. Defaults to `http://localhost:4200`.
+- `--port` is the HTTPS port the Local preview loads. Defaults to `4300`.
+- `--allow-inline-scripts` allows inline scripts in your dev server's HTML pages. Off by default.
+
+The command does not need a profile. It serves your dev server over HTTPS with a self-signed certificate stored in `~/.celonis-content-cli-preview`, and forwards requests and WebSocket connections to the target. If the Local preview stays blank, open the printed `https://localhost:` URL in a browser tab, accept the certificate, and reload the preview. Press Ctrl+C to stop.
+
+Published apps block inline scripts, and so does the Local preview by default. Some dev servers inject one, such as React's Fast Refresh preamble, and the app stays blank without it. Pass `--allow-inline-scripts` to allow them: the command adds a hash of each inline script to the page's `script-src`. Either way it prints a warning for each inline script the first time it sees it. Move any inline script of your own into a separate file before publishing.
diff --git a/package.json b/package.json
index a383927e..9ccb4fd7 100644
--- a/package.json
+++ b/package.json
@@ -18,17 +18,18 @@
"node": ">=18.20.5"
},
"dependencies": {
+ "@github/keytar": "7.10.6",
"adm-zip": "0.6.0",
- "chalk": "4.1.2",
- "uuid": "9.0.1",
"axios": "1.18.0",
+ "chalk": "4.1.2",
"commander": "13.1.0",
"form-data": "4.0.6",
- "openid-client": "5.6.1",
"hpagent": "1.2.0",
- "@github/keytar": "7.10.6",
+ "openid-client": "5.6.1",
+ "selfsigned": "5.5.0",
"semver": "7.6.3",
"simple-git": "3.36.0",
+ "uuid": "9.0.1",
"valid-url": "1.0.9",
"winston": "3.17.0",
"yaml": "2.8.3"
diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts
index 267949bc..aca85c38 100644
--- a/src/commands/sdk/module.ts
+++ b/src/commands/sdk/module.ts
@@ -1,6 +1,7 @@
import { Configurator, IModule } from "../../core/command/module-handler";
import { Context } from "../../core/command/cli-context";
import { Command, OptionValues } from "commander";
+import { SdkPreviewService } from "./sdk-preview.service";
import { SdkService } from "./sdk.service";
class Module extends IModule {
@@ -24,6 +25,16 @@ class Module extends IModule {
.requiredOption("--appId ", "Identifier of the app to publish the build for")
.requiredOption("--dist ", "Directory containing the build of the app")
.action(this.publishApp);
+
+ sdkCommand
+ .command("preview")
+ .beta()
+ .skipProfileLoading()
+ .description("Proxy your local dev server to preview the app inside Celonis")
+ .option("--target ", "URL of the local dev server to proxy", "http://localhost:4200")
+ .option("--port ", "Target port on which the preview is hosted", "4300")
+ .option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML")
+ .action(this.previewApp);
}
private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise {
@@ -33,6 +44,10 @@ class Module extends IModule {
private async publishApp(context: Context, command: Command, options: OptionValues): Promise {
await new SdkService(context).publishApp(options.appId, options.dist);
}
+
+ private async previewApp(context: Context, command: Command, options: OptionValues): Promise {
+ await new SdkPreviewService().previewUntilStopped(options.target, options.port, options.allowInlineScripts === true);
+ }
}
export = Module;
diff --git a/src/commands/sdk/preview-certificate.ts b/src/commands/sdk/preview-certificate.ts
new file mode 100644
index 00000000..d0acac71
--- /dev/null
+++ b/src/commands/sdk/preview-certificate.ts
@@ -0,0 +1,66 @@
+import { createPrivateKey, X509Certificate } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { generate } from "selfsigned";
+
+export interface PreviewCertificate {
+ key: string;
+ cert: string;
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const CERT_FILE = "localhost-cert.pem";
+const KEY_FILE = "localhost-key.pem";
+
+export async function loadOrCreatePreviewCertificate(directory: string, now: Date = new Date()): Promise {
+ const existing = readUsableCertificate(directory, now);
+ if (existing) {
+ return existing;
+ }
+
+ const generated = await generate([{ name: "commonName", value: "localhost" }], {
+ keyType: "ec",
+ curve: "P-256",
+ algorithm: "sha256",
+ notBeforeDate: now,
+ notAfterDate: new Date(now.getTime() + 365 * DAY_MS),
+ extensions: [
+ {
+ name: "subjectAltName",
+ altNames: [
+ { type: 2, value: "localhost" },
+ { type: 7, ip: "127.0.0.1" },
+ { type: 7, ip: "::1" },
+ ],
+ },
+ ],
+ });
+ const certificate: PreviewCertificate = { key: generated.private, cert: generated.cert };
+
+ mkdirSync(directory, { recursive: true });
+ writeFileSync(join(directory, CERT_FILE), certificate.cert);
+ writeFileSync(join(directory, KEY_FILE), certificate.key, { mode: 0o600 });
+ return certificate;
+}
+
+function readUsableCertificate(directory: string, now: Date): PreviewCertificate | null {
+ const certPath = join(directory, CERT_FILE);
+ const keyPath = join(directory, KEY_FILE);
+ if (!existsSync(certPath) || !existsSync(keyPath)) {
+ return null;
+ }
+ try {
+ const cert = readFileSync(certPath, "utf8");
+ const key = readFileSync(keyPath, "utf8");
+ const x509 = new X509Certificate(cert);
+ if (!x509.checkPrivateKey(createPrivateKey(key))) {
+ return null;
+ }
+ if (new Date(x509.validTo).getTime() - now.getTime() <= DAY_MS) {
+ return null;
+ }
+ return { key, cert };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/commands/sdk/preview-headers.ts b/src/commands/sdk/preview-headers.ts
new file mode 100644
index 00000000..286fd45c
--- /dev/null
+++ b/src/commands/sdk/preview-headers.ts
@@ -0,0 +1,14 @@
+/**
+ * Copied from `localPreviewHeaders()` in ems-frontend `libs/common/fragments/sandbox/src/lib/policy.ts`,
+ * whose spec fails when the gateway policy drifts. Update both together.
+ */
+export const LOCAL_PREVIEW_HEADERS: Readonly> = {
+ "content-security-policy":
+ "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts",
+ "permissions-policy":
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), clipboard-read=(), clipboard-write=(), display-capture=()",
+ "access-control-allow-origin": "*",
+ "referrer-policy": "no-referrer",
+ "x-content-type-options": "nosniff",
+ "cache-control": "no-store",
+};
diff --git a/src/commands/sdk/preview-inline-scripts.ts b/src/commands/sdk/preview-inline-scripts.ts
new file mode 100644
index 00000000..c7378853
--- /dev/null
+++ b/src/commands/sdk/preview-inline-scripts.ts
@@ -0,0 +1,45 @@
+import { createHash } from "node:crypto";
+
+export interface InlineScript {
+ /** `'sha256-'`, quoted and ready for a CSP source list. */
+ hash: string;
+ /** The first 60 characters of the script text, with whitespace collapsed. */
+ excerpt: string;
+}
+
+const SCRIPT_ELEMENT = /" +
+ "";
+
+ expect(inlineScriptHashes(html).map(script => script.hash)).toEqual([
+ sha256("import { injectIntoGlobalHook } from \"/@react-refresh\";"),
+ sha256("a()"),
+ ]);
+ });
+
+ it("Should list a script that appears twice only once", () => {
+ const html = "";
+
+ expect(inlineScriptHashes(html).map(script => script.hash)).toEqual([sha256("a()"), sha256("b()")]);
+ });
+
+ it("Should keep whitespace and non-ASCII characters in the hashed text", () => {
+ const text = "\n window.greeting = \"Grüße\";\n";
+
+ expect(inlineScriptHashes(``)[0].hash).toBe(sha256(text));
+ });
+
+ it("Should hash the text with line endings normalized to LF, as the HTML parser does", () => {
+ const html = "";
+
+ expect(inlineScriptHashes(html)[0].hash).toBe(sha256("\na();\nb();\nc();\n"));
+ });
+
+ it("Should treat src in any case or position as an external script", () => {
+ const html = "";
+
+ expect(inlineScriptHashes(html).map(script => script.hash)).toEqual([sha256("c()")]);
+ });
+
+ it("Should skip empty inline scripts", () => {
+ expect(inlineScriptHashes("")).toEqual([]);
+ });
+
+ it("Should give an excerpt of the first 60 characters with whitespace collapsed", () => {
+ const text = "\n import { injectIntoGlobalHook } from \"/@react-refresh\";\n injectIntoGlobalHook(window);\n";
+
+ expect(inlineScriptHashes(``)[0].excerpt)
+ .toBe("import { injectIntoGlobalHook } from \"/@react-refresh\"; inje");
+ });
+ });
+
+ describe("allowInlineScripts", () => {
+ it("Should add hashes to script-src only", () => {
+ expect(allowInlineScripts("default-src 'none'; script-src 'self'; style-src 'self'", ["'sha256-x'"]))
+ .toBe("default-src 'none'; script-src 'self' 'sha256-x'; style-src 'self'");
+ });
+
+ it("Should add several hashes in order", () => {
+ expect(allowInlineScripts("script-src 'self'; style-src 'self'", ["'sha256-x'", "'sha256-y'"]))
+ .toBe("script-src 'self' 'sha256-x' 'sha256-y'; style-src 'self'");
+ });
+
+ it("Should leave the policy unchanged without hashes", () => {
+ const csp = "default-src 'none'; script-src 'self'";
+
+ expect(allowInlineScripts(csp, [])).toBe(csp);
+ });
+ });
+});
diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts
new file mode 100644
index 00000000..8d85b8c1
--- /dev/null
+++ b/tests/commands/sdk/preview-proxy.spec.ts
@@ -0,0 +1,478 @@
+import { createHash } from "node:crypto";
+import * as http from "node:http";
+import * as https from "node:https";
+import * as net from "node:net";
+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 { loggingTestTransport } from "../../jest.setup";
+import { makeTempDir, rmTempDir } from "../../utls/fs-utils";
+
+interface ProxyResponse {
+ status: number;
+ headers: http.IncomingHttpHeaders;
+ rawHeaders: string[];
+ body: string;
+}
+
+describe("Preview proxy", () => {
+ let dir: string;
+ let certificate: PreviewCertificate;
+ let targets: http.Server[];
+ let sockets: Duplex[];
+ let proxies: PreviewProxy[];
+ let blockers: net.Server[];
+
+ beforeEach(async () => {
+ dir = makeTempDir();
+ certificate = await loadOrCreatePreviewCertificate(dir);
+ targets = [];
+ sockets = [];
+ proxies = [];
+ blockers = [];
+ });
+
+ afterEach(async () => {
+ sockets.forEach(socket => socket.destroy());
+ await Promise.all(proxies.map(proxy => proxy.close()));
+ await Promise.all(targets.map(target => new Promise(resolve => {
+ target.closeAllConnections();
+ target.close(() => resolve());
+ })));
+ await Promise.all(blockers.map(blocker => new Promise(resolve => blocker.close(() => resolve()))));
+ rmTempDir(dir);
+ });
+
+ function freePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(0, "localhost", () => {
+ const port = (server.address() as net.AddressInfo).port;
+ server.close(() => resolve(port));
+ });
+ });
+ }
+
+ function startTarget(handler: http.RequestListener, port: number = 0): Promise {
+ const target = http.createServer(handler);
+ targets.push(target);
+ return new Promise((resolve, reject) => {
+ target.once("error", reject);
+ target.listen(port, "localhost", () => resolve(target));
+ });
+ }
+
+ function targetUrl(target: http.Server): URL {
+ return new URL(`http://localhost:${(target.address() as net.AddressInfo).port}`);
+ }
+
+ async function startProxy(target: URL, allowInlineScripts: boolean = false): Promise {
+ const proxy = await startPreviewProxy({
+ target,
+ port: await freePort(),
+ certificate,
+ headers: LOCAL_PREVIEW_HEADERS,
+ allowInlineScripts,
+ });
+ proxies.push(proxy);
+ return proxy;
+ }
+
+ function send(proxy: PreviewProxy, path: string, method: string = "GET", body?: string): Promise {
+ return new Promise((resolve, reject) => {
+ const request = https.request(`${proxy.url}${path}`, { method, ca: certificate.cert, agent: false }, response => {
+ const chunks: Buffer[] = [];
+ response.on("data", chunk => chunks.push(chunk));
+ response.on("end", () => resolve({
+ status: response.statusCode,
+ headers: response.headers,
+ rawHeaders: response.rawHeaders,
+ body: Buffer.concat(chunks).toString(),
+ }));
+ response.on("error", reject);
+ });
+ request.on("error", reject);
+ request.end(body);
+ });
+ }
+
+ function headerCount(rawHeaders: string[], name: string): number {
+ return rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === name).length;
+ }
+
+ it("Should forward method, path, query and body to the target and return its status and body", async () => {
+ const target = await startTarget((req, res) => {
+ const chunks: Buffer[] = [];
+ req.on("data", chunk => chunks.push(chunk));
+ req.on("end", () => {
+ res.writeHead(201);
+ res.end(`${req.method} ${req.url} ${Buffer.concat(chunks).toString()}`);
+ });
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ const response = await send(proxy, "/api/x?y=1", "POST", "hi");
+
+ expect(proxy.url).toMatch(/^https:\/\/localhost:\d+$/);
+ expect(response.status).toBe(201);
+ expect(response.body).toBe("POST /api/x?y=1 hi");
+ });
+
+ it("Should rewrite the host header to the target", async () => {
+ const target = await startTarget((req, res) => res.end(req.headers.host));
+ const proxy = await startProxy(targetUrl(target));
+
+ const response = await send(proxy, "/");
+
+ expect(response.body).toBe(targetUrl(target).host);
+ });
+
+ it("Should replace the target's security headers with the preview headers", async () => {
+ const target = await startTarget((req, res) => {
+ res.setHeader("Content-Security-Policy", "default-src *");
+ res.setHeader("Content-Security-Policy-Report-Only", "default-src *");
+ res.setHeader("X-Frame-Options", "DENY");
+ res.setHeader("Access-Control-Allow-Origin", "https://evil.example");
+ res.setHeader("Cache-Control", "max-age=3600");
+ res.setHeader("Content-Type", "text/html");
+ res.end("");
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ const response = await send(proxy, "/");
+
+ expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
+ 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.body).toBe("");
+ });
+
+ it("Should add the preview headers to responses without any headers of their own", async () => {
+ const target = await startTarget((req, res) => res.end());
+ const proxy = await startProxy(targetUrl(target));
+
+ const response = await send(proxy, "/");
+
+ expect(response.status).toBe(200);
+ for (const [name, value] of Object.entries(LOCAL_PREVIEW_HEADERS)) {
+ expect(headerCount(response.rawHeaders, name)).toBe(1);
+ expect(response.headers[name]).toBe(value);
+ }
+ });
+
+ it("Should answer 502 naming the target while it is unreachable and recover once it is up", async () => {
+ const port = await freePort();
+ const target = new URL(`http://localhost:${port}`);
+ const proxy = await startProxy(target);
+
+ const first = await send(proxy, "/");
+ const second = await send(proxy, "/");
+
+ expect(first.status).toBe(502);
+ expect(first.headers["content-type"]).toBe("text/plain");
+ expect(first.body).toContain(target.origin);
+ expect(first.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
+ expect(second.status).toBe(502);
+ const warnings = loggingTestTransport.logMessages.filter(entry => entry.level.includes("warn"));
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0].message.trim()).toBe(first.body);
+
+ await startTarget((req, res) => res.end("up"), port);
+
+ const recovered = await send(proxy, "/");
+
+ expect(recovered.status).toBe(200);
+ expect(recovered.body).toBe("up");
+ });
+
+ it("Should abort the response when the target fails mid-response and keep serving", async () => {
+ let calls = 0;
+ const target = await startTarget((req, res) => {
+ calls++;
+ if (calls === 1) {
+ res.writeHead(200);
+ res.write("partial");
+ setImmediate(() => res.destroy());
+ return;
+ }
+ res.end("ok");
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ await expect(send(proxy, "/")).rejects.toThrow();
+ const next = await send(proxy, "/");
+
+ expect(next.body).toBe("ok");
+ });
+
+ it("Should cancel the target request when the client disconnects", async () => {
+ let targetRequestClosed: () => void;
+ const closed = new Promise(resolve => targetRequestClosed = resolve);
+ let clientRequest: http.ClientRequest;
+ const target = await startTarget((req, res) => {
+ res.writeHead(200);
+ res.write("streaming");
+ res.on("close", () => targetRequestClosed());
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ await new Promise(resolve => {
+ clientRequest = https.request(`${proxy.url}/stream`, { ca: certificate.cert, agent: false }, response => {
+ response.once("data", () => resolve());
+ });
+ clientRequest.on("error", () => undefined);
+ clientRequest.end();
+ });
+ clientRequest.destroy();
+
+ await expect(closed).resolves.toBeUndefined();
+ });
+
+ 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 target = await startTarget((req, res) => {
+ res.on("close", () => targetRequestClosed());
+ requestReceived();
+ });
+ const proxy = await startProxy(targetUrl(target));
+ const clientRequest = https.request(`${proxy.url}/slow`, { ca: certificate.cert, agent: false });
+ clientRequest.on("error", () => undefined);
+ clientRequest.end();
+
+ await received;
+ clientRequest.destroy();
+ await closed;
+ await new Promise(resolve => setImmediate(resolve));
+
+ expect(loggingTestTransport.logMessages.filter(entry => entry.level.includes("warn"))).toHaveLength(0);
+ });
+
+ it("Should forward WebSocket upgrades to the target", async () => {
+ const target = await startTarget((req, res) => res.end());
+ let upgradeHost: string;
+ target.on("upgrade", (req: http.IncomingMessage, socket: Duplex) => {
+ sockets.push(socket);
+ upgradeHost = req.headers.host;
+ socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
+ socket.pipe(socket);
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ const echoed = await new Promise((resolve, reject) => {
+ const request = https.request(`${proxy.url}/ws`, {
+ ca: certificate.cert,
+ agent: false,
+ headers: { Connection: "Upgrade", Upgrade: "websocket" },
+ });
+ request.on("upgrade", (response, socket) => {
+ sockets.push(socket);
+ expect(response.statusCode).toBe(101);
+ socket.once("data", data => resolve(data.toString()));
+ socket.write("ping");
+ });
+ request.on("error", reject);
+ request.end();
+ });
+
+ expect(echoed).toBe("ping");
+ expect(upgradeHost).toBe(targetUrl(target).host);
+ });
+
+ function sha256(text: string): string {
+ return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
+ }
+
+ function scriptSrc(response: ProxyResponse): string[] {
+ const directive = (response.headers["content-security-policy"] as string)
+ .split(";")
+ .map(part => part.trim())
+ .find(part => part.startsWith("script-src "));
+ return directive.split(/\s+/).slice(1);
+ }
+
+ function warnings(): string[] {
+ return loggingTestTransport.logMessages
+ .filter(entry => entry.level.includes("warn"))
+ .map(entry => entry.message.trim());
+ }
+
+ const reactPreamble = "import { injectIntoGlobalHook } from \"/@react-refresh\";";
+ const reactPage = `` +
+ "Grüße";
+
+ function startHtmlTarget(html: string): Promise {
+ return startTarget((req, res) => {
+ res.writeHead(200, {
+ "content-type": "text/html; charset=utf-8",
+ "content-length": Buffer.byteLength(html),
+ });
+ res.end(html);
+ });
+ }
+
+ it("Should block the inline scripts of a proxied HTML page by default and warn about each once", async () => {
+ const proxy = await startProxy(targetUrl(await startHtmlTarget(reactPage)));
+
+ const first = await send(proxy, "/index.html");
+ 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(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.",
+ ]);
+ });
+
+ it("Should allow the inline scripts of a proxied HTML page when enabled and warn about each once", async () => {
+ const inline = reactPreamble;
+ const html = reactPage;
+ const proxy = await startProxy(targetUrl(await startHtmlTarget(html)), true);
+
+ const first = await send(proxy, "/index.html");
+ 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(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.",
+ ]);
+ });
+
+ it("Should allow the inline scripts of an HTML page sent without a content-length", async () => {
+ const target = await startTarget((req, res) => {
+ res.writeHead(200, { "content-type": "text/html" });
+ res.write(""));
+ });
+ const proxy = await startProxy(targetUrl(target), true);
+
+ const response = await send(proxy, "/");
+
+ expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]);
+ expect(response.body).toBe("");
+ expect(response.headers["content-length"]).toBeUndefined();
+ });
+
+ 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(\"\");");
+ });
+ 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(warnings()).toEqual([]);
+ });
+
+ it("Should pass HEAD and 304 responses for HTML through without a body", async () => {
+ const target = await startTarget((req, res) => {
+ if (req.method === "HEAD") {
+ res.writeHead(200, { "content-type": "text/html", "content-length": "42" });
+ res.end();
+ return;
+ }
+ res.writeHead(304, { "content-type": "text/html", etag: "\"v1\"" });
+ res.end();
+ });
+ const proxy = await startProxy(targetUrl(target));
+
+ const head = await send(proxy, "/", "HEAD");
+ const notModified = await send(proxy, "/");
+
+ expect(head.status).toBe(200);
+ expect(head.headers["content-length"]).toBe("42");
+ 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.body).toBe("");
+ });
+
+ it("Should abort an HTML response when the target fails mid-body and keep serving", async () => {
+ let calls = 0;
+ const target = await startTarget((req, res) => {
+ calls++;
+ res.writeHead(200, { "content-type": "text/html" });
+ if (calls === 1) {
+ res.write("