From 38d7945551fcb31d0802ac46cb3755950bbaeabb Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 14:05:49 +0100 Subject: [PATCH 01/11] feat: add local preview headers and certificate Co-Authored-By: Claude Includes-AI-Code: true --- package.json | 9 +- src/commands/sdk/preview-certificate.ts | 66 ++++++ src/commands/sdk/preview-headers.ts | 14 ++ .../commands/sdk/preview-certificate.spec.ts | 48 +++++ yarn.lock | 200 +++++++++++++++++- 5 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 src/commands/sdk/preview-certificate.ts create mode 100644 src/commands/sdk/preview-headers.ts create mode 100644 tests/commands/sdk/preview-certificate.spec.ts diff --git a/package.json b/package.json index a80ea0c9..50b4821c 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/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/tests/commands/sdk/preview-certificate.spec.ts b/tests/commands/sdk/preview-certificate.spec.ts new file mode 100644 index 00000000..6f13ec8c --- /dev/null +++ b/tests/commands/sdk/preview-certificate.spec.ts @@ -0,0 +1,48 @@ +import { createPrivateKey, X509Certificate } from "node:crypto"; +import { mkdirSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadOrCreatePreviewCertificate } from "../../../src/commands/sdk/preview-certificate"; +import { makeTempDir, rmTempDir } from "../../utls/fs-utils"; + +describe("Preview certificate", () => { + let dir: string; + + beforeEach(() => { + dir = makeTempDir(); + }); + + afterEach(() => { + rmTempDir(dir); + }); + + it("Should create a localhost certificate and reuse it on the next run", async () => { + const first = await loadOrCreatePreviewCertificate(dir); + const second = await loadOrCreatePreviewCertificate(dir); + expect(second).toEqual(first); + const x509 = new X509Certificate(first.cert); + expect(x509.subjectAltName).toContain("DNS:localhost"); + expect(x509.subjectAltName).toContain("IP Address:127.0.0.1"); + expect(x509.checkPrivateKey(createPrivateKey(first.key))).toBe(true); + }); + + it("Should regenerate a certificate that expires within a day", async () => { + const first = await loadOrCreatePreviewCertificate(dir); + const nextYear = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + expect((await loadOrCreatePreviewCertificate(dir, nextYear)).cert).not.toBe(first.cert); + }); + + it("Should regenerate unreadable certificate files", async () => { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "localhost-cert.pem"), "garbage"); + writeFileSync(join(dir, "localhost-key.pem"), "garbage"); + const certificate = await loadOrCreatePreviewCertificate(dir); + expect(() => new X509Certificate(certificate.cert)).not.toThrow(); + }); + + it("Should keep the private key readable only by the user", async () => { + await loadOrCreatePreviewCertificate(dir); + if (process.platform !== "win32") { + expect(statSync(join(dir, "localhost-key.pem")).mode & 0o077).toBe(0); + } + }); +}); diff --git a/yarn.lock b/yarn.lock index 07eba0a8..3e6b5e99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -660,6 +660,141 @@ resolved "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@noble/hashes@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@peculiar/asn1-cms@^2.11.1", "@peculiar/asn1-cms@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.11.1.tgz#d097d7e4a4266fd37d08228444bd521f9286db58" + integrity sha512-Bk+oatE62dW0Tzzpfk6vdQxfGGEfvuGdWgmijnKjcv7HqZCJISs85z4zw2uDArrk+R1Oel1wts/BBl7HHrtAcA== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + "@peculiar/asn1-x509-attr" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-csr@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.11.1.tgz#bffbe1efd42b0f58834261e207331389d447d609" + integrity sha512-KVfbXaKng8886/tOPG55t5K1XkTkGbR4amjIJzytXWobFU5nZgw/sKnSpa1hxP17nUSsrOF/7Mt9WUYeyLsi6Q== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-ecc@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.11.1.tgz#175f6d47ea202ca335c10cc8a3d2c81faf3e34e3" + integrity sha512-7MSNji77fQa/oN3WAfwagMy8EoJFg50sW4hZ1dtr0zU1BSugit0I2DGpIdPhvkNplopQhz5bogxpn1077eaamA== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-pfx@^2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.11.1.tgz#d71b5a8d7efc088f53d5beb7c8dff11ac8a6800a" + integrity sha512-2SN2z8qkc2hk/GgTYA8RN54wtsCDZShg9gI2Mq1vLhdj1adcv5fhTbiEmExZUaAMqlizxiEpULQDfHM5iYtn1g== + dependencies: + "@peculiar/asn1-cms" "^2.11.1" + "@peculiar/asn1-pkcs8" "^2.11.1" + "@peculiar/asn1-rsa" "^2.11.1" + "@peculiar/asn1-schema" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs8@^2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.11.1.tgz#b29a7979768743d33a18a66b6f9d75163b639a47" + integrity sha512-HqsSrnpQghRp9SM9QSmR6fa4z/BNktnKq7928+oq4qF0VAnuHZ4cyePKvcxh9fP4taBCP/WHNItlVNsYaQm2qw== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs9@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.11.1.tgz#854571d611d3e3a889e964fb075da8360d4f2b5e" + integrity sha512-OHFkTg8D2zYafILqFE4KPbqX8srpvd3diGDc2h2cduumawc765mOHGcuFMGvLWlL2bbCMWMmMSCtlXptsOuiPA== + dependencies: + "@peculiar/asn1-cms" "^2.11.1" + "@peculiar/asn1-pfx" "^2.11.1" + "@peculiar/asn1-pkcs8" "^2.11.1" + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + "@peculiar/asn1-x509-attr" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-rsa@^2.11.1", "@peculiar/asn1-rsa@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.11.1.tgz#bb0ab67f514763f1aa37ab1612c86a392eccfe51" + integrity sha512-6KwKGjulF489CpidZqkogNgLj6oGzgC4BL2FClM8PuJQ4t5NqT4q4a6A4vrSxBIBhMnL8hGYkLOpbVw0Vq38Tw== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-schema@^2.11.1", "@peculiar/asn1-schema@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz#cb6d532bed05326847f92a0c6b7bc76d7e7dfff6" + integrity sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g== + dependencies: + "@peculiar/utils" "^2.0.2" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-x509-attr@^2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.11.1.tgz#9978203228b38b80d35b221973f51e5db6ca3f55" + integrity sha512-n7RKFrbTzt8pGBlZ90kmKEn1qsLTTyw7lrvHMK0xsA42kQugPk/GXYpNbEcl7DkkdVsU/one5UX7KQPfxLj8vQ== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/asn1-x509" "^2.11.1" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/asn1-x509@^2.11.1", "@peculiar/asn1-x509@^2.6.0": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.11.1.tgz#4fd12c118be06a55ea8828a1bc86fd5d3a45d636" + integrity sha512-SXU4/X6B3FquqQAN6ARI0bFDUV7+L1WlvPEPMKzEMkstd2D7UyM0r93fv241+OjdhqAKYGx5MvV3gzZMAp0APA== + dependencies: + "@peculiar/asn1-schema" "^2.11.1" + "@peculiar/utils" "^2.0.2" + asn1js "^3.0.10" + tslib "^2.8.1" + +"@peculiar/utils@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@peculiar/utils/-/utils-2.0.3.tgz#a27ca4c4b73652e110f19a7d16d664f458a5528e" + integrity sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ== + dependencies: + tslib "^2.8.1" + +"@peculiar/x509@^1.14.2": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.3.tgz#2c44c2b89474346afec38a0c2803ec4fb8ce959e" + integrity sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-csr" "^2.6.0" + "@peculiar/asn1-ecc" "^2.6.0" + "@peculiar/asn1-pkcs9" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + pvtsutils "^1.3.6" + reflect-metadata "^0.2.2" + tslib "^2.8.1" + tsyringe "^4.10.0" + "@simple-git/args-pathspec@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz#9ef4a2ad5f49ab4056362d03f93f775b93118ca5" @@ -906,6 +1041,15 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +asn1js@^3.0.10, asn1js@^3.0.6: + version "3.1.0" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.1.0.tgz#332de2e8241f229531013990d1ecda958aaf5cf5" + integrity sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.2.0" + tslib "^2.8.1" + async@^3.2.3: version "3.2.6" resolved "https://registry.npmjs.org/async/-/async-3.2.6.tgz" @@ -1050,6 +1194,11 @@ builtin-modules@^1.1.1: resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz" integrity sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ== +bytestreamjs@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz#a32947c7ce389a6fa11a09a9a563d0a45889535e" + integrity sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ== + call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" @@ -2595,6 +2744,18 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pkijs@^3.3.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/pkijs/-/pkijs-3.4.3.tgz#3d94b67b86f77474c9024f1992c2f6b56c246e9e" + integrity sha512-V9kOzaItwDgqlryhXysQsAXUEKi5O7kqWUaP3dXI7MLvkPqWWMb4wVb1QAtmEDCKI+XIAINsZoNKMFF7sZb1eg== + dependencies: + "@noble/hashes" "1.8.0" + asn1js "^3.0.6" + bytestreamjs "^2.0.1" + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + prettier@3.4.2: version "3.4.2" resolved "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz" @@ -2627,6 +2788,18 @@ pure-rand@^6.0.0: resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3, pvutils@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.2.0.tgz#4b5487a9cccd52d275b0538775790a3ca982bc22" + integrity sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg== + react-is@^18.0.0: version "18.3.1" resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" @@ -2646,6 +2819,11 @@ reflect-metadata@^0.1.12: resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz" integrity sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A== +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" @@ -2700,6 +2878,14 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== +selfsigned@5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-5.5.0.tgz#4c9ab7c7c9f35f18fb6a9882c253eb0e6bd6557b" + integrity sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew== + dependencies: + "@peculiar/x509" "^1.14.2" + pkijs "^3.3.3" + semver@7.6.3, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3: version "7.6.3" resolved "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz" @@ -2974,11 +3160,16 @@ ts-node@10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1: +tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.3: version "1.14.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslint-config-prettier@1.18.0: version "1.18.0" resolved "https://registry.npmjs.org/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz" @@ -3026,6 +3217,13 @@ tsutils@^3.5.0: dependencies: tslib "^1.8.1" +tsyringe@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/tsyringe/-/tsyringe-4.10.0.tgz#d0c95815d584464214060285eaaadd94aa03299c" + integrity sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw== + dependencies: + tslib "^1.9.3" + type-detect@4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" From 9410e1ed1e4e76374acab7f08be5d5d25cabdbc6 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 14:13:20 +0100 Subject: [PATCH 02/11] feat: add local preview proxy Co-Authored-By: Claude Includes-AI-Code: true --- src/commands/sdk/preview-proxy.ts | 152 ++++++++++++ tests/commands/sdk/preview-proxy.spec.ts | 300 +++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 src/commands/sdk/preview-proxy.ts create mode 100644 tests/commands/sdk/preview-proxy.spec.ts diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts new file mode 100644 index 00000000..41a40f38 --- /dev/null +++ b/src/commands/sdk/preview-proxy.ts @@ -0,0 +1,152 @@ +import * as http from "node:http"; +import * as https from "node:https"; +import * as net from "node:net"; +import { Duplex } from "node:stream"; +import * as tls from "node:tls"; +import { FatalError, logger } from "../../core/utils/logger"; +import { PreviewCertificate } from "./preview-certificate"; + +export interface PreviewProxyOptions { + target: URL; + port: number; + certificate: PreviewCertificate; + headers: Readonly>; +} + +export interface PreviewProxy { + url: string; + close(): Promise; +} + +const REPLACED_HEADERS = ["content-security-policy", "content-security-policy-report-only", "x-frame-options"]; +const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"]; + +export function startPreviewProxy(options: PreviewProxyOptions): Promise { + const { target, port, certificate, headers } = options; + const secureTarget = target.protocol === "https:"; + const targetHostname = target.hostname.replace(/^\[(.*)\]$/, "$1"); + const targetPort = Number(target.port) || (secureTarget ? 443 : 80); + const rejectUnauthorized = !LOOPBACK_HOSTNAMES.includes(target.hostname); + const agent = secureTarget + ? new https.Agent({ keepAlive: true, rejectUnauthorized }) + : new http.Agent({ keepAlive: true }); + const removedHeaders = new Set([...REPLACED_HEADERS, ...Object.keys(headers).map(name => name.toLowerCase())]); + const unreachableMessage = `content-cli sdk preview can't reach ${target.origin}. Start your dev server or pass --target.`; + const openSockets = new Set(); + let warnedUnreachable = false; + + function track(socket: Duplex): void { + openSockets.add(socket); + socket.once("close", () => openSockets.delete(socket)); + } + + function forwardRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const proxyReq = (secureTarget ? https : http).request({ + protocol: target.protocol, + hostname: targetHostname, + port: targetPort, + method: req.method, + path: req.url, + headers: { ...req.headers, host: target.host }, + agent, + }); + + proxyReq.on("response", proxyRes => { + warnedUnreachable = false; + const responseHeaders: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(proxyRes.headers)) { + if (!removedHeaders.has(name.toLowerCase())) { + responseHeaders[name] = value; + } + } + res.writeHead(proxyRes.statusCode, { ...responseHeaders, ...headers }); + proxyRes.on("error", () => res.destroy()); + proxyRes.on("aborted", () => res.destroy()); + proxyRes.pipe(res); + }); + + proxyReq.on("error", () => { + if (res.destroyed) { + return; + } + if (res.headersSent) { + res.destroy(); + return; + } + if (!warnedUnreachable) { + warnedUnreachable = true; + logger.warn(unreachableMessage); + } + res.writeHead(502, { ...headers, "content-type": "text/plain" }); + res.end(unreachableMessage); + }); + + res.on("close", () => { + if (!res.writableFinished) { + proxyReq.destroy(); + } + }); + + req.pipe(proxyReq); + } + + function forwardUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void { + const targetSocket: net.Socket = secureTarget + ? tls.connect({ + host: targetHostname, + port: targetPort, + servername: net.isIP(targetHostname) ? undefined : targetHostname, + rejectUnauthorized, + }) + : net.connect({ host: targetHostname, port: targetPort }); + track(targetSocket); + + const destroyBoth = (): void => { + socket.destroy(); + targetSocket.destroy(); + }; + socket.on("error", destroyBoth); + targetSocket.on("error", destroyBoth); + socket.on("close", () => targetSocket.destroy()); + targetSocket.on("close", () => socket.destroy()); + + targetSocket.once(secureTarget ? "secureConnect" : "connect", () => { + const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]; + lines.push(`${name}: ${name.toLowerCase() === "host" ? target.host : req.rawHeaders[i + 1]}`); + } + targetSocket.write(`${lines.join("\r\n")}\r\n\r\n`); + if (head.length > 0) { + targetSocket.write(head); + } + targetSocket.pipe(socket); + socket.pipe(targetSocket); + }); + } + + const server = https.createServer({ key: certificate.key, cert: certificate.cert }, forwardRequest); + server.on("connection", track); + server.on("upgrade", forwardUpgrade); + + return new Promise((resolve, reject) => { + const onListenError = (error: NodeJS.ErrnoException): void => { + agent.destroy(); + reject(new FatalError(error.code === "EADDRINUSE" + ? `Port ${port} is already in use. Stop the other process or pass --port.` + : `Could not start the preview proxy on port ${port}: ${error.message}`)); + }; + server.once("error", onListenError); + server.listen({ port, host: "localhost" }, () => { + server.off("error", onListenError); + resolve({ + url: `https://localhost:${(server.address() as net.AddressInfo).port}`, + close: () => new Promise(closed => { + server.close(() => closed()); + openSockets.forEach(socket => socket.destroy()); + agent.destroy(); + }), + }); + }); + }); +} diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts new file mode 100644 index 00000000..d750acb2 --- /dev/null +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -0,0 +1,300 @@ +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): Promise { + const proxy = await startPreviewProxy({ target, port: await freePort(), certificate, headers: LOCAL_PREVIEW_HEADERS }); + 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 closed; + }); + + 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); + }); + + it("Should fail with a clear error when the port is already in use", async () => { + const blocker = net.createServer(); + blockers.push(blocker); + const busyPort = await new Promise(resolve => { + blocker.listen(0, "localhost", () => resolve((blocker.address() as net.AddressInfo).port)); + }); + const options: PreviewProxyOptions = { + target: new URL("http://localhost:1"), + port: busyPort, + certificate, + headers: LOCAL_PREVIEW_HEADERS, + }; + + await expect(startPreviewProxy(options)).rejects.toThrow(`Port ${busyPort} is already in use`); + }); +}); From d65124623468a72414242e606b42e5541c8b59bf Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 14:27:04 +0100 Subject: [PATCH 03/11] feat: add sdk preview command Co-Authored-By: Claude Includes-AI-Code: true --- docs/command-graph.html | 5 +- docs/user-guide/sdk-commands.md | 13 ++++++ src/commands/sdk/module.ts | 24 ++++++++++ src/commands/sdk/sdk-preview.service.ts | 61 +++++++++++++++++++++++++ tests/commands/sdk/module.spec.ts | 37 ++++++++++++++- tests/commands/sdk/sdk-preview.spec.ts | 61 +++++++++++++++++++++++++ tests/utls/configurator-mock.ts | 2 + 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/commands/sdk/sdk-preview.service.ts create mode 100644 tests/commands/sdk/sdk-preview.spec.ts diff --git a/docs/command-graph.html b/docs/command-graph.html index a79bd426..6e2893af 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 ", "-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..005a9f5a 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -27,3 +27,16 @@ 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 ] +``` + +- `--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`. + +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. diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts index 267949bc..943f5372 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,15 @@ 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("Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules") + .option("--target ", "URL of your app's dev server", "http://localhost:4200") + .option("--port ", "HTTPS port the Codespace preview loads", "4300") + .action(this.previewApp); } private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise { @@ -33,6 +43,20 @@ 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 { + const proxy = await new SdkPreviewService().preview(options.target, options.port); + await new Promise(resolve => { + const stop = (): void => { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + resolve(); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }); + await proxy.close(); + } } export = Module; diff --git a/src/commands/sdk/sdk-preview.service.ts b/src/commands/sdk/sdk-preview.service.ts new file mode 100644 index 00000000..905b9009 --- /dev/null +++ b/src/commands/sdk/sdk-preview.service.ts @@ -0,0 +1,61 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { FatalError, logger } from "../../core/utils/logger"; +import { loadOrCreatePreviewCertificate } from "./preview-certificate"; +import { LOCAL_PREVIEW_HEADERS } from "./preview-headers"; +import { PreviewProxy, startPreviewProxy } from "./preview-proxy"; + +export class SdkPreviewService { + public static readonly CERTIFICATE_DIRECTORY = path.resolve(os.homedir(), ".celonis-content-cli-preview"); + + constructor(private readonly certificateDirectory: string = SdkPreviewService.CERTIFICATE_DIRECTORY) {} + + public async preview(target: string, port: string | number): Promise { + const targetUrl = this.parseTarget(target); + const portNumber = this.parsePort(port); + + const certificate = await loadOrCreatePreviewCertificate(this.certificateDirectory); + const proxy = await startPreviewProxy({ + target: targetUrl, + port: portNumber, + certificate, + headers: LOCAL_PREVIEW_HEADERS, + }); + + logger.info(`Local preview running on https://localhost:${portNumber}, forwarding to ${target}.`); + logger.info( + `Open the Local preview in your Codespace. If it stays blank, open https://localhost:${portNumber} in a browser tab and accept the certificate, then reload the preview.` + ); + logger.info("Press Ctrl+C to stop."); + return proxy; + } + + private parseTarget(target: string): URL { + const message = "--target must be an http or https origin, for example http://localhost:4200"; + let url: URL; + try { + url = new URL(target); + } catch { + throw new FatalError(message); + } + const isOrigin = + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password && + url.pathname === "/" && + !url.search && + !url.hash; + if (!isOrigin) { + throw new FatalError(message); + } + return url; + } + + private parsePort(port: string | number): number { + const value = typeof port === "number" ? port : /^\d+$/.test(port) ? Number(port) : NaN; + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new FatalError("--port must be a number between 1 and 65535"); + } + return value; + } +} diff --git a/tests/commands/sdk/module.spec.ts b/tests/commands/sdk/module.spec.ts index fb7aeac0..f7c8f8f9 100644 --- a/tests/commands/sdk/module.spec.ts +++ b/tests/commands/sdk/module.spec.ts @@ -2,6 +2,8 @@ import Module = require("../../../src/commands/sdk/module"); import { Command } from "commander"; import { testContext } from "../../utls/test-context"; import { createMockConfigurator } from "../../utls/configurator-mock"; +import { PreviewProxy } from "../../../src/commands/sdk/preview-proxy"; +import { SdkPreviewService } from "../../../src/commands/sdk/sdk-preview.service"; import { SdkService } from "../../../src/commands/sdk/sdk.service"; describe("SDK Module", () => { @@ -14,7 +16,7 @@ describe("SDK Module", () => { expect(mockConfigurator.command).toHaveBeenCalledWith("sdk"); expect(mockConfigurator.command).toHaveBeenCalledWith("download"); expect(mockConfigurator.requiredOption).toHaveBeenCalledWith("--appId ", expect.any(String)); - expect(mockConfigurator.beta).toHaveBeenCalledTimes(3); + expect(mockConfigurator.beta).toHaveBeenCalledTimes(4); }); it("registers the sdk publish command", () => { @@ -47,5 +49,38 @@ describe("SDK Module", () => { expect(publishApp).toHaveBeenCalledWith("app-id", "dist"); }); + + it("registers the sdk preview command with its defaults and without profile loading", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + expect(mockConfigurator.command).toHaveBeenCalledWith("preview"); + expect(mockConfigurator.option).toHaveBeenCalledWith("--target ", expect.any(String), "http://localhost:4200"); + expect(mockConfigurator.option).toHaveBeenCalledWith("--port ", expect.any(String), "4300"); + expect(mockConfigurator.skipProfileLoading).toHaveBeenCalledTimes(1); + }); + + it("starts the preview with the given target and port and closes it on SIGINT", async () => { + const close = jest.fn().mockResolvedValue(undefined); + const proxy: PreviewProxy = { url: "https://localhost:4300", close }; + const preview = jest.spyOn(SdkPreviewService.prototype, "preview").mockResolvedValue(proxy); + const mockConfigurator = createMockConfigurator(); + new Module().register(testContext, mockConfigurator); + const sigintListeners = process.listenerCount("SIGINT"); + const sigtermListeners = process.listenerCount("SIGTERM"); + + const previewAction = mockConfigurator.action.mock.calls[2][0]; + const running = previewAction(testContext, new Command(), { target: "http://localhost:5173", port: "5173" }); + await new Promise(resolve => setImmediate(resolve)); + expect(close).not.toHaveBeenCalled(); + process.emit("SIGINT"); + await running; + + expect(preview).toHaveBeenCalledWith("http://localhost:5173", "5173"); + expect(close).toHaveBeenCalledTimes(1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners); + expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners); + }); }); }); diff --git a/tests/commands/sdk/sdk-preview.spec.ts b/tests/commands/sdk/sdk-preview.spec.ts new file mode 100644 index 00000000..1bc60f9b --- /dev/null +++ b/tests/commands/sdk/sdk-preview.spec.ts @@ -0,0 +1,61 @@ +import * as net from "node:net"; +import { PreviewProxy } from "../../../src/commands/sdk/preview-proxy"; +import { SdkPreviewService } from "../../../src/commands/sdk/sdk-preview.service"; +import { loggingTestTransport } from "../../jest.setup"; +import { makeTempDir, rmTempDir } from "../../utls/fs-utils"; + +describe("SDK preview", () => { + let dir: string; + let port: number; + let proxy: PreviewProxy | undefined; + + function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "localhost", () => { + const { port: freed } = server.address() as net.AddressInfo; + server.close(() => resolve(freed)); + }); + }); + } + + beforeEach(async () => { + dir = makeTempDir(); + port = await freePort(); + proxy = undefined; + }); + + afterEach(async () => { + await proxy?.close(); + rmTempDir(dir); + }); + + it("Should print the preview URL, the target and how to trust the certificate", async () => { + proxy = await new SdkPreviewService(dir).preview("http://localhost:4200", port); + + const output = loggingTestTransport.logMessages.map(m => m.message).join("\n"); + expect(proxy.url).toContain(`${port}`); + expect(output).toContain(`Local preview running on https://localhost:${port}, forwarding to http://localhost:4200.`); + expect(output).toContain(`open https://localhost:${port} in a browser tab`); + expect(output).toContain("Press Ctrl+C to stop."); + }); + + it("Should accept a string port and a trailing-slash target", async () => { + proxy = await new SdkPreviewService(dir).preview("http://localhost:4200/", String(port)); + + const output = loggingTestTransport.logMessages.map(m => m.message).join("\n"); + expect(output).toContain(`https://localhost:${port}`); + }); + + it.each(["localhost:4200", "ftp://localhost:4200", "http://localhost:4200/app", "http://user:pw@localhost:4200", "http://localhost:4200/?a=1", "http://localhost:4200/#x", "not a url"])( + "Should reject the target %s", async target => { + await expect(new SdkPreviewService(dir).preview(target, port)) + .rejects.toThrow("--target must be an http or https origin, for example http://localhost:4200"); + }); + + it.each(["abc", "0", "65536", "1.5", "", "-1"])("Should reject the port %p", async invalidPort => { + await expect(new SdkPreviewService(dir).preview("http://localhost:4200", invalidPort)) + .rejects.toThrow("--port must be a number between 1 and 65535"); + }); +}); diff --git a/tests/utls/configurator-mock.ts b/tests/utls/configurator-mock.ts index a22fe19d..c160b7ac 100644 --- a/tests/utls/configurator-mock.ts +++ b/tests/utls/configurator-mock.ts @@ -20,6 +20,7 @@ export interface MockConfigurator extends Configurator { betaOption: jest.Mock; deprecationNotice: jest.Mock; beta: jest.Mock; + skipProfileLoading: jest.Mock; action: jest.Mock; } @@ -35,6 +36,7 @@ export function createMockConfigurator(): MockConfigurator { chain.betaOption = jest.fn(() => chain); chain.deprecationNotice = jest.fn(() => chain); chain.beta = jest.fn(() => chain); + chain.skipProfileLoading = jest.fn(() => chain); chain.action = jest.fn(); return chain as MockConfigurator; From 73628f5cc8d19615176365935de8f98b9b6eb000 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 15:11:35 +0100 Subject: [PATCH 04/11] feat: allow inline scripts in the local preview with a warning Co-Authored-By: Claude Includes-AI-Code: true --- docs/user-guide/sdk-commands.md | 2 + src/commands/sdk/preview-inline-scripts.ts | 43 +++++ src/commands/sdk/preview-proxy.ts | 53 ++++++- .../sdk/preview-inline-scripts.spec.ts | 67 ++++++++ tests/commands/sdk/preview-proxy.spec.ts | 149 ++++++++++++++++++ 5 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/commands/sdk/preview-inline-scripts.ts create mode 100644 tests/commands/sdk/preview-inline-scripts.spec.ts diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index 005a9f5a..f106b750 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -40,3 +40,5 @@ content-cli sdk preview [--target ] [--port ] - `--port` is the HTTPS port the Local preview loads. Defaults to `4300`. 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. + +Inline scripts in your dev server's HTML pages, such as React's Fast Refresh preamble, are allowed in the Local preview: the command adds a hash of each one to the page's `script-src` and prints a warning for each the first time it sees it. Published apps block inline scripts, so move any inline script of your own into a separate file before publishing. diff --git a/src/commands/sdk/preview-inline-scripts.ts b/src/commands/sdk/preview-inline-scripts.ts new file mode 100644 index 00000000..22b9fa05 --- /dev/null +++ b/src/commands/sdk/preview-inline-scripts.ts @@ -0,0 +1,43 @@ +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 = /]*)>([\s\S]*?)<\/script\s*>/gi; +const SRC_ATTRIBUTE = /(?:^|[\s/])src(?=[\s=/]|$)/i; +const EXCERPT_LENGTH = 60; + +/** + * The inline scripts (`" + + ""; + + 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 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 index d750acb2..1101b5fb 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.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"; @@ -282,6 +283,154 @@ describe("Preview proxy", () => { 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()); + } + + it("Should allow the inline scripts of a proxied HTML page and warn about each once", async () => { + const inline = "import { injectIntoGlobalHook } from \"/@react-refresh\";"; + const html = `` + + "Grüße"; + const target = await startTarget((req, res) => { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(html), + }); + res.end(html); + }); + const proxy = await startProxy(targetUrl(target)); + + 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}". ` + + "Published apps block inline scripts, so this only works locally unless your dev server added it " + + "(like React's Fast Refresh preamble).", + ]); + }); + + 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)); + + 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("`)[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 = ""; From 761327f4e596cb1e63cde48638bdf4089ad0f8f6 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 18:05:45 +0100 Subject: [PATCH 06/11] feat: allow inline scripts in the local preview only with --allow-inline-scripts The preview now blocks inline scripts by default, like published apps, and warns about each one with a hint to pass --allow-inline-scripts. Co-Authored-By: Claude Includes-AI-Code: true --- docs/user-guide/sdk-commands.md | 5 ++- src/commands/sdk/module.ts | 3 +- src/commands/sdk/preview-proxy.ts | 19 ++++++---- src/commands/sdk/sdk-preview.service.ts | 3 +- tests/commands/sdk/module.spec.ts | 5 ++- tests/commands/sdk/preview-proxy.spec.ts | 48 +++++++++++++++++++----- 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index f106b750..0ee2531f 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -33,12 +33,13 @@ The command compresses the given build directory and uploads it for the app. On 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 ] +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. -Inline scripts in your dev server's HTML pages, such as React's Fast Refresh preamble, are allowed in the Local preview: the command adds a hash of each one to the page's `script-src` and prints a warning for each the first time it sees it. Published apps block inline scripts, so move any inline script of your own into a separate file before publishing. +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/src/commands/sdk/module.ts b/src/commands/sdk/module.ts index 943f5372..cb6ae169 100644 --- a/src/commands/sdk/module.ts +++ b/src/commands/sdk/module.ts @@ -33,6 +33,7 @@ class Module extends IModule { .description("Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules") .option("--target ", "URL of your app's dev server", "http://localhost:4200") .option("--port ", "HTTPS port the Codespace preview loads", "4300") + .option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML, like React's Fast Refresh preamble") .action(this.previewApp); } @@ -45,7 +46,7 @@ class Module extends IModule { } private async previewApp(context: Context, command: Command, options: OptionValues): Promise { - const proxy = await new SdkPreviewService().preview(options.target, options.port); + const proxy = await new SdkPreviewService().preview(options.target, options.port, options.allowInlineScripts === true); await new Promise(resolve => { const stop = (): void => { process.removeListener("SIGINT", stop); diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index fd9228c4..939971dd 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -12,6 +12,8 @@ export interface PreviewProxyOptions { port: number; certificate: PreviewCertificate; headers: Readonly>; + /** Adds a hash of each inline script in proxied HTML to `script-src`. Published apps block inline scripts. */ + allowInlineScripts: boolean; } export interface PreviewProxy { @@ -23,14 +25,17 @@ const REPLACED_HEADERS = ["content-security-policy", "content-security-policy-re const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"]; const BODILESS_STATUSES = [204, 304]; -function inlineScriptWarning(path: string, excerpt: string): string { - return `Allowing an inline script in ${path} for the local preview: "${excerpt}". ` + - "Published apps block inline scripts, so this only works locally unless your dev server added it " + - "(like React's Fast Refresh preamble)."; +function inlineScriptWarning(path: string, excerpt: string, allowed: boolean): string { + return allowed + ? `Allowing an inline script in ${path} for the local preview: "${excerpt}". ` + + "Published apps block inline scripts, so this only works locally unless your dev server added it " + + "(like React's Fast Refresh preamble)." + : `Blocking an inline script in ${path}: "${excerpt}". Published apps block inline scripts too. ` + + "If your dev server added it (like React's Fast Refresh preamble), pass --allow-inline-scripts."; } export function startPreviewProxy(options: PreviewProxyOptions): Promise { - const { target, port, certificate, headers } = options; + const { target, port, certificate, headers, allowInlineScripts: allowInline } = options; const secureTarget = target.protocol === "https:"; const targetHostname = target.hostname.replace(/^\[(.*)\]$/, "$1"); const targetPort = Number(target.port) || (secureTarget ? 443 : 80); @@ -92,7 +97,7 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise script.hash)), }), }); diff --git a/src/commands/sdk/sdk-preview.service.ts b/src/commands/sdk/sdk-preview.service.ts index 905b9009..2954fcd8 100644 --- a/src/commands/sdk/sdk-preview.service.ts +++ b/src/commands/sdk/sdk-preview.service.ts @@ -10,7 +10,7 @@ export class SdkPreviewService { constructor(private readonly certificateDirectory: string = SdkPreviewService.CERTIFICATE_DIRECTORY) {} - public async preview(target: string, port: string | number): Promise { + public async preview(target: string, port: string | number, allowInlineScripts: boolean = false): Promise { const targetUrl = this.parseTarget(target); const portNumber = this.parsePort(port); @@ -20,6 +20,7 @@ export class SdkPreviewService { port: portNumber, certificate, headers: LOCAL_PREVIEW_HEADERS, + allowInlineScripts, }); logger.info(`Local preview running on https://localhost:${portNumber}, forwarding to ${target}.`); diff --git a/tests/commands/sdk/module.spec.ts b/tests/commands/sdk/module.spec.ts index f7c8f8f9..9121abcd 100644 --- a/tests/commands/sdk/module.spec.ts +++ b/tests/commands/sdk/module.spec.ts @@ -58,6 +58,7 @@ describe("SDK Module", () => { expect(mockConfigurator.command).toHaveBeenCalledWith("preview"); expect(mockConfigurator.option).toHaveBeenCalledWith("--target ", expect.any(String), "http://localhost:4200"); expect(mockConfigurator.option).toHaveBeenCalledWith("--port ", expect.any(String), "4300"); + expect(mockConfigurator.option).toHaveBeenCalledWith("--allow-inline-scripts", expect.any(String)); expect(mockConfigurator.skipProfileLoading).toHaveBeenCalledTimes(1); }); @@ -71,13 +72,13 @@ describe("SDK Module", () => { const sigtermListeners = process.listenerCount("SIGTERM"); const previewAction = mockConfigurator.action.mock.calls[2][0]; - const running = previewAction(testContext, new Command(), { target: "http://localhost:5173", port: "5173" }); + const running = previewAction(testContext, new Command(), { target: "http://localhost:5173", port: "5173", allowInlineScripts: true }); await new Promise(resolve => setImmediate(resolve)); expect(close).not.toHaveBeenCalled(); process.emit("SIGINT"); await running; - expect(preview).toHaveBeenCalledWith("http://localhost:5173", "5173"); + expect(preview).toHaveBeenCalledWith("http://localhost:5173", "5173", true); expect(close).toHaveBeenCalledTimes(1); expect(process.listenerCount("SIGINT")).toBe(sigintListeners); expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners); diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 1101b5fb..61d5a85b 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -68,8 +68,14 @@ describe("Preview proxy", () => { return new URL(`http://localhost:${(target.address() as net.AddressInfo).port}`); } - async function startProxy(target: URL): Promise { - const proxy = await startPreviewProxy({ target, port: await freePort(), certificate, headers: LOCAL_PREVIEW_HEADERS }); + 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; } @@ -301,18 +307,41 @@ describe("Preview proxy", () => { .map(entry => entry.message.trim()); } - it("Should allow the inline scripts of a proxied HTML page and warn about each once", async () => { - const inline = "import { injectIntoGlobalHook } from \"/@react-refresh\";"; - const html = `` + - "Grüße"; - const target = await startTarget((req, res) => { + 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); }); - const proxy = await startProxy(targetUrl(target)); + } + + 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}". Published apps block inline scripts too. ` + + "If your dev server added it (like React's Fast Refresh preamble), pass --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"); @@ -338,7 +367,7 @@ describe("Preview proxy", () => { res.write("")); }); - const proxy = await startProxy(targetUrl(target)); + const proxy = await startProxy(targetUrl(target), true); const response = await send(proxy, "/"); @@ -442,6 +471,7 @@ describe("Preview proxy", () => { port: busyPort, certificate, headers: LOCAL_PREVIEW_HEADERS, + allowInlineScripts: false, }; await expect(startPreviewProxy(options)).rejects.toThrow(`Port ${busyPort} is already in use`); From 1306c736aba09950ee5b70e48515088a0969b8d2 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 27 Oct 2026 18:07:56 +0100 Subject: [PATCH 07/11] docs: list --allow-inline-scripts in the command graph Co-Authored-By: Claude Includes-AI-Code: true --- docs/command-graph.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/command-graph.html b/docs/command-graph.html index 6e2893af..309b4b34 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -446,7 +446,7 @@ 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 ", "-h, --help"] }, + options: ["--target ", "--port ", "--allow-inline-scripts", "-h, --help"] }, // git profile { id: "git_profile_area", label: "profile", group: "subarea", path: "git profile", From d2898d515e567ca88712b05689d15ed78bede8b6 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Wed, 28 Oct 2026 15:20:51 +0100 Subject: [PATCH 08/11] fix: resolve SonarCloud findings in the preview command Moves the proxy's close() out of the listen callback, uses Sets for the loopback hostnames and bodiless statuses, parses --port without a nested ternary, and asserts that the disconnect test's target request closes. Co-Authored-By: Claude Includes-AI-Code: true --- src/commands/sdk/preview-proxy.ts | 22 +++++++++++++--------- src/commands/sdk/sdk-preview.service.ts | 2 +- tests/commands/sdk/preview-proxy.spec.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index 939971dd..3c540f80 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -22,8 +22,8 @@ export interface PreviewProxy { } const REPLACED_HEADERS = ["content-security-policy", "content-security-policy-report-only", "x-frame-options"]; -const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"]; -const BODILESS_STATUSES = [204, 304]; +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); +const BODILESS_STATUSES = new Set([204, 304]); function inlineScriptWarning(path: string, excerpt: string, allowed: boolean): string { return allowed @@ -39,7 +39,7 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise res.destroy()); const isHtml = /^text\/html\b/i.test(proxyRes.headers["content-type"] ?? ""); - const hasBody = req.method !== "HEAD" && !BODILESS_STATUSES.includes(proxyRes.statusCode); + const hasBody = req.method !== "HEAD" && !BODILESS_STATUSES.has(proxyRes.statusCode); if (!isHtml || !hasBody) { res.writeHead(proxyRes.statusCode, { ...responseHeaders, ...headers }); proxyRes.pipe(res); @@ -179,6 +179,14 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise { + return new Promise(closed => { + server.close(() => closed()); + openSockets.forEach(socket => socket.destroy()); + agent.destroy(); + }); + } + return new Promise((resolve, reject) => { const onListenError = (error: NodeJS.ErrnoException): void => { agent.destroy(); @@ -191,11 +199,7 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise new Promise(closed => { - server.close(() => closed()); - openSockets.forEach(socket => socket.destroy()); - agent.destroy(); - }), + close, }); }); }); diff --git a/src/commands/sdk/sdk-preview.service.ts b/src/commands/sdk/sdk-preview.service.ts index 2954fcd8..7e50db2a 100644 --- a/src/commands/sdk/sdk-preview.service.ts +++ b/src/commands/sdk/sdk-preview.service.ts @@ -53,7 +53,7 @@ export class SdkPreviewService { } private parsePort(port: string | number): number { - const value = typeof port === "number" ? port : /^\d+$/.test(port) ? Number(port) : NaN; + const value = typeof port === "number" || /^\d+$/.test(port) ? Number(port) : Number.NaN; if (!Number.isInteger(value) || value < 1 || value > 65535) { throw new FatalError("--port must be a number between 1 and 65535"); } diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 61d5a85b..9c949681 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -233,7 +233,7 @@ describe("Preview proxy", () => { }); clientRequest.destroy(); - await closed; + await expect(closed).resolves.toBeUndefined(); }); it("Should not report the target as unreachable when the client disconnects before it answers", async () => { From edb23710ff061b5607a332678e0d8b4a289716a3 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Thu, 29 Oct 2026 09:00:33 +0100 Subject: [PATCH 09/11] remove react preamble examples from user docs --- src/commands/sdk/module.ts | 2 +- src/commands/sdk/preview-proxy.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/sdk/module.ts b/src/commands/sdk/module.ts index cb6ae169..4d179fcb 100644 --- a/src/commands/sdk/module.ts +++ b/src/commands/sdk/module.ts @@ -33,7 +33,7 @@ class Module extends IModule { .description("Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules") .option("--target ", "URL of your app's dev server", "http://localhost:4200") .option("--port ", "HTTPS port the Codespace preview loads", "4300") - .option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML, like React's Fast Refresh preamble") + .option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML") .action(this.previewApp); } diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index 3c540f80..26cc9768 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -29,9 +29,9 @@ function inlineScriptWarning(path: string, excerpt: string, allowed: boolean): s return allowed ? `Allowing an inline script in ${path} for the local preview: "${excerpt}". ` + "Published apps block inline scripts, so this only works locally unless your dev server added it " + - "(like React's Fast Refresh preamble)." + "for local development only." : `Blocking an inline script in ${path}: "${excerpt}". Published apps block inline scripts too. ` + - "If your dev server added it (like React's Fast Refresh preamble), pass --allow-inline-scripts."; + "If your dev server added it for local development only, pass --allow-inline-scripts."; } export function startPreviewProxy(options: PreviewProxyOptions): Promise { From ad93df593493b02da5caed901f73dc343b23a704 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Thu, 29 Oct 2026 09:38:27 +0100 Subject: [PATCH 10/11] fix tests --- tests/commands/sdk/preview-proxy.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 9c949681..e51d43eb 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -334,7 +334,7 @@ describe("Preview proxy", () => { } expect(warnings()).toEqual([ `Blocking an inline script in /index.html: "${reactPreamble}". Published apps block inline scripts too. ` + - "If your dev server added it (like React's Fast Refresh preamble), pass --allow-inline-scripts.", + "If your dev server added it for local development only, pass --allow-inline-scripts.", ]); }); @@ -357,7 +357,7 @@ describe("Preview proxy", () => { expect(warnings()).toEqual([ `Allowing an inline script in /index.html for the local preview: "${inline}". ` + "Published apps block inline scripts, so this only works locally unless your dev server added it " + - "(like React's Fast Refresh preamble).", + "for local development only.", ]); }); From 84e5a7ea18f687234f46d00f8518df7b95fdaecb Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Thu, 29 Oct 2026 11:53:11 +0100 Subject: [PATCH 11/11] DISCO-184: build the preview proxy on http-proxy-middleware Replaces the hand-written request forwarding, raw-socket WebSocket tunnel, keep-alive agent and TLS handling with http-proxy-middleware. The HTML buffering for inline-script hashing and CSP rewriting stays as a small proxyRes handler. Co-Authored-By: Claude Includes-AI-Code: true --- package.json | 1 + src/commands/sdk/preview-proxy.ts | 196 ++++++++++++------------------ yarn.lock | 64 +++++++++- 3 files changed, 143 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 50b4821c..79a420bd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "commander": "13.1.0", "form-data": "4.0.6", "hpagent": "1.2.0", + "http-proxy-middleware": "3.0.5", "openid-client": "5.6.1", "selfsigned": "5.5.0", "semver": "7.6.3", diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index 26cc9768..e2c83c8b 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -2,7 +2,7 @@ import * as http from "node:http"; import * as https from "node:https"; import * as net from "node:net"; import { Duplex } from "node:stream"; -import * as tls from "node:tls"; +import { createProxyMiddleware } from "http-proxy-middleware"; import { FatalError, logger } from "../../core/utils/logger"; import { PreviewCertificate } from "./preview-certificate"; import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts"; @@ -36,13 +36,6 @@ function inlineScriptWarning(path: string, excerpt: string, allowed: boolean): s export function startPreviewProxy(options: PreviewProxyOptions): Promise { const { target, port, certificate, headers, allowInlineScripts: allowInline } = options; - const secureTarget = target.protocol === "https:"; - const targetHostname = target.hostname.replace(/^\[(.*)\]$/, "$1"); - const targetPort = Number(target.port) || (secureTarget ? 443 : 80); - const rejectUnauthorized = !LOOPBACK_HOSTNAMES.has(target.hostname); - const agent = secureTarget - ? new https.Agent({ keepAlive: true, rejectUnauthorized }) - : new http.Agent({ keepAlive: true }); const removedHeaders = new Set([...REPLACED_HEADERS, ...Object.keys(headers).map(name => name.toLowerCase())]); const unreachableMessage = `content-cli sdk preview can't reach ${target.origin}. Start your dev server or pass --target.`; const openSockets = new Set(); @@ -54,142 +47,113 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise openSockets.delete(socket)); } - function forwardRequest(req: http.IncomingMessage, res: http.ServerResponse): void { - const proxyReq = (secureTarget ? https : http).request({ - protocol: target.protocol, - hostname: targetHostname, - port: targetPort, - method: req.method, - path: req.url, - headers: { ...req.headers, host: target.host, "accept-encoding": "identity" }, - agent, - }); - - let responded = false; - proxyReq.on("response", proxyRes => { - responded = true; - warnedUnreachable = false; - const responseHeaders: http.OutgoingHttpHeaders = {}; - for (const [name, value] of Object.entries(proxyRes.headers)) { - if (!removedHeaders.has(name.toLowerCase())) { - responseHeaders[name] = value; - } - } - proxyRes.on("error", () => res.destroy()); - proxyRes.on("aborted", () => res.destroy()); - - const isHtml = /^text\/html\b/i.test(proxyRes.headers["content-type"] ?? ""); - const hasBody = req.method !== "HEAD" && !BODILESS_STATUSES.has(proxyRes.statusCode); - if (!isHtml || !hasBody) { - res.writeHead(proxyRes.statusCode, { ...responseHeaders, ...headers }); - proxyRes.pipe(res); + function respondHtml(proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse, + responseHeaders: http.OutgoingHttpHeaders): void { + const chunks: Buffer[] = []; + proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk)); + proxyRes.on("end", () => { + if (res.destroyed) { return; } - - const chunks: Buffer[] = []; - proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk)); - proxyRes.on("end", () => { - if (res.destroyed) { - return; - } - const body = Buffer.concat(chunks); - const scripts = inlineScriptHashes(body.toString("utf8")); - for (const { hash, excerpt } of scripts) { - if (!warnedInlineScripts.has(hash)) { - warnedInlineScripts.add(hash); - logger.warn(inlineScriptWarning(req.url, excerpt, allowInline)); - } + const body = Buffer.concat(chunks); + const scripts = inlineScriptHashes(body.toString("utf8")); + for (const { hash, excerpt } of scripts) { + if (!warnedInlineScripts.has(hash)) { + warnedInlineScripts.add(hash); + logger.warn(inlineScriptWarning(req.url, excerpt, allowInline)); } - if (proxyRes.headers["content-length"] !== undefined) { - responseHeaders["content-length"] = body.length; - } - 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)), - }), - }); - res.end(body); + } + if (proxyRes.headers["content-length"] !== undefined) { + responseHeaders["content-length"] = body.length; + } + 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)), + }), }); + res.end(body); }); + } - proxyReq.on("error", () => { - if (res.destroyed) { - return; + function onProxyRes(proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse): void { + warnedUnreachable = false; + const responseHeaders: http.OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(proxyRes.headers)) { + if (!removedHeaders.has(name.toLowerCase())) { + responseHeaders[name] = value; } - if (responded || res.headersSent) { - res.destroy(); - return; - } - if (!warnedUnreachable) { - warnedUnreachable = true; - logger.warn(unreachableMessage); - } - res.writeHead(502, { ...headers, "content-type": "text/plain" }); - res.end(unreachableMessage); - }); + } + proxyRes.on("error", () => res.destroy()); + proxyRes.on("aborted", () => res.destroy()); + + const isHtml = /^text\/html\b/i.test(proxyRes.headers["content-type"] ?? ""); + const hasBody = req.method !== "HEAD" && !BODILESS_STATUSES.has(proxyRes.statusCode); + if (isHtml && hasBody) { + respondHtml(proxyRes, req, res, responseHeaders); + return; + } + res.writeHead(proxyRes.statusCode, { ...responseHeaders, ...headers }); + proxyRes.pipe(res); + } + function onProxyReq(proxyReq: http.ClientRequest, _req: http.IncomingMessage, res: http.ServerResponse): void { res.on("close", () => { if (!res.writableFinished) { proxyReq.destroy(); } }); - - req.pipe(proxyReq); } - function forwardUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void { - const targetSocket: net.Socket = secureTarget - ? tls.connect({ - host: targetHostname, - port: targetPort, - servername: net.isIP(targetHostname) ? undefined : targetHostname, - rejectUnauthorized, - }) - : net.connect({ host: targetHostname, port: targetPort }); - track(targetSocket); - - const destroyBoth = (): void => { - socket.destroy(); - targetSocket.destroy(); - }; - socket.on("error", destroyBoth); - targetSocket.on("error", destroyBoth); - socket.on("close", () => targetSocket.destroy()); - targetSocket.on("close", () => socket.destroy()); - - targetSocket.once(secureTarget ? "secureConnect" : "connect", () => { - const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]; - for (let i = 0; i < req.rawHeaders.length; i += 2) { - const name = req.rawHeaders[i]; - lines.push(`${name}: ${name.toLowerCase() === "host" ? target.host : req.rawHeaders[i + 1]}`); - } - targetSocket.write(`${lines.join("\r\n")}\r\n\r\n`); - if (head.length > 0) { - targetSocket.write(head); - } - targetSocket.pipe(socket); - socket.pipe(targetSocket); - }); + function onError(_error: Error, _req: http.IncomingMessage, res: http.ServerResponse | Duplex): void { + if (!(res instanceof http.ServerResponse)) { + res.destroy(); + return; + } + if (res.destroyed) { + return; + } + if (res.headersSent) { + res.destroy(); + return; + } + if (!warnedUnreachable) { + warnedUnreachable = true; + logger.warn(unreachableMessage); + } + res.writeHead(502, { ...headers, "content-type": "text/plain" }); + res.end(unreachableMessage); } - const server = https.createServer({ key: certificate.key, cert: certificate.cert }, forwardRequest); + const proxy = createProxyMiddleware({ + target: target.origin, + changeOrigin: true, + ws: true, + secure: !LOOPBACK_HOSTNAMES.has(target.hostname), + selfHandleResponse: true, + headers: { "accept-encoding": "identity" }, + on: { proxyReq: onProxyReq, proxyRes: onProxyRes, error: onError }, + }); + + const server = https.createServer({ key: certificate.key, cert: certificate.cert }, (req, res) => + proxy(req, res, () => undefined)); server.on("connection", track); - server.on("upgrade", forwardUpgrade); + server.on("upgrade", (req, socket, head) => { + track(socket); + proxy.upgrade(req, socket as net.Socket, head); + }); function close(): Promise { return new Promise(closed => { server.close(() => closed()); openSockets.forEach(socket => socket.destroy()); - agent.destroy(); }); } return new Promise((resolve, reject) => { const onListenError = (error: NodeJS.ErrnoException): void => { - agent.destroy(); reject(new FatalError(error.code === "EADDRINUSE" ? `Port ${port} is already in use. Stop the other process or pass --port.` : `Could not start the preview proxy on port ${port}: ${error.message}`)); diff --git a/yarn.lock b/yarn.lock index 3e6b5e99..ad4b3eb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -893,6 +893,13 @@ dependencies: "@types/node" "*" +"@types/http-proxy@^1.17.15": + version "1.17.17" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.17.tgz#d9e2c4571fe3507343cb210cd41790375e59a533" + integrity sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" @@ -1410,7 +1417,7 @@ cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -debug@4: +debug@4, debug@^4.3.6: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -1554,6 +1561,11 @@ esprima@^4.0.0: resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" @@ -1649,6 +1661,11 @@ fn.name@1.x.x: resolved "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== +follow-redirects@^1.0.0: + version "1.16.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.2.tgz#a233160dd1bc9b16cbbab43709e6adc7cc941c8c" + integrity sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow== + follow-redirects@^1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" @@ -1793,6 +1810,27 @@ html-escaper@^2.0.0: resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +http-proxy-middleware@3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz#9dcde663edc44079bc5a9c63e03fe5e5d6037fab" + integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg== + dependencies: + "@types/http-proxy" "^1.17.15" + debug "^4.3.6" + http-proxy "^1.18.1" + is-glob "^4.0.3" + is-plain-object "^5.0.0" + micromatch "^4.0.8" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -1851,6 +1889,11 @@ is-core-module@^2.16.0: dependencies: hasown "^2.0.2" +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" @@ -1873,11 +1916,23 @@ is-generator-fn@^2.0.0: resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-plain-object@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.1.0.tgz#22ed7b5e856bb57966348e5b9468dada42be87f4" + integrity sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" @@ -2483,7 +2538,7 @@ merge-stream@^2.0.0: resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -micromatch@^4.0.4, micromatch@~4.0.8: +micromatch@^4.0.4, micromatch@^4.0.8, micromatch@~4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -2829,6 +2884,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz"