diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 682fc9762f3..c69f097c3ba 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -1063,6 +1063,15 @@ jobs: - name: Build run: pnpm build + # The production image starts the server from this bundle with plain + # node (Dockerfile CMD), so prove it boots before the image is built: + # bundle the `pnpm build` output, start it against a throwaway embedded + # instance, and wait for /api/health to report ok. + - name: Boot the production runtime bundle + run: | + pnpm --filter @paperclipai/server build:runtime + scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs + # This verification only needs `pnpm build` output (the verifier stages # the public packages' dist and reuses ui/dist itself), yet it ran at the # end of Canary Dry Run, serially after the ~210-295s release.sh dry run. diff --git a/.gitignore b/.gitignore index 5907c86c94a..c7477f4f2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ dist-preview/ dist-flow-preview/ server/ui-dist/ +server/dist-runtime/ packages/paperclip-runner/runner/target/ ui/storybook-static/ ui/storybook/generated/ diff --git a/Dockerfile b/Dockerfile index 2f4e30ffdf0..542e64577d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -144,6 +144,11 @@ RUN pnpm --filter @paperclipai/plugin-sdk build ENV NODE_OPTIONS=--max-old-space-size=4096 RUN pnpm --filter @paperclipai/server build RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) +# Bundle the server with its workspace packages compiled in, so production +# starts with plain node instead of transpiling ~1,200 TypeScript sources +# through the tsx loader on every boot (server/scripts/bundle-runtime.mjs). +RUN pnpm --filter @paperclipai/server build:runtime \ + && test -f server/dist-runtime/index.mjs || (echo "ERROR: server runtime bundle missing" && exit 1) RUN rm -rf packages/paperclip-runner/runner/target # Remote OpenCode and ACPX runs require a controller-owned provider pack to @@ -230,8 +235,12 @@ EXPOSE 3100 # cgroup pid limit is exhausted and *every* fork() in the container fails. # tini reaps adopted orphans and forwards signals, so the exec chain below and # graceful shutdown are unchanged. Mirrors docker/agent-runtime/Dockerfile.base. -ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"] -CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] +# `-s` registers tini as a child subreaper, so it still reaps orphans when a +# platform runs its own init as PID 1 (tini then warns and reaps nothing). +ENTRYPOINT ["/usr/bin/tini", "-s", "--", "docker-entrypoint.sh"] +# The runtime bundle needs no loader. tsx stays installed for the few +# development-only paths that load workspace TypeScript on demand. +CMD ["node", "server/dist-runtime/index.mjs"] # Cloud image variant (build with `--target cloud`): the production image # plus built bundled sandbox-provider plugins. Managed instances receive a diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 2870a742db8..beb6793b8f5 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -32,6 +32,14 @@ docker build -t paperclip-local \ --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) . ``` +The image starts the server from a runtime bundle, `server/dist-runtime/index.mjs`, +with plain `node`. The build creates it from the server's `tsc` output with +`pnpm --filter @paperclipai/server build:runtime` (`server/scripts/bundle-runtime.mjs`), +compiling in the workspace packages that otherwise load as TypeScript source. +This avoids transpiling them through the tsx loader on every boot. To check a +build boots, run `scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs`; +PR CI runs the same check after `pnpm build`. + ## Standard images and downstream composition The Docker workflow publishes the standard `production` target for Linux AMD64 diff --git a/scripts/assert-cloud-image-sentry.mjs b/scripts/assert-cloud-image-sentry.mjs index 4b64b5cd2ec..eb33f3b14c5 100644 --- a/scripts/assert-cloud-image-sentry.mjs +++ b/scripts/assert-cloud-image-sentry.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node // Prove two things about the target image's server directory, in order: // -// 1. The production `CMD` can still find its ECMAScript module loader, -// `server/node_modules/tsx/dist/loader.mjs`. That path is a symbolic -// link into the workspace pnpm store, and the `cloud` stage's Sentry -// copy writes into the same `server/node_modules` directory. A copy -// that removes or shadows the link stops the container from booting. +// 1. The tsx loader, `server/node_modules/tsx/dist/loader.mjs`, still +// resolves. Workers that load workspace TypeScript on demand use it (the +// production `CMD` itself starts the runtime bundle with plain node). That +// path is a symbolic link into the workspace pnpm store, and the `cloud` +// stage's Sentry copy writes into the same `server/node_modules` +// directory, so a copy that removes or shadows the link breaks them. // 2. The installed `@sentry/node` package resolves, the same way the // server's own peer-version gate does // (server/src/peer-version-check.ts). Then print its version. @@ -27,19 +28,14 @@ import { fileURLToPath } from "node:url"; const serverDir = dirname(fileURLToPath(import.meta.url)); -// The production `CMD` boots the server through this exact path, resolved -// as a plain relative file path from the container's `/app` working -// directory (`./server/node_modules/tsx/dist/loader.mjs`), so it bypasses -// package-export checks and only needs the file to exist once symbolic -// links resolve. Follow the link the same way Node's own module loader -// does, so a broken or missing link fails this probe before it fails a -// live container. +// Follow the link the same way Node's own module loader does, so a broken +// or missing link fails this probe before it fails a live container. const tsxLoaderPath = join(serverDir, "node_modules", "tsx", "dist", "loader.mjs"); try { realpathSync(tsxLoaderPath); } catch (error) { console.error( - `could not resolve ${tsxLoaderPath}: the production CMD boots through this path and the server cannot start without it (${error.message})`, + `could not resolve ${tsxLoaderPath}: workers that load workspace TypeScript need this loader (${error.message})`, ); process.exit(1); } diff --git a/scripts/server-boot-smoke.sh b/scripts/server-boot-smoke.sh new file mode 100755 index 00000000000..34b9e684e15 --- /dev/null +++ b/scripts/server-boot-smoke.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Boot a built server command and wait for it to become healthy. +# +# scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs +# +# Starts the command against a throwaway instance (embedded Postgres, +# local_trusted mode, loopback only) and waits until GET /api/health reports +# status "ok", which means migrations applied and startup recovery finished. +# Prints the time to listen and the time to ready, stops the server, and exits +# non-zero if it never became ready, with the server log on stderr. +# +# The server runs with a cleared environment (only PATH and a scratch HOME), +# from a scratch working directory, with its config path pointed into the +# scratch directory, so no DATABASE_URL, config file or .env from the caller +# can point it at a real instance. It listens on a free port chosen at start, +# and a healthy response counts only after the child logs that it bound that +# port. Relative command arguments are resolved against the repository root. +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [args...]" >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +port="${SMOKE_PORT:-$(node -e 'const s=require("node:net").createServer().listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();})')}" +timeout_seconds="${SMOKE_TIMEOUT_SECONDS:-180}" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-boot-smoke.XXXXXX")" +log="$scratch/server.log" + +args=() +for arg in "$@"; do + if [ -e "$repo_root/$arg" ]; then args+=("$repo_root/$arg"); else args+=("$arg"); fi +done + +server_pid="" +cleanup() { + if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then + kill -TERM "$server_pid" 2>/dev/null || true + for _ in $(seq 1 50); do kill -0 "$server_pid" 2>/dev/null || break; sleep 0.2; done + kill -KILL "$server_pid" 2>/dev/null || true + fi + rm -rf "$scratch" +} +trap cleanup EXIT + +started_ms=$(node -e 'console.log(Date.now())') +( + cd "$scratch" + exec env -i \ + PATH="$PATH" \ + HOME="$scratch" \ + NODE_ENV=production \ + PAPERCLIP_CONFIG="$scratch/config.json" \ + PAPERCLIP_HOME="$scratch/home" \ + PAPERCLIP_INSTANCE_ID=boot-smoke \ + PAPERCLIP_DEPLOYMENT_MODE=local_trusted \ + PAPERCLIP_MIGRATION_AUTO_APPLY=true \ + HOST=127.0.0.1 \ + PORT="$port" \ + SERVE_UI=false \ + "${args[@]}" +) >"$log" 2>&1 & +server_pid=$! + +listen_ms="" +deadline=$(( $(date +%s) + timeout_seconds )) +while [ "$(date +%s)" -lt "$deadline" ]; do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "FAIL: server exited before becoming ready" >&2 + cat "$log" >&2 + exit 1 + fi + # Only trust the port once this child has bound it. + if ! grep -q "listener bound on 127.0.0.1:$port" "$log" 2>/dev/null; then sleep 0.5; continue; fi + body="$(curl -fsS --max-time 2 "http://127.0.0.1:$port/api/health" 2>/dev/null || true)" + if [ -n "$body" ] && [ -z "$listen_ms" ]; then + listen_ms=$(node -e 'console.log(Date.now())') + fi + if printf '%s' "$body" | grep -q '"status":"ok"'; then + ready_ms=$(node -e 'console.log(Date.now())') + echo "boot smoke ok: listening after $(( listen_ms - started_ms )) ms, ready after $(( ready_ms - started_ms )) ms" + exit 0 + fi + sleep 0.5 +done + +echo "FAIL: /api/health did not report ok within ${timeout_seconds}s" >&2 +echo "last response: ${body:-}" >&2 +cat "$log" >&2 +exit 1 diff --git a/server/package.json b/server/package.json index ab0bf9e23a2..8ef255259c5 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "prepack": "pnpm run prepare:ui-dist && pnpm run build", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", + "build:runtime": "node scripts/bundle-runtime.mjs", "start": "node dist/index.js", "prepare:runner-vendor": "node scripts/prepare-runner-vendor.mjs", "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" diff --git a/server/scripts/bundle-runtime.mjs b/server/scripts/bundle-runtime.mjs new file mode 100644 index 00000000000..8539f97a04a --- /dev/null +++ b/server/scripts/bundle-runtime.mjs @@ -0,0 +1,174 @@ +// Bundle the built server into `dist-runtime/` for production startup. +// +// The server's tsc output imports the workspace packages as TypeScript source +// (their package exports point at `src/*.ts`), so production used to start +// Node with the tsx loader. Every boot then resolved and transpiled thousands +// of modules through tsx's hooks: about 8-10 s warm and over 20 s on a cold +// container, all before the server could listen. This script runs after the +// server `build` and bundles `dist/index.js`, with the workspace packages +// compiled in, so production starts with plain `node` in a few seconds. +// +// The bundle keeps runtime behavior the same as running `dist/index.js`: +// +// - Third-party packages stay external. A package the server package itself +// resolves to the same file stays a bare specifier, so Node resolves it at +// runtime from server/node_modules exactly as before (the cloud image adds +// optional peers such as @sentry/node there after the build). Only a +// dependency that pnpm's strict layout hides from the server package, one +// that only a workspace package can see, is emitted as an absolute path. +// Resolution follows Node's rules (the `main` field and `node` condition), +// not bundler-only `module` builds. +// - Every bundled module keeps its own location: `import.meta.url`, +// `import.meta.dirname` and `import.meta.filename` are replaced with the +// module's original path, so code that finds files next to its source +// (scripts, assets, worker entry files) keeps finding them. +// - Code splitting keeps dynamic imports as separate chunks. Bundled into a +// single file, the server's top-level await never settled (observed on the +// production build); split chunks keep the module evaluation order Node +// uses for the unbundled output. +// - A `require` built from the chunk's location is provided for the few +// modules that call `require` from ESM. +// - The bundle's entry sets `process.argv[1]` to the original entry file +// before loading it, so the server's main-module check (and anything else +// that reads argv[1]) sees exactly what it saw when started unbundled. + +import { readFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const serverDir = join(scriptDir, ".."); + +const REQUIRE_SHIM = "import { createRequire as __paperclipCreateRequire } from 'node:module'; const require = __paperclipCreateRequire(import.meta.url);"; + +/** + * Replace a module's `import.meta` location properties with its original + * path, so bundling does not move where the module looks for nearby files. + * + * @param {string} source Module source. + * @param {string} file Absolute path of the module's original file. + * @returns {string} + */ +export function preserveModuleLocation(source, file) { + return source + .replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(file).href)) + .replace(/\bimport\.meta\.dirname\b/g, JSON.stringify(dirname(file))) + .replace(/\bimport\.meta\.filename\b/g, JSON.stringify(file)); +} + +/** + * The bundle's entry: present the original entry file as the main module, + * then load it. + * + * @param {string} entry Absolute path of the original entry file. + * @returns {import("esbuild").Plugin} + */ +function runtimeEntry(entry) { + return { + name: "runtime-entry", + setup(builder) { + builder.onResolve({ filter: /^paperclip-runtime-entry$/ }, () => ({ path: "paperclip-runtime-entry", namespace: "runtime-entry" })); + builder.onLoad({ filter: /.*/, namespace: "runtime-entry" }, () => ({ + contents: `process.argv[1] = ${JSON.stringify(entry)};\nawait import(${JSON.stringify(entry)});\n`, + loader: "js", + resolveDir: dirname(entry), + })); + }, + }; +} + +/** @type {import("esbuild").Plugin} */ +const keepModuleLocations = { + name: "keep-module-locations", + setup(builder) { + builder.onLoad({ filter: /\.(?:[cm]?js|[cm]?ts)$/ }, async (args) => { + if (args.path.includes("/node_modules/")) return undefined; + const source = await readFile(args.path, "utf8"); + if (!source.includes("import.meta")) return undefined; + return { + contents: preserveModuleLocation(source, args.path), + loader: /\.[cm]?ts$/.test(args.path) ? "ts" : "js", + resolveDir: dirname(args.path), + }; + }); + }, +}; + +/** + * @param {(specifier: string) => boolean} isBundled Whether a bare specifier is + * bundled (workspace packages) rather than kept external. + * @param {string} runtimeDir A directory inside the server package, from which + * bare specifiers resolve at runtime. + * @returns {import("esbuild").Plugin} + */ +function externalizeThirdParty(isBundled, runtimeDir) { + return { + name: "externalize-third-party", + setup(builder) { + builder.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData?.externalizeThirdParty || isBundled(args.path)) return undefined; + const resolved = await builder.resolve(args.path, { + kind: args.kind, + resolveDir: args.resolveDir, + importer: args.importer, + pluginData: { externalizeThirdParty: true }, + }); + // Builtins and anything esbuild cannot resolve stay as written; + // Node resolves them at runtime exactly as before. + if (resolved.errors.length > 0 || resolved.external) return { path: args.path, external: true }; + const fromServer = await builder.resolve(args.path, { + kind: args.kind, + resolveDir: runtimeDir, + pluginData: { externalizeThirdParty: true }, + }); + if (fromServer.errors.length === 0 && fromServer.path === resolved.path) return { path: args.path, external: true }; + return { path: resolved.path, external: true }; + }); + }, + }; +} + +/** + * Bundle a built entry point and everything it imports from the workspace. + * + * @param {{ + * entry: string, + * outdir: string, + * isBundled?: (specifier: string) => boolean, + * }} options + * @returns {Promise<{ outfile: string, inputs: number, chunks: number }>} + */ +export async function bundleRuntime({ entry, outdir, isBundled = (specifier) => specifier.startsWith("@paperclipai/") }) { + await rm(outdir, { recursive: true, force: true }); + const result = await build({ + entryPoints: { index: "paperclip-runtime-entry" }, + outdir, + outExtension: { ".js": ".mjs" }, + chunkNames: "chunk-[hash]", + bundle: true, + splitting: true, + format: "esm", + platform: "node", + target: "node24", + mainFields: ["main"], + conditions: ["node"], + banner: { js: REQUIRE_SHIM }, + sourcemap: "linked", + metafile: true, + logLevel: "error", + plugins: [runtimeEntry(entry), keepModuleLocations, externalizeThirdParty(isBundled, dirname(entry))], + }); + const outputs = Object.keys(result.metafile.outputs).filter((file) => file.endsWith(".mjs")); + return { outfile: join(outdir, "index.mjs"), inputs: Object.keys(result.metafile.inputs).length, chunks: outputs.length }; +} + +async function main() { + const started = performance.now(); + const summary = await bundleRuntime({ entry: join(serverDir, "dist", "index.js"), outdir: join(serverDir, "dist-runtime") }); + console.log(`[bundle-runtime] ${summary.inputs} inputs -> ${summary.chunks} chunks in ${Math.round(performance.now() - started)} ms: ${summary.outfile}`); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await main(); +} diff --git a/server/scripts/bundle-runtime.test.mjs b/server/scripts/bundle-runtime.test.mjs new file mode 100644 index 00000000000..2f34a9bf931 --- /dev/null +++ b/server/scripts/bundle-runtime.test.mjs @@ -0,0 +1,122 @@ +import { execFile } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { bundleRuntime, preserveModuleLocation } from "./bundle-runtime.mjs"; + +const execFileAsync = promisify(execFile); + +function write(file, content) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe("bundleRuntime", () => { + let root; + + beforeEach(() => { + root = realpathSync(mkdtempSync(path.join(tmpdir(), "paperclip-bundle-runtime-"))); + // A workspace package that exports TypeScript source, like packages/*. + const pkg = path.join(root, "packages", "fixture"); + write(path.join(pkg, "package.json"), JSON.stringify({ name: "@paperclipai/fixture", type: "module", exports: { ".": "./src/index.ts" } })); + write(path.join(pkg, "src", "asset.txt"), "asset beside its source\n"); + write(path.join(pkg, "src", "helper.ts"), "export const double = (value: number): number => value * 2;\n"); + write(path.join(pkg, "src", "index.ts"), [ + "import { readFileSync } from \"node:fs\";", + "import { double } from \"./helper.js\";", + "import { onlyForPackage } from \"package-private-dep\";", + "import { nodeBuild } from \"dual-build-dep\";", + "export const asset = readFileSync(new URL(\"./asset.txt\", import.meta.url), \"utf8\").trim();", + "export const location = import.meta.dirname;", + "export const values = { doubled: double(21), onlyForPackage, nodeBuild };", + "", + ].join("\n")); + // A dependency only the workspace package can see (pnpm strict layout). + write(path.join(pkg, "node_modules", "package-private-dep", "package.json"), JSON.stringify({ name: "package-private-dep", type: "module", main: "index.js" })); + write(path.join(pkg, "node_modules", "package-private-dep", "index.js"), "export const onlyForPackage = \"resolved from the package\";\n"); + // A dependency whose bundler-only "module" build is not valid Node ESM; + // Node ignores the "module" condition, bundlers prefer it. + write(path.join(pkg, "node_modules", "dual-build-dep", "package.json"), JSON.stringify({ + name: "dual-build-dep", exports: { ".": { module: "./bundler/index.js", default: "./node.cjs" } }, + })); + write(path.join(pkg, "node_modules", "dual-build-dep", "node.cjs"), "exports.nodeBuild = \"node build\";\n"); + write(path.join(pkg, "node_modules", "dual-build-dep", "bundler", "index.js"), "export * from \"./extensionless\";\n"); + + // The server package sees the workspace package through its node_modules. + const server = path.join(root, "server"); + mkdirSync(path.join(server, "node_modules", "@paperclipai"), { recursive: true }); + symlinkSync(pkg, path.join(server, "node_modules", "@paperclipai", "fixture"), "dir"); + write(path.join(server, "package.json"), JSON.stringify({ name: "server", type: "module" })); + // A dependency the server package resolves itself, linked from a store + // directory the way pnpm installs packages. + for (const [version, flavor] of [["1", "installed at build"], ["2", "replaced after build"]]) { + const store = path.join(root, "node_modules", ".pnpm", `server-dep@${version}`); + write(path.join(store, "package.json"), JSON.stringify({ name: "server-dep", type: "module", main: "index.js" })); + write(path.join(store, "index.js"), `export const flavor = ${JSON.stringify(flavor)};\n`); + } + symlinkSync(path.join(root, "node_modules", ".pnpm", "server-dep@1"), path.join(server, "node_modules", "server-dep"), "dir"); + // The entry awaits a dynamic import of another built module. + write(path.join(server, "dist", "late.js"), "import { values } from \"@paperclipai/fixture\";\nexport const late = () => values.doubled + 1;\n"); + write(path.join(server, "dist", "index.js"), [ + "import { asset, location, values } from \"@paperclipai/fixture\";", + "import { flavor } from \"server-dep\";", + "import { pathToFileURL } from \"node:url\";", + "const { late } = await import(\"./late.js\");", + "// The server only starts when it is the main module.", + "if (pathToFileURL(process.argv[1]).href === import.meta.url) {", + " console.log(JSON.stringify({ asset, location, values, flavor, late: late(), entryDir: import.meta.dirname }));", + "}", + "", + ].join("\n")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("bundles workspace TypeScript and runs with plain node", async () => { + const outdir = path.join(root, "server", "dist-runtime"); + const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir }); + expect(summary.outfile).toBe(path.join(outdir, "index.mjs")); + + const { stdout } = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), timeout: 30_000 }); + const output = JSON.parse(stdout); + expect(output.values).toEqual({ doubled: 42, onlyForPackage: "resolved from the package", nodeBuild: "node build" }); + expect(output.late).toBe(43); + // Modules keep their original locations, so nearby files still resolve. + expect(output.asset).toBe("asset beside its source"); + expect(output.location).toBe(path.join(root, "packages", "fixture", "src")); + expect(output.entryDir).toBe(path.join(root, "server", "dist")); + }); + + it("leaves the server's own dependencies to runtime resolution", async () => { + const outdir = path.join(root, "server", "dist-runtime"); + const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir }); + // Re-point the package after the build, as the cloud image does when it + // installs optional peers into server/node_modules; the bundle must pick + // up the replacement instead of the store path seen at build time. + rmSync(path.join(root, "server", "node_modules", "server-dep")); + symlinkSync(path.join(root, "node_modules", ".pnpm", "server-dep@2"), path.join(root, "server", "node_modules", "server-dep"), "dir"); + const { stdout } = await execFileAsync(process.execPath, [summary.outfile], { cwd: path.join(root, "server"), timeout: 30_000 }); + expect(JSON.parse(stdout).flavor).toBe("replaced after build"); + }); + + it("keeps third-party packages external", async () => { + const outdir = path.join(root, "server", "dist-runtime"); + await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir }); + const { stdout } = await execFileAsync("grep", ["-rl", "resolved from the package", outdir], { encoding: "utf8" }).catch(() => ({ stdout: "" })); + expect(stdout.trim()).toBe(""); + }); +}); + +describe("preserveModuleLocation", () => { + it("replaces import.meta location properties with the original path", () => { + const file = "/srv/app/packages/x/src/mod.ts"; + const out = preserveModuleLocation("a(import.meta.url); b(import.meta.dirname); c(import.meta.filename); d(import.meta.resolve);", file); + expect(out).toBe(`a("file:///srv/app/packages/x/src/mod.ts"); b("/srv/app/packages/x/src"); c("/srv/app/packages/x/src/mod.ts"); d(import.meta.resolve);`); + }); +}); diff --git a/server/src/__tests__/container-init-reaping.test.ts b/server/src/__tests__/container-init-reaping.test.ts index 217dfa886ad..baf91cd17f8 100644 --- a/server/src/__tests__/container-init-reaping.test.ts +++ b/server/src/__tests__/container-init-reaping.test.ts @@ -74,10 +74,12 @@ describe("server image init", () => { const lines = entrypoints(dockerfile); expect(lines.length, "Dockerfile must declare an ENTRYPOINT").toBeGreaterThan(0); for (const line of lines) { + // `-s` registers tini as a child subreaper, so it still reaps orphans + // when a platform runs its own init as PID 1. expect( line, - "node must not inherit PID 1: wrap the entrypoint in tini so adopted orphans are reaped", - ).toBe('ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"]'); + "node must not inherit PID 1: wrap the entrypoint in tini (as a subreaper) so adopted orphans are reaped", + ).toBe('ENTRYPOINT ["/usr/bin/tini", "-s", "--", "docker-entrypoint.sh"]'); } }); diff --git a/server/src/services/agent-avatar-pool.ts b/server/src/services/agent-avatar-pool.ts index bda5ab4a82b..3522019e69a 100644 --- a/server/src/services/agent-avatar-pool.ts +++ b/server/src/services/agent-avatar-pool.ts @@ -13,7 +13,12 @@ export function createAgentAvatarPool(concurrency = 2, maxQueue = 64) { function spawn() { const source = new URL(import.meta.url).pathname.endsWith(".ts"); const url = new URL(source ? "./agent-avatar-worker.ts" : "./agent-avatar-worker.js", import.meta.url); - const worker = source + // The worker imports workspace packages as TypeScript source. A worker + // inherits the tsx loader only when the main thread was started with it; + // the production runtime bundle runs without a loader, so load the + // worker through tsx's API instead. + const loaderInherited = process.execArgv.some((arg) => arg.includes("tsx")); + const worker = source || !loaderInherited ? new Worker(`import(${JSON.stringify(import.meta.resolve('tsx/esm/api'))}).then(({tsImport}) => tsImport(${JSON.stringify(url.href)}, ${JSON.stringify(import.meta.url)}));`, { eval: true }) : new Worker(url); workers.add(worker);