From 9d08b6c4702fdfd33d92117596340c920ef2f535 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Sun, 2 Aug 2026 03:16:49 +0000 Subject: [PATCH] fix: ship Windows app code in asar to fit legacy Squirrel path limits Squirrel 1.x expands every package file through 260-character .NET path APIs, during releasify and again on end-user machines under %LOCALAPPDATA%. Loose node_modules paths (deepest: nested OpenTelemetry copies under spectrum-ts at 166 characters) exceed that budget, so 0.0.36 Windows packaging failed with PathTooLongException. Windows packages now ship application code inside app.asar (a few hundred short paths instead of tens of thousands of loose files) and unpack only what must exist on real disk: scripts, container image, deploy config, public assets, the desktop directory, node-pty, and native modules. The desktop shell translates HELM_APP_ROOT, the working directory, and the Squirrel uninstall helper to the unpacked tree so Python, PowerShell, WSL, and plain-Node consumers keep reading real files, while the server itself is imported from inside the archive so dependency resolution stays there. The photon sidecar, a plain Node child process that cannot open asar archives, is bundled self-contained during npm run build and started from the unpacked tree in packaged builds. macOS, Linux, and development startup paths are unchanged. Verified by packaging the real app for Linux with the identical asar configuration: 581 unpacked files with a longest relative path of 118 characters, and the packaged app booted and served /api/setup/status. Release 0.0.37: version, changelog, and channel-machine pins. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + CHANGELOG.md | 20 ++++++++++++ README.md | 2 +- desktop/main.cjs | 19 +++++++++-- package-lock.json | 4 +-- package.json | 5 +-- scripts/package-windows.cjs | 45 ++++++++++++++++++------- src/server/channel-computers.ts | 2 +- src/server/db.ts | 2 +- src/server/photon.ts | 13 +++++++- test/channel-computers.mjs | 2 +- test/desktop.mjs | 58 +++++++++++++++++++++++++++++++++ 12 files changed, 149 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 2142fdd..1e800bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data-*/ public/bundle.js public/bundle.css public/app.css +desktop/photon-sidecar.bundle.mjs public/excalidraw/ *.log .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index babbebf..e15c780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.37] - 2026-08-02 + +### Fixed + +- Fixed Windows packaging failing with `PathTooLongException` during Squirrel + releasify. Application code now ships inside `app.asar`, so legacy Squirrel + handles a few hundred short paths instead of tens of thousands of deeply + nested loose dependency files — both while building the installer and while + installing or updating on end-user machines, where the same 260-character + .NET path limit applies under `%LOCALAPPDATA%` regardless of username + length. +- Assets consumed by external processes stay on real disk next to the archive: + the WSL setup script, OCI runtime and container image, deploy configuration, + public assets, the Squirrel uninstall helper, and native terminal modules. +- The Photon sidecar, which runs as a plain Node child process and cannot read + modules inside an asar archive, is now built as a single self-contained + bundle during `npm run build` and started from the unpacked tree in packaged + Windows builds. macOS, Linux, and development startup paths are unchanged. + ## [0.0.36] - 2026-08-02 ### Fixed @@ -1001,6 +1020,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Application Support, and isolated Apple container machines. [Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.36...HEAD +[0.0.37]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.37 [0.0.36]: https://github.com/gitcommit90/1Helm/compare/v0.0.35...v0.0.36 [0.0.35]: https://github.com/gitcommit90/1Helm/compare/v0.0.34...v0.0.35 [0.0.34]: https://github.com/gitcommit90/1Helm/compare/v0.0.33...v0.0.34 diff --git a/README.md b/README.md index 8a616fd..8e4dd1c 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.36` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.37` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/desktop/main.cjs b/desktop/main.cjs index c052e7d..62a5618 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -41,6 +41,14 @@ function rememberDesktopMode(mode) { fs.writeFileSync(desktopModePath(), `${mode}\n`, { mode: 0o600 }); } +// Windows packages ship application code inside app.asar; assets consumed by +// external processes (Python, PowerShell, WSL, plain-Node sidecars) are +// unpacked beside the archive. Translate paths for those consumers. Loose +// packages (macOS, Linux, development) pass through unchanged. +function unpackedPath(target) { + return String(target).replace(/app\.asar(?=[\\/]|$)/, "app.asar.unpacked"); +} + function handleSquirrelEvent() { if (process.platform !== "win32") return false; const event = process.argv[1]; @@ -53,7 +61,7 @@ function handleSquirrelEvent() { } else if (event === "--squirrel-uninstall") { const dataRoot = app.getPath("userData"); const wslRoot = path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime"); - const cleanup = path.resolve(__dirname, "..", "scripts", "windows-removal.cjs"); + const cleanup = unpackedPath(path.resolve(__dirname, "..", "scripts", "windows-removal.cjs")); spawnSync(process.execPath, [cleanup, dataRoot, wslRoot], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", windowsHide: true, timeout: 10 * 60_000 }); spawnSync(updateExe, ["--removeShortcut", exe], { stdio: "ignore", windowsHide: true }); } @@ -114,16 +122,21 @@ async function waitForServer(origin, timeoutMs = SERVER_READY_TIMEOUT_MS) { async function startLocalRuntime() { const appRoot = app.getAppPath(); + // Server code is imported from inside app.asar so dependency resolution + // stays within the archive, but HELM_APP_ROOT and the working directory + // must be the real on-disk root: scripts, container assets, deploy config, + // and public files are read by external processes that cannot open asar. + const assetRoot = unpackedPath(appRoot); const port = await freePort(); process.env.HELM_DESKTOP = "1"; - process.env.HELM_APP_ROOT = appRoot; + process.env.HELM_APP_ROOT = assetRoot; process.env.HELM_RESOURCES_PATH = process.resourcesPath; process.env.HELM_HOST = LOOPBACK; process.env.PORT = String(port); process.env.CTRL_DATA_DIR = app.getPath("userData"); if (process.platform !== "win32") process.env.SHELL ||= "/bin/zsh"; process.env.HELM_INTERNAL_WAKE_TOKEN ||= crypto.randomBytes(32).toString("hex"); - process.chdir(appRoot); + process.chdir(assetRoot); localOrigin = `http://${LOOPBACK}:${port}`; await import(pathToFileURL(path.join(appRoot, "src", "server", "index.ts")).href); await waitForServer(localOrigin); diff --git a/package-lock.json b/package-lock.json index 6ecdcd0..3802d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.36", + "version": "0.0.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.36", + "version": "0.0.37", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 4ba988b..e3c456c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.36", + "version": "0.0.37", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -21,7 +21,8 @@ "build:css": "tailwindcss -i src/client/styles.css -o public/app.css --minify", "build:stamp": "node src/build/stamp.mjs", "build:excalidraw": "node scripts/copy-excalidraw-assets.mjs", - "build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:stamp", + "build:sidecar": "esbuild src/server/photon-sidecar.mjs --bundle --platform=node --format=esm --outfile=desktop/photon-sidecar.bundle.mjs --minify --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"", + "build": "npm run build:excalidraw && npm run build:js && npm run build:css && npm run build:sidecar && npm run build:stamp", "mobile:sync": "node scripts/sync-mobile-version.mjs && npm run assets:mobile && npm run build && cap sync", "mobile:check": "npm run typecheck && node --test test/mobile.mjs && cap doctor android", "start": "node --disable-warning=ExperimentalWarning src/server/index.ts", diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index da17c47..9ae662b 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -80,13 +80,18 @@ function prepareCloudflared(destination) { } async function main() { + // The photon sidecar bundle is produced by `npm run build`. Fail before + // packaging rather than shipping an app whose sidecar cannot start. + if (!fs.existsSync(path.join(ROOT, "desktop", "photon-sidecar.bundle.mjs"))) throw new Error("desktop/photon-sidecar.bundle.mjs is missing; run `npm run build` first."); fs.mkdirSync(DIST, { recursive: true }); const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-")); // Squirrel 1.x expands every package file through legacy .NET APIs limited - // to 260-character paths. A short release directory alone is insufficient: - // its packages/app-version staging tree still includes the packaged app's - // full relative paths. Build both the app and installer in one fresh, - // drive-root scratch directory, then retain only canonical artifacts. + // to 260-character paths, both while releasifying here and while installing + // or updating on end-user machines under %LOCALAPPDATA%. Loose node_modules + // exceed that budget, so application code ships inside app.asar and only + // assets consumed by external processes are unpacked (see the asar option + // below). The drive-root scratch directory still keeps Squirrel's own + // staging prefixes short for the unpacked remainder. const windowsScratch = fs.mkdtempSync(path.join(path.parse(ROOT).root, "1hw-")); const ico = path.join(iconRoot, "1Helm.ico"); try { @@ -100,7 +105,16 @@ async function main() { const [appDir] = await packager({ dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, - platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, asar: false, icon: ico, + platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, icon: ico, + // Everything importable stays inside app.asar so legacy Squirrel sees a + // handful of short paths instead of tens of thousands of loose files. + // Unpacked: assets read by external processes (PowerShell, Python, WSL, + // the plain-Node photon sidecar, Squirrel's uninstall hook) plus native + // modules that must exist on disk to load. + asar: { + unpack: "**/*.node", + unpackDir: "{scripts,container,deploy,public,desktop,node_modules/node-pty}", + }, ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, IGNORE_INSTRUCTION_FILES, /\.DS_Store$/, /\.log$/], }); const appExe = path.join(appDir, "1Helm.exe"); @@ -109,21 +123,28 @@ async function main() { prepareCloudflared(cloudflared); if (!fs.existsSync(cloudflared)) throw new Error("Packaged Windows app is missing cloudflared.exe."); signPackagedExecutables(appDir); - const pty = path.join(appDir, "resources", "app", "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); + const asarArchive = path.join(appDir, "resources", "app.asar"); + if (!fs.existsSync(asarArchive)) throw new Error("Packaged Windows app is missing app.asar."); + const unpackedRoot = path.join(appDir, "resources", "app.asar.unpacked"); + const pty = path.join(unpackedRoot, "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); if (!fs.existsSync(pty)) throw new Error("Packaged Windows app is missing the x64 terminal module."); + const sidecarBundle = path.join(unpackedRoot, "desktop", "photon-sidecar.bundle.mjs"); + if (!fs.existsSync(sidecarBundle)) throw new Error("Packaged Windows app is missing the self-contained photon sidecar bundle."); if (capture("where.exe", ["dumpbin.exe"])) { const headers = capture("dumpbin.exe", ["/headers", appExe]); if (!/machine \(x64\)/i.test(headers)) throw new Error("Packaged Windows application is not x64."); } if (capture("where.exe", ["powershell.exe"])) { - const script = path.join(appDir, "resources", "app", "scripts", "install-wsl-runtime.ps1"); + const script = path.join(unpackedRoot, "scripts", "install-wsl-runtime.ps1"); if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script."); for (const required of [ - path.join(appDir, "resources", "app", "scripts", "1helm-oci-runtime"), - path.join(appDir, "resources", "app", "deploy", "1helm-oci-runtime-v1.conf"), - path.join(appDir, "resources", "app", "container", "Containerfile.oci"), - path.join(appDir, "resources", "app", "container", "channel-machine.oci.tar"), - path.join(appDir, "resources", "app", "container", "channel-machine.oci.sha256"), + path.join(unpackedRoot, "scripts", "1helm-oci-runtime"), + path.join(unpackedRoot, "scripts", "mnemosyne-bridge.py"), + path.join(unpackedRoot, "scripts", "windows-removal.cjs"), + path.join(unpackedRoot, "deploy", "1helm-oci-runtime-v1.conf"), + path.join(unpackedRoot, "container", "Containerfile.oci"), + path.join(unpackedRoot, "container", "channel-machine.oci.tar"), + path.join(unpackedRoot, "container", "channel-machine.oci.sha256"), ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); } diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index a40c16c..158bf3c 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; const OCI_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index 56d4c0c..067cf48 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.36"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.37"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/src/server/photon.ts b/src/server/photon.ts index fa512a0..80e9bb5 100644 --- a/src/server/photon.ts +++ b/src/server/photon.ts @@ -12,6 +12,17 @@ import { agentForChannel, ensureThread, refreshThreadSummary } from "./agents.ts const CREDENTIAL_FILE = join(DATA_DIR, "photon-credentials.json"); const SIDECAR = new URL("./photon-sidecar.mjs", import.meta.url); const E164 = /^\+\d{6,15}$/; + +// The sidecar runs as a plain Node child process, which cannot read modules +// packed inside app.asar. Packaged asar builds ship a self-contained bundle +// under the unpacked desktop directory; loose packages use the source module. +function sidecarEntry(): string { + const override = String(process.env.PHOTON_SIDECAR_PATH || ""); + if (override) return override; + const source = fileURLToPath(SIDECAR); + if (!/app\.asar[\\/]/.test(source)) return source; + return join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs"); +} type PhotonCredential = { project_id: string; project_secret: string; operator_phone: string; assigned_phone: string; dashboard_token?: string; configured_at: number }; type PhotonEvent = { id: string; space_id: string; space_type: string; sender: string; text: string; timestamp: string }; type PhotonDispatch = (bot: Row, channelId: number, triggerId: number, threadRootId: number) => Promise; @@ -317,7 +328,7 @@ export async function startPhotonConnector(): Promise { const value = credentials(); if (!value?.project_id || !value.project_secret || child?.exitCode == null && child) return; const port = await freePort(); token = randomBytes(32).toString("hex"); base = `http://127.0.0.1:${port}`; - const sidecarPath = String(process.env.PHOTON_SIDECAR_PATH || fileURLToPath(SIDECAR)); + const sidecarPath = sidecarEntry(); const sidecarProcess: ChildProcess = spawn(process.execPath, [sidecarPath], { stdio: ["pipe", "ignore", "pipe"], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", PHOTON_PROJECT_ID: value.project_id, PHOTON_PROJECT_SECRET: value.project_secret, PHOTON_SIDECAR_TOKEN: token, PHOTON_SIDECAR_PORT: String(port) }, diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 8c21a13..ef8b6a6 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -181,7 +181,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.36"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.37"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/desktop.mjs b/test/desktop.mjs index 0bb29ac..89144a1 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -319,3 +319,61 @@ test("release packaging is fail-closed and records stable product identity", asy assert.match(source, /Cloudflared binary checksum does not match the release pin/, "release packaging verifies the exact upstream tunnel connector before signing"); assert.match(source, /codesign", \["--verify", "--strict", "--verbose=2", cloudflared\]/, "release packaging verifies the bundled connector's post-signing seal"); }); + +test("Windows packaging ships app code in asar and keeps external consumers on real files", async (t) => { + const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); + const mainCjs = await readFile(join(root, "desktop", "main.cjs"), "utf8"); + const photon = await readFile(join(root, "src", "server", "photon.ts"), "utf8"); + const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); + + // Legacy Squirrel expands every loose package file through 260-character + // .NET path APIs while releasifying and again on end-user machines during + // install/update. Application code must ship inside app.asar. + assert.doesNotMatch(windowsPackager, /asar: false/, "loose-file Windows packaging exceeds legacy Squirrel's path budget"); + assert.match(windowsPackager, /unpack: "\*\*\/\*\.node"/, "native modules are unpacked so they can load from disk"); + assert.match(windowsPackager, /unpackDir: "\{scripts,container,deploy,public,desktop,node_modules\/node-pty\}"/, "assets read by external processes stay on real disk"); + assert.match(windowsPackager, /missing app\.asar/, "packaging proves the archive was actually created"); + assert.match(windowsPackager, /app\.asar\.unpacked/, "packaging verifies required assets in the unpacked tree"); + assert.match(windowsPackager, /photon-sidecar\.bundle\.mjs is missing/, "packaging refuses to run without the sidecar bundle"); + assert.ok(String(pkg.scripts.build).includes("build:sidecar"), "the standard build produces the sidecar bundle"); + assert.ok(String(pkg.scripts["build:sidecar"]).includes("--bundle"), "the sidecar bundle is self-contained"); + + assert.match(mainCjs, /function unpackedPath\(/, "the desktop shell can translate asar paths for external consumers"); + assert.match(mainCjs, /const assetRoot = unpackedPath\(appRoot\)/, "external-asset root leaves the asar in packaged builds"); + assert.match(mainCjs, /process\.env\.HELM_APP_ROOT = assetRoot/, "server asset consumers receive the real on-disk root"); + assert.match(mainCjs, /process\.chdir\(assetRoot\)/, "chdir into a virtual asar path would throw at startup"); + assert.match(mainCjs, /unpackedPath\(path\.resolve\(__dirname, "\.\.", "scripts", "windows-removal\.cjs"\)\)/, "the Squirrel uninstall hook runs the removal script from real disk"); + assert.ok(photon.includes('join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs")'), "asar builds start the sidecar from the unpacked self-contained bundle"); + + if (process.platform === "win32") return; + // Prove the sidecar bundle actually builds and resolves every module on its + // own: run it from a foreign directory with no credentials and require an + // application-level outcome, never a module-resolution failure. + const bundleDir = await mkdtemp(join(tmpdir(), "1helm-sidecar-bundle-")); + t.after(() => rm(bundleDir, { recursive: true, force: true })); + const outfile = join(bundleDir, "photon-sidecar.bundle.mjs"); + await new Promise((resolveExit, rejectExit) => { + const build = spawn(join(root, "node_modules", ".bin", "esbuild"), [ + "src/server/photon-sidecar.mjs", "--bundle", "--platform=node", "--format=esm", `--outfile=${outfile}`, + "--banner:js=import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + ], { cwd: root, stdio: ["ignore", "ignore", "pipe"] }); + let errors = ""; + build.stderr.on("data", (chunk) => { errors += chunk; }); + build.once("exit", (code) => code === 0 ? resolveExit(null) : rejectExit(new Error(`sidecar bundle failed: ${errors.slice(-2000)}`))); + }); + const result = await new Promise((resolveExit) => { + const child = spawn(process.execPath, [outfile], { + cwd: bundleDir, + env: { ...process.env, PHOTON_SIDECAR_PORT: "0", PHOTON_SIDECAR_TOKEN: "test-token", PHOTON_PROJECT_ID: "", PHOTON_PROJECT_SECRET: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { output += chunk; }); + const timer = setTimeout(() => { child.kill("SIGKILL"); resolveExit(output); }, 8_000); + child.once("exit", () => { clearTimeout(timer); resolveExit(output); }); + }); + for (const signature of ["ERR_MODULE_NOT_FOUND", "Cannot find module", "Cannot find package", "Dynamic require of"]) { + assert.ok(!result.includes(signature), `sidecar bundle is not self-contained: ${result.slice(0, 400)}`); + } +});