1import * as ByteSize from "effect/ByteSize";
2import * as Effect from "effect/Effect";
3import * as Duration from "effect/Duration";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Redacted from "effect/Redacted";
7import * as Stream from "effect/Stream";
8import * as Option from "effect/Option";
9import type { Scope } from "effect/Scope";
10import * as ChildProcess from "effect/process/ChildProcess";
11import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
12import { normalizeEntrypoint } from "./ComputeArchive.ts";
13
14// `@vercel/nft` is an optional peer dependency — loaded lazily so importing
15// the Prisma provider never requires it unless a NestJS artifact is traced.
16const importNft = () =>
17 import("@vercel/nft").catch((cause) => {
18 throw new Error(
19 "Failed to load '@vercel/nft'. Install the optional peer dependency '@vercel/nft' to build NestJS apps for Prisma Compute.",
20 { cause },
21 );
22 });
23
24export type ComputeAutoBuildFramework =
25 | "auto"
26 | "nextjs"
27 | "nuxt"
28 | "astro"
29 | "nestjs"
30 | "tanstack-start"
31 | "vite"
32 | "bun";
33
34export interface ComputeAutoBuildOptions {
35 /**
36 * Application root.
37 */
38 appPath: string;
39 /**
40 * Entrypoint used by the Bun fallback strategy.
41 */
42 entrypoint?: string;
43 /**
44 * Framework to build. `auto` tries Next.js, Nuxt, Astro, TanStack Start,
45 * Vite, then Bun.
46 *
47 * @default "auto"
48 */
49 framework?: ComputeAutoBuildFramework;
50 /**
51 * Environment variables supplied to the build command.
52 * Ambient `PRISMA_SERVICE_TOKEN` and `PRISMA_API_TOKEN` credentials are not
53 * inherited; include one here explicitly only when the application build
54 * genuinely needs Prisma Management API access.
55 *
56 * Plain strings are persisted in Alchemy state when this is configured
57 * through `Prisma.Compute`. Wrap secrets with `Redacted.make(secret)`.
58 *
59 * ```typescript
60 * env: { NPM_TOKEN: Redacted.make(process.env.NPM_TOKEN!) }
61 * ```
62 */
63 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
64 /**
65 * Maximum bytes retained from each build output stream.
66 *
67 * @default 1048576 (1 MiB)
68 */
69 outputLimitBytes?: number;
70 /**
71 * Maximum wall-clock time for the framework build command.
72 *
73 * @default 900 (15 minutes)
74 */
75 timeoutSeconds?: number;
76}
77
78export interface ComputeBuildArtifact {
79 /**
80 * Directory to archive and upload.
81 */
82 directory: string;
83 /**
84 * Entrypoint relative to `directory`.
85 */
86 entrypoint: string;
87 /**
88 * Default HTTP port for framework conventions.
89 */
90 defaultPort?: number;
91 /**
92 * Directory within the artifact that user-authored archive ignore patterns
93 * are relative to.
94 */
95 archiveIgnorePrefix?: string;
96 /**
97 * Artifact-relative files that must remain present after archiving.
98 */
99 requiredFiles?: readonly string[];
100 /**
101 * Removes temporary build output.
102 */
103 cleanup: Effect.Effect<void, never, FileSystem.FileSystem>;
104}
105
106export interface ComputeStaticBuildOptions {
107 /**
108 * Application root.
109 */
110 appPath: string;
111 /**
112 * Optional shell command that creates the static output directory.
113 */
114 command?: string;
115 /**
116 * Working directory for the build command and static output.
117 *
118 * @default appPath
119 */
120 cwd?: string;
121 /**
122 * Static output directory, relative to `cwd`.
123 */
124 outdir: string;
125 /**
126 * HTML file served at the root and for SPA fallbacks.
127 *
128 * @default "index.html"
129 */
130 indexPage?: string;
131 /**
132 * Serve the index page when no static file matches the request.
133 *
134 * @default false
135 */
136 spa?: boolean;
137 /**
138 * Environment variables supplied to the build command.
139 */
140 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
141 /**
142 * Maximum bytes retained from each build output stream.
143 *
144 * @default 1048576 (1 MiB)
145 */
146 outputLimitBytes?: number;
147 /**
148 * Maximum wall-clock time for the build command.
149 *
150 * @default 900 (15 minutes)
151 */
152 timeoutSeconds?: number;
153}
154
155interface BuildStrategy {
156 name: Exclude<ComputeAutoBuildFramework, "auto">;
157 canBuild: (appPath: string) => Effect.Effect<boolean, unknown, BuildServices>;
158 execute: (
159 options: Required<Pick<ComputeAutoBuildOptions, "appPath">> &
160 Omit<ComputeAutoBuildOptions, "appPath">,
161 ) => Effect.Effect<ComputeBuildArtifact, unknown, BuildServices>;
162}
163
164interface DirectoryEntry {
165 name: string;
166 type: "Directory" | "File" | "SymbolicLink" | "Other";
167}
168
169type BuildServices =
170 | ChildProcessSpawner
171 | FileSystem.FileSystem
172 | Path.Path
173 | Scope;
174
175export interface RunBuildCommandOptions {
176 command: string;
177 cwd?: string;
178 /**
179 * Explicit environment variables supplied to the build command. Ambient
180 * Prisma Management API credentials are withheld unless they are provided
181 * here intentionally.
182 */
183 env?: Record<string, string>;
184 /**
185 * Maximum bytes retained from each output stream before the build is
186 * interrupted. This prevents noisy build tools from exhausting memory.
187 *
188 * @default 1048576 (1 MiB)
189 */
190 outputLimitBytes?: number;
191 /**
192 * Maximum wall-clock time for the build command.
193 *
194 * @default 900 (15 minutes)
195 */
196 timeoutSeconds?: number;
197}
198
199const DEFAULT_BUILD_OUTPUT_LIMIT_BYTES = 1024 * 1024;
200const DEFAULT_BUILD_TIMEOUT_SECONDS = 15 * 60;
201const STAGING_MAX_ENTRIES = 50_000;
202const STAGING_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
203const STAGING_MAX_FILE_BYTES = 128 * 1024 * 1024;
204const STAGING_TIMEOUT_SECONDS = 2 * 60;
205const PRISMA_MANAGEMENT_CREDENTIAL_ENV_NAMES = new Set([
206 "PRISMA_SERVICE_TOKEN",
207 "PRISMA_API_TOKEN",
208]);
209
210interface StagingBudget {
211 entries: number;
212 totalBytes: number;
213 readonly deadline: number;
214}
215
216const collectBoundedOutput = (
217 stream: Stream.Stream<Uint8Array, unknown>,
218 label: string,
219 limit: number,
220) =>
221 Stream.runFoldEffect(
222 stream,
223 () => ({ chunks: [] as Uint8Array[], bytes: 0 }),
224 (state, chunk) => {
225 const bytes = state.bytes + chunk.byteLength;
226 return bytes > limit
227 ? Effect.fail(
228 new Error(
229 `Build ${label} exceeded the ${limit} byte output safety limit. Reduce build verbosity or raise outputLimitBytes explicitly.`,
230 ),
231 )
232 : Effect.sync(() => {
233 state.chunks.push(chunk);
234 return { chunks: state.chunks, bytes };
235 });
236 },
237 ).pipe(
238 Effect.map(({ chunks, bytes }) => {
239 const output = new Uint8Array(bytes);
240 let offset = 0;
241 for (const chunk of chunks) {
242 output.set(chunk, offset);
243 offset += chunk.byteLength;
244 }
245 return { text: new TextDecoder().decode(output), bytes };
246 }),
247 );
248
249/**
250 * Run a shell build command, dying on non-zero exit. Local stand-in for the
251 * removed `Build/Command.ts` helper. Build commands inherit the ambient
252 * environment except for Prisma Management API credentials, which must be
253 * passed explicitly through `env` when a build genuinely needs them.
254 */
255export const runBuildCommand = Effect.fn(function* ({
256 command,
257 cwd,
258 env,
259 outputLimitBytes = DEFAULT_BUILD_OUTPUT_LIMIT_BYTES,
260 timeoutSeconds = DEFAULT_BUILD_TIMEOUT_SECONDS,
261}: RunBuildCommandOptions) {
262 if (!Number.isSafeInteger(outputLimitBytes) || outputLimitBytes <= 0) {
263 return yield* Effect.fail(
264 new Error("outputLimitBytes must be a positive safe integer"),
265 );
266 }
267 if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
268 return yield* Effect.fail(
269 new Error("timeoutSeconds must be a positive finite number"),
270 );
271 }
272 const spawner = yield* ChildProcessSpawner;
273 const path = yield* Path.Path;
274 const resultOption = yield* Effect.scoped(
275 Effect.gen(function* () {
276 const handle = yield* spawner.spawn(
277 ChildProcess.make(command, [], {
278 cwd: path.resolve(cwd ?? "."),
279 shell: true,
280 env: buildProcessEnvironment(env),
281 extendEnv: false,
282 stdin: "ignore",
283 stdout: "pipe",
284 stderr: "pipe",
285 killSignal: "SIGKILL",
286 }),
287 );
288 const result = yield* Effect.all(
289 {
290 exitCode: handle.exitCode,
291 stdout: collectBoundedOutput(
292 handle.stdout,
293 "stdout",
294 outputLimitBytes,
295 ),
296 stderr: collectBoundedOutput(
297 handle.stderr,
298 "stderr",
299 outputLimitBytes,
300 ),
301 },
302 { concurrency: "unbounded" },
303 );
304 // A successful build shell can still leave daemonized descendants in
305 // its detached process group. Always reap the group after output has
306 // closed; ESRCH is expected when no descendants remain.
307 yield* handle.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore);
308 return result;
309 }).pipe(Effect.timeoutOption(Duration.seconds(timeoutSeconds))),
310 );
311 if (Option.isNone(resultOption)) {
312 return yield* Effect.fail(
313 new Error(`Build command timed out after ${timeoutSeconds} seconds.`),
314 );
315 }
316 const result = resultOption.value;
317
318 if (result.exitCode !== 0) {
319 return yield* Effect.fail(
320 new Error(
321 `Build command failed with exit code ${result.exitCode} (stdout: ${result.stdout.bytes} bytes; stderr: ${result.stderr.bytes} bytes).`,
322 ),
323 );
324 }
325
326 yield* Effect.logDebug("Build command completed", {
327 stdoutBytes: result.stdout.bytes,
328 stderrBytes: result.stderr.bytes,
329 });
330
331 return {
332 exitCode: result.exitCode,
333 stdout: result.stdout.text,
334 stderr: result.stderr.text,
335 };
336});
337
338const buildProcessEnvironment = (
339 explicitEnv: Record<string, string> = {},
340): Record<string, string> => ({
341 ...Object.fromEntries(
342 Object.entries(process.env).filter(
343 (entry): entry is [string, string] =>
344 entry[1] !== undefined &&
345 !PRISMA_MANAGEMENT_CREDENTIAL_ENV_NAMES.has(entry[0].toUpperCase()),
346 ),
347 ),
348 ...explicitEnv,
349});
350
351const NEXT_CONFIG_FILENAMES = [
352 "next.config.js",
353 "next.config.mjs",
354 "next.config.ts",
355 "next.config.mts",
356] as const;
357
358const NUXT_CONFIG_FILENAMES = [
359 "nuxt.config.js",
360 "nuxt.config.mjs",
361 "nuxt.config.cjs",
362 "nuxt.config.ts",
363 "nuxt.config.mts",
364] as const;
365
366const ASTRO_CONFIG_FILENAMES = [
367 "astro.config.js",
368 "astro.config.mjs",
369 "astro.config.cjs",
370 "astro.config.ts",
371 "astro.config.mts",
372] as const;
373
374const NEST_CLI_FILENAME = "nest-cli.json";
375const NEST_TSCONFIG_FILENAMES = [
376 "tsconfig.build.json",
377 "tsconfig.json",
378] as const;
379const NEST_DEFAULT_COMPILED_ENTRYPOINTS = [
380 "dist/src/main.js",
381 "dist/main.js",
382] as const;
383
384const TANSTACK_START_PACKAGES = [
385 "@tanstack/react-start",
386 "@tanstack/solid-start",
387] as const;
388
389const strategies: readonly BuildStrategy[] = [
390 {
391 name: "nextjs",
392 canBuild: (appPath) =>
393 Effect.gen(function* () {
394 return (
395 (yield* hasRootFile(appPath, NEXT_CONFIG_FILENAMES)) ||
396 (yield* hasPackageDependency(appPath, ["next"]))
397 );
398 }),
399 execute: (options) =>
400 buildFramework({
401 appPath: options.appPath,
402 env: options.env,
403 outputLimitBytes: options.outputLimitBytes,
404 timeoutSeconds: options.timeoutSeconds,
405 cliName: "next",
406 args: ["build"],
407 failurePrefix: "Next.js",
408 missingMessage:
409 "Could not find an installed Next.js CLI. Install `next` in the application or workspace before deploying.",
410 sourceDir: ".next/standalone",
411 entrypoint: "server.js",
412 defaultPort: 3000,
413 allowNestedEntrypoint: true,
414 extrasRelativeToEntrypoint: true,
415 missingOutputMessage:
416 'Next.js build did not produce standalone output. Add output: "standalone" to your next.config file.',
417 extras: [
418 { from: "public", to: "public" },
419 { from: ".next/static", to: ".next/static" },
420 ],
421 }),
422 },
423 {
424 name: "nuxt",
425 canBuild: (appPath) =>
426 Effect.gen(function* () {
427 return (
428 (yield* hasRootFile(appPath, NUXT_CONFIG_FILENAMES)) ||
429 (yield* hasPackageDependency(appPath, ["nuxt"]))
430 );
431 }),
432 execute: (options) =>
433 buildFramework({
434 appPath: options.appPath,
435 env: options.env,
436 outputLimitBytes: options.outputLimitBytes,
437 timeoutSeconds: options.timeoutSeconds,
438 cliName: "nuxt",
439 args: ["build"],
440 failurePrefix: "Nuxt",
441 missingMessage:
442 "Could not find an installed Nuxt CLI. Install `nuxt` in the application or workspace before deploying.",
443 sourceDir: ".output",
444 entrypoint: "server/index.mjs",
445 defaultPort: 3000,
446 requiredFile: ".output/server/index.mjs",
447 missingOutputMessage:
448 "Nuxt build did not produce a Nitro node server entrypoint at .output/server/index.mjs. Ensure nitro.preset is 'node-server' (the default).",
449 }),
450 },
451 {
452 name: "astro",
453 canBuild: (appPath) =>
454 Effect.gen(function* () {
455 return (
456 (yield* hasRootFile(appPath, ASTRO_CONFIG_FILENAMES)) ||
457 (yield* hasPackageDependency(appPath, ["astro"]))
458 );
459 }),
460 execute: (options) =>
461 buildFramework({
462 appPath: options.appPath,
463 env: options.env,
464 outputLimitBytes: options.outputLimitBytes,
465 timeoutSeconds: options.timeoutSeconds,
466 cliName: "astro",
467 args: ["build"],
468 failurePrefix: "Astro",
469 missingMessage:
470 "Could not find an installed Astro CLI. Install `astro` in the application or workspace before deploying.",
471 sourceDir: "dist",
472 entrypoint: "server/entry.mjs",
473 defaultPort: 4321,
474 requiredFile: "dist/server/entry.mjs",
475 missingOutputMessage:
476 'Astro build did not produce a standalone server entrypoint. Install @astrojs/node and configure it with adapter: node({ mode: "standalone" }) in your astro.config file.',
477 }),
478 },
479 {
480 name: "nestjs",
481 canBuild: (appPath) =>
482 Effect.gen(function* () {
483 return (
484 (yield* hasRootFile(appPath, [NEST_CLI_FILENAME])) ||
485 (yield* hasPackageDependency(appPath, ["@nestjs/core"]))
486 );
487 }),
488 execute: (options) => buildNestjs(options),
489 },
490 {
491 name: "tanstack-start",
492 canBuild: (appPath) =>
493 hasPackageDependency(appPath, TANSTACK_START_PACKAGES),
494 execute: (options) =>
495 buildFramework({
496 appPath: options.appPath,
497 env: options.env,
498 outputLimitBytes: options.outputLimitBytes,
499 timeoutSeconds: options.timeoutSeconds,
500 cliName: "vite",
501 args: ["build"],
502 failurePrefix: "TanStack Start",
503 missingMessage:
504 "Could not find an installed Vite CLI. Install `vite` in the application or workspace before deploying.",
505 sourceDir: ".output",
506 entrypoint: "server/index.mjs",
507 defaultPort: 3000,
508 requiredFile: ".output/server/index.mjs",
509 missingOutputMessage:
510 "TanStack Start build did not produce a Nitro node server entrypoint at .output/server/index.mjs. Ensure your vite.config includes the TanStack Start and Nitro plugins with the default node preset.",
511 }),
512 },
513 {
514 name: "vite",
515 canBuild: (appPath) => hasPackageDependency(appPath, ["vite"]),
516 execute: (options) =>
517 Effect.gen(function* () {
518 const command = yield* packageCliCommand(
519 options.appPath,
520 "vite",
521 ["build"],
522 "Could not find an installed Vite CLI. Install `vite` in the application or workspace before deploying.",
523 );
524 return yield* runComputeStaticBuild({
525 appPath: options.appPath,
526 command,
527 outdir: "dist",
528 spa: true,
529 env: options.env,
530 outputLimitBytes: options.outputLimitBytes,
531 timeoutSeconds: options.timeoutSeconds,
532 });
533 }),
534 },
535 {
536 name: "bun",
537 canBuild: () => Effect.succeed(true),
538 execute: (options) => buildBun(options),
539 },
540] as const;
541
542export const runComputeAutoBuild = Effect.fn(function* (
543 options: ComputeAutoBuildOptions,
544) {
545 const requested = options.framework ?? "auto";
546 const candidates =
547 requested === "auto"
548 ? strategies
549 : strategies.filter((strategy) => strategy.name === requested);
550
551 for (const strategy of candidates) {
552 if (yield* strategy.canBuild(options.appPath)) {
553 return yield* strategy.execute(options);
554 }
555 }
556
557 return yield* Effect.fail(
558 new Error("No suitable Prisma Compute auto-build strategy found."),
559 );
560});
561
562export const runComputeStaticBuild = Effect.fn(function* (
563 options: ComputeStaticBuildOptions,
564) {
565 const fs = yield* FileSystem.FileSystem;
566 const path = yield* Path.Path;
567 const cwd = options.cwd ? path.resolve(options.cwd) : options.appPath;
568 const outdir = normalizeRelativePath(options.outdir);
569 if (outdir === undefined) {
570 return yield* Effect.fail(
571 new Error(
572 "Static site outdir must be a relative path without parent segments.",
573 ),
574 );
575 }
576 const indexPage = normalizeRelativePath(options.indexPage ?? "index.html");
577 if (indexPage === undefined || indexPage === ".") {
578 return yield* Effect.fail(
579 new Error(
580 "Static site indexPage must be a relative file path without parent segments.",
581 ),
582 );
583 }
584
585 if (options.command !== undefined) {
586 yield* runBuildCommand({
587 command: options.command,
588 cwd,
589 env: processBuildEnv(options.env),
590 outputLimitBytes: options.outputLimitBytes,
591 timeoutSeconds: options.timeoutSeconds,
592 });
593 }
594
595 const sourceDir = path.resolve(cwd, outdir);
596 if (!(yield* directoryExists(sourceDir))) {
597 return yield* Effect.fail(
598 new Error(
599 `Static site build did not produce an output directory at ${sourceDir}.`,
600 ),
601 );
602 }
603 const indexStat = yield* fs
604 .stat(path.join(sourceDir, indexPage))
605 .pipe(Effect.catch(() => Effect.succeed(undefined)));
606 if (indexStat?.type !== "File") {
607 return yield* Effect.fail(
608 new Error(
609 `Static site build did not produce ${indexPage} inside ${sourceDir}.`,
610 ),
611 );
612 }
613
614 const temp = yield* makeTempArtifactDir();
615 const budget = makeStagingBudget();
616 const build = Effect.gen(function* () {
617 return yield* withStagingDeadline(
618 Effect.gen(function* () {
619 const publicDir = path.join(temp.artifactDir, "public");
620 yield* copyDirectoryPreserveSymlinks(
621 sourceDir,
622 publicDir,
623 temp.artifactDir,
624 cwd,
625 budget,
626 );
627 const entrypoint = "server.mjs";
628 const serverPath = path.join(temp.artifactDir, entrypoint);
629 const source = staticSiteServerSource({
630 indexPage,
631 spa: options.spa ?? false,
632 });
633 yield* accountStagingEntry(
634 budget,
635 serverPath,
636 yield* Effect.sync(() => new TextEncoder().encode(source).byteLength),
637 );
638 yield* fs.writeFileString(serverPath, source);
639 return {
640 directory: temp.artifactDir,
641 entrypoint,
642 defaultPort: 8080,
643 archiveIgnorePrefix: "public",
644 requiredFiles: [`public/${indexPage}`],
645 cleanup: temp.cleanup,
646 };
647 }),
648 budget,
649 );
650 });
651
652 return yield* build.pipe(
653 Effect.catch((error) =>
654 temp.cleanup.pipe(Effect.andThen(Effect.fail(error))),
655 ),
656 );
657});
658
659const staticSiteServerSource = (options: { indexPage: string; spa: boolean }) =>
660 [
661 'const publicRoot = new URL("./public/", import.meta.url);',
662 `const indexPage = ${JSON.stringify(options.indexPage)};`,
663 `const spa = ${JSON.stringify(options.spa)};`,
664 "",
665 'const encodePath = (value) => value.split("/").map(encodeURIComponent).join("/");',
666 "",
667 "const responseForFile = async (fileUrl, method) => {",
668 " const file = Bun.file(fileUrl);",
669 " if (!(await file.exists())) return undefined;",
670 ' if (method !== "HEAD") return new Response(file);',
671 " return new Response(null, {",
672 " headers: {",
673 ' "content-length": String(file.size),',
674 ' "content-type": file.type || "application/octet-stream",',
675 " },",
676 " });",
677 "};",
678 "",
679 "const resolvePublicPath = (pathname) => {",
680 " let decoded;",
681 " try {",
682 " decoded = decodeURIComponent(pathname);",
683 " } catch {",
684 " return undefined;",
685 " }",
686 ' if (decoded.includes("\\0")) return undefined;',
687 " const target = new URL(`.${encodePath(decoded)}`, publicRoot);",
688 " return target.href.startsWith(publicRoot.href) ? target : undefined;",
689 "};",
690 "",
691 "const server = Bun.serve({",
692 ' hostname: "0.0.0.0",',
693 ' port: Number(process.env.PORT ?? "8080"),',
694 " async fetch(request) {",
695 " const method = request.method;",
696 ' if (method !== "GET" && method !== "HEAD") {',
697 ' return new Response("Method Not Allowed", {',
698 " status: 405,",
699 ' headers: { allow: "GET, HEAD" },',
700 " });",
701 " }",
702 "",
703 " const url = new URL(request.url);",
704 " const pathname = url.pathname;",
705 " const target = resolvePublicPath(pathname);",
706 ' if (target === undefined) return new Response("Bad Request", { status: 400 });',
707 "",
708 " const exact = await responseForFile(target, method);",
709 " if (exact) return exact;",
710 ' const directoryUrl = target.href.endsWith("/") ? target : new URL(`${target.href}/`);',
711 ' const directoryIndexPage = pathname === "/" ? indexPage : "index.html";',
712 " const directoryIndex = await responseForFile(new URL(encodePath(directoryIndexPage), directoryUrl), method);",
713 " if (directoryIndex) {",
714 ' if (!pathname.endsWith("/")) {',
715 ' const location = `${pathname.replace(/^\\/+/, "/")}/${url.search}`;',
716 " return new Response(null, { status: 301, headers: { location } });",
717 " }",
718 " return directoryIndex;",
719 " }",
720 "",
721 " if (spa) {",
722 " const fallback = await responseForFile(new URL(encodePath(indexPage), publicRoot), method);",
723 " if (fallback) return fallback;",
724 " }",
725 ' return new Response("Not Found", { status: 404 });',
726 " },",
727 "});",
728 "",
729 "console.log(`Listening on ${server.url}`);",
730 "",
731 ].join("\n");
732
733const buildFramework = Effect.fn(function* (options: {
734 appPath: string;
735 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
736 outputLimitBytes?: number;
737 timeoutSeconds?: number;
738 cliName: string;
739 args: string[];
740 failurePrefix: string;
741 missingMessage: string;
742 sourceDir: string;
743 entrypoint: string;
744 defaultPort: number;
745 allowNestedEntrypoint?: boolean;
746 requiredFile?: string;
747 missingOutputMessage: string;
748 extrasRelativeToEntrypoint?: boolean;
749 extras?: ReadonlyArray<{ from: string; to: string }>;
750}) {
751 const fs = yield* FileSystem.FileSystem;
752 const path = yield* Path.Path;
753 yield* runBuildCommand({
754 command: yield* packageCliCommand(
755 options.appPath,
756 options.cliName,
757 options.args,
758 options.missingMessage,
759 ),
760 cwd: options.appPath,
761 env: processBuildEnv(options.env),
762 outputLimitBytes: options.outputLimitBytes,
763 timeoutSeconds: options.timeoutSeconds,
764 });
765
766 const requiredPath = path.join(
767 options.appPath,
768 options.requiredFile ?? options.sourceDir,
769 );
770 if (!(yield* fs.exists(requiredPath))) {
771 return yield* Effect.fail(new Error(options.missingOutputMessage));
772 }
773
774 const temp = yield* makeTempArtifactDir();
775 const budget = makeStagingBudget();
776 const build = Effect.gen(function* () {
777 return yield* withStagingDeadline(
778 Effect.gen(function* () {
779 yield* copyDirectoryPreserveSymlinks(
780 path.join(options.appPath, options.sourceDir),
781 temp.artifactDir,
782 temp.artifactDir,
783 options.appPath,
784 budget,
785 );
786 yield* materializeBunNodeModuleAliases(
787 temp.artifactDir,
788 path.join(options.appPath, options.sourceDir),
789 budget,
790 );
791 const entrypoint = yield* resolveFrameworkEntrypoint(
792 temp.artifactDir,
793 options.entrypoint,
794 options.allowNestedEntrypoint ?? false,
795 );
796 const extrasBaseDir = options.extrasRelativeToEntrypoint
797 ? path.dirname(path.join(temp.artifactDir, entrypoint))
798 : temp.artifactDir;
799 for (const extra of options.extras ?? []) {
800 const extraSource = path.join(options.appPath, extra.from);
801 if (yield* directoryExists(extraSource)) {
802 const extraTarget = path.join(extrasBaseDir, extra.to);
803 yield* copyDirectoryPreserveSymlinks(
804 extraSource,
805 extraTarget,
806 temp.artifactDir,
807 options.appPath,
808 budget,
809 );
810 }
811 }
812 return {
813 directory: temp.artifactDir,
814 entrypoint,
815 defaultPort: options.defaultPort,
816 cleanup: temp.cleanup,
817 };
818 }),
819 budget,
820 );
821 });
822
823 return yield* build.pipe(
824 Effect.catch((error) =>
825 temp.cleanup.pipe(Effect.andThen(Effect.fail(error))),
826 ),
827 );
828});
829
830const buildNestjs = Effect.fn(function* (options: ComputeAutoBuildOptions) {
831 const fs = yield* FileSystem.FileSystem;
832 const path = yield* Path.Path;
833 const buildScript = yield* readPackageScript(options.appPath, "build");
834
835 yield* runBuildCommand({
836 command:
837 buildScript === undefined
838 ? yield* packageCliCommand(
839 options.appPath,
840 "nest",
841 ["build"],
842 "Could not find an installed Nest CLI. Add a `build` script to package.json or install `@nestjs/cli` in the application or workspace before deploying.",
843 )
844 : packageScriptCommand("build"),
845 cwd: options.appPath,
846 env: processBuildEnv(options.env),
847 outputLimitBytes: options.outputLimitBytes,
848 timeoutSeconds: options.timeoutSeconds,
849 });
850
851 const compiledEntry = yield* resolveNestjsCompiledEntrypoint(options.appPath);
852 const temp = yield* makeTempArtifactDir();
853 const budget = makeStagingBudget();
854 const build = Effect.gen(function* () {
855 const entrypoint = yield* withStagingDeadline(
856 stageTracedNestjsArtifact({
857 appPath: options.appPath,
858 artifactDir: temp.artifactDir,
859 compiledEntry,
860 budget,
861 }),
862 budget,
863 );
864 return {
865 directory: temp.artifactDir,
866 entrypoint,
867 defaultPort: 3000,
868 cleanup: temp.cleanup,
869 };
870 });
871
872 return yield* build.pipe(
873 Effect.catch((error) =>
874 fs
875 .remove(path.dirname(temp.artifactDir), { recursive: true })
876 .pipe(Effect.ignore, Effect.andThen(Effect.fail(error))),
877 ),
878 );
879});
880
881const resolveNestjsCompiledEntrypoint = Effect.fn(function* (appPath: string) {
882 const fs = yield* FileSystem.FileSystem;
883 const path = yield* Path.Path;
884 const candidates = [
885 yield* readPackageMain(appPath),
886 yield* configuredNestjsCompiledEntrypoint(appPath),
887 ...NEST_DEFAULT_COMPILED_ENTRYPOINTS,
888 ].flatMap((candidate) => {
889 if (candidate === undefined) return [];
890 const normalized = normalizeRelativePath(candidate);
891 return normalized === undefined ? [] : [normalized];
892 });
893
894 for (const candidate of candidates) {
895 const stat = yield* fs
896 .stat(path.join(appPath, candidate))
897 .pipe(Effect.catch(() => Effect.succeed(undefined)));
898 if (stat?.type === "File") return candidate;
899 }
900
901 return yield* Effect.fail(
902 new Error(
903 `NestJS build did not produce a compiled entrypoint. Looked for ${candidates.join(", ")} under ${appPath}. Ensure \`nest build\` ran and check package.json main, nest-cli.json, or tsconfig outDir.`,
904 ),
905 );
906});
907
908const configuredNestjsCompiledEntrypoint = Effect.fn(function* (
909 appPath: string,
910) {
911 const path = yield* Path.Path;
912 const config = yield* readNestCliConfig(appPath);
913 const sourceRoot = normalizeRelativePath(config.sourceRoot ?? "src") ?? "src";
914 const entryFile = config.entryFile ?? "main";
915 const outDir = yield* readNestjsOutDir(appPath);
916 return sourceRoot === "."
917 ? path.join(outDir, `${entryFile}.js`)
918 : path.join(outDir, sourceRoot, `${entryFile}.js`);
919});
920
921const stageTracedNestjsArtifact = Effect.fn(function* (options: {
922 appPath: string;
923 artifactDir: string;
924 compiledEntry: string;
925 budget: StagingBudget;
926}) {
927 const fs = yield* FileSystem.FileSystem;
928 const path = yield* Path.Path;
929 const sourceRoot = yield* resolveSourceRoot(options.appPath);
930 const realSourceRoot = yield* fs.realPath(sourceRoot);
931 const artifactRoot = path.resolve(options.artifactDir);
932 const entry = path.join(options.appPath, options.compiledEntry);
933 const entrypoint = path.relative(sourceRoot, entry).replaceAll("\\", "/");
934 const fileList = yield* Effect.tryPromise({
935 try: () =>
936 importNft()
937 .then(({ nodeFileTrace }) =>
938 nodeFileTrace([entry], { base: sourceRoot }),
939 )
940 .then((result) => Array.from(result.fileList)),
941 catch: (cause) =>
942 cause instanceof Error
943 ? cause
944 : new Error(`Failed to trace NestJS artifact: ${String(cause)}`),
945 });
946
947 const sortedFiles = fileList.sort((a, b) => a.localeCompare(b));
948 if (sortedFiles.length > STAGING_MAX_ENTRIES) {
949 return yield* Effect.fail(
950 new Error(
951 `Compute artifact staging exceeded the ${STAGING_MAX_ENTRIES} entry safety limit while tracing NestJS dependencies.`,
952 ),
953 );
954 }
955 for (const file of sortedFiles) {
956 const normalized = normalizeRelativePath(file);
957 if (normalized === undefined || normalized === ".") {
958 return yield* Effect.fail(
959 new Error(`NestJS dependency trace returned an unsafe path: ${file}`),
960 );
961 }
962 yield* stageTracedPath(
963 path.join(sourceRoot, normalized),
964 path.join(options.artifactDir, normalized),
965 [realSourceRoot],
966 artifactRoot,
967 options.budget,
968 );
969 }
970
971 return entrypoint;
972});
973
974const copyDirectoryPreserveSymlinks = Effect.fn(function* (
975 sourceDir: string,
976 targetDir: string,
977 targetRoot: string,
978 sourceBoundary: string,
979 budget: StagingBudget,
980) {
981 const fs = yield* FileSystem.FileSystem;
982 const path = yield* Path.Path;
983 const realSourceRoot = yield* fs.realPath(sourceDir);
984 const realSourceBoundary = yield* fs.realPath(sourceBoundary);
985 yield* assertPathWithinRoots(realSourceRoot, [realSourceBoundary], sourceDir);
986 yield* copyDirectoryWithinRoots(
987 sourceDir,
988 targetDir,
989 [realSourceRoot],
990 path.resolve(targetRoot),
991 budget,
992 );
993});
994
995const copyDirectoryWithinRoots: (
996 sourceDir: string,
997 targetDir: string,
998 allowedRoots: readonly string[],
999 targetRoot: string,
1000 budget: StagingBudget,
1001) => Effect.Effect<void, unknown, FileSystem.FileSystem | Path.Path> =
1002 Effect.fn(function* (sourceDir, targetDir, allowedRoots, targetRoot, budget) {
1003 const fs = yield* FileSystem.FileSystem;
1004 const path = yield* Path.Path;
1005 yield* assertRealPathWithinRoots(sourceDir, allowedRoots);
1006 yield* assertSafeStagingDestination(targetDir, targetRoot);
1007 yield* fs.makeDirectory(targetDir, { recursive: true });
1008
1009 const entries = (yield* readDirectoryEntries(sourceDir)).sort((a, b) =>
1010 a.name.localeCompare(b.name),
1011 );
1012 for (const entry of entries) {
1013 const source = path.join(sourceDir, entry.name);
1014 const target = path.join(targetDir, entry.name);
1015 if (entry.type === "SymbolicLink") {
1016 const symlinkTarget = yield* validateStagedSymlink(
1017 source,
1018 allowedRoots,
1019 );
1020 yield* accountStagingEntry(budget, source, 0);
1021 yield* assertSafeStagingDestination(target, targetRoot);
1022 yield* assertPathWithinRoots(
1023 path.resolve(path.dirname(target), symlinkTarget),
1024 [targetRoot],
1025 target,
1026 );
1027 yield* fs.remove(target, { recursive: true, force: true });
1028 yield* fs.symlink(symlinkTarget, target);
1029 continue;
1030 }
1031
1032 if (entry.type === "Directory") {
1033 yield* assertRealPathWithinRoots(source, allowedRoots);
1034 yield* accountStagingEntry(budget, source, 0);
1035 yield* copyDirectoryWithinRoots(
1036 source,
1037 target,
1038 allowedRoots,
1039 targetRoot,
1040 budget,
1041 );
1042 continue;
1043 }
1044
1045 if (entry.type === "File") {
1046 yield* assertRealPathWithinRoots(source, allowedRoots);
1047 const stat = yield* fs.stat(source);
1048 const size = yield* safeFileSize(source, stat.size);
1049 yield* accountStagingEntry(budget, source, size);
1050 yield* assertSafeStagingDestination(target, targetRoot);
1051 yield* fs.makeDirectory(path.dirname(target), { recursive: true });
1052 yield* fs.copyFile(source, target);
1053 const copied = yield* fs.stat(target);
1054 if (copied.type !== "File" || Number(copied.size) !== size) {
1055 yield* fs.remove(target, { force: true }).pipe(Effect.ignore);
1056 return yield* Effect.fail(
1057 new Error(`Compute artifact file changed while staging: ${source}`),
1058 );
1059 }
1060 yield* fs.chmod(target, stat.mode & 0o777);
1061 continue;
1062 }
1063
1064 yield* accountStagingEntry(budget, source, 0);
1065 return yield* Effect.fail(
1066 new Error(
1067 `Unsupported filesystem entry in compute artifact: ${source}`,
1068 ),
1069 );
1070 }
1071 });
1072
1073const readDirectoryEntries = Effect.fn(function* (directory: string) {
1074 const fs = yield* FileSystem.FileSystem;
1075 const path = yield* Path.Path;
1076 const names = yield* fs.readDirectory(directory);
1077 if (names.length > STAGING_MAX_ENTRIES) {
1078 return yield* Effect.fail(
1079 new Error(
1080 `Compute artifact directory exceeds the ${STAGING_MAX_ENTRIES} entry safety limit: ${directory}`,
1081 ),
1082 );
1083 }
1084 return yield* Effect.forEach(names, (name) =>
1085 Effect.gen(function* () {
1086 const file = path.join(directory, name);
1087 const isSymlink = yield* fs.readLink(file).pipe(
1088 Effect.as(true),
1089 Effect.catch(() => Effect.succeed(false)),
1090 );
1091 if (isSymlink) {
1092 return { name, type: "SymbolicLink" } satisfies DirectoryEntry;
1093 }
1094 const stat = yield* fs.stat(file);
1095 return {
1096 name,
1097 type:
1098 stat.type === "Directory" || stat.type === "File"
1099 ? stat.type
1100 : "Other",
1101 } satisfies DirectoryEntry;
1102 }),
1103 );
1104});
1105
1106const materializeBunNodeModuleAliases = Effect.fn(function* (
1107 artifactDir: string,
1108 sourceDir: string,
1109 budget: StagingBudget,
1110) {
1111 const fs = yield* FileSystem.FileSystem;
1112 const path = yield* Path.Path;
1113 const nodeModules = path.join(artifactDir, "node_modules");
1114 const aliasRoot = path.join(nodeModules, ".bun", "node_modules");
1115 if (!(yield* directoryExists(aliasRoot))) return;
1116 const allowedRoots = [
1117 yield* fs.realPath(artifactDir),
1118 yield* fs.realPath(sourceDir),
1119 ];
1120 const targetRoot = path.resolve(artifactDir);
1121
1122 for (const entry of yield* fs.readDirectory(aliasRoot)) {
1123 const source = path.join(aliasRoot, entry);
1124 const stat = yield* fs
1125 .stat(source)
1126 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1127 if (stat?.type !== "Directory") continue;
1128
1129 if (entry.startsWith("@")) {
1130 const targetScope = path.join(nodeModules, entry);
1131 yield* assertSafeStagingDestination(targetScope, targetRoot);
1132 yield* fs.makeDirectory(targetScope, { recursive: true });
1133 for (const packageName of yield* fs.readDirectory(source)) {
1134 yield* copyAliasIfMissing(
1135 allowedRoots,
1136 path.join(source, packageName),
1137 path.join(targetScope, packageName),
1138 targetRoot,
1139 budget,
1140 );
1141 }
1142 } else {
1143 yield* copyAliasIfMissing(
1144 allowedRoots,
1145 source,
1146 path.join(nodeModules, entry),
1147 targetRoot,
1148 budget,
1149 );
1150 }
1151 }
1152});
1153
1154const copyAliasIfMissing = Effect.fn(function* (
1155 allowedRoots: readonly string[],
1156 source: string,
1157 target: string,
1158 targetRoot: string,
1159 budget: StagingBudget,
1160) {
1161 const fs = yield* FileSystem.FileSystem;
1162 if (yield* fs.exists(target)) return;
1163 const realSource = yield* fs.realPath(source);
1164 yield* assertPathWithinRoots(realSource, allowedRoots, source);
1165 const stat = yield* fs.stat(realSource);
1166 yield* assertSafeStagingDestination(target, targetRoot);
1167 if (stat.type === "Directory") {
1168 yield* accountStagingEntry(budget, source, 0);
1169 yield* copyDirectoryWithinRoots(
1170 realSource,
1171 target,
1172 allowedRoots,
1173 targetRoot,
1174 budget,
1175 );
1176 return;
1177 }
1178 if (stat.type === "File") {
1179 const size = yield* safeFileSize(source, stat.size);
1180 yield* accountStagingEntry(budget, source, size);
1181 yield* fs.copyFile(realSource, target);
1182 const copied = yield* fs.stat(target);
1183 if (copied.type !== "File" || Number(copied.size) !== size) {
1184 yield* fs.remove(target, { force: true }).pipe(Effect.ignore);
1185 return yield* Effect.fail(
1186 new Error(`Compute artifact file changed while staging: ${source}`),
1187 );
1188 }
1189 yield* fs.chmod(target, stat.mode & 0o777);
1190 return;
1191 }
1192 return yield* Effect.fail(
1193 new Error(`Unsupported Bun package alias source: ${source}`),
1194 );
1195});
1196
1197const resolveFrameworkEntrypoint = Effect.fn(function* (
1198 artifactDir: string,
1199 entrypoint: string,
1200 allowNested: boolean,
1201) {
1202 const fs = yield* FileSystem.FileSystem;
1203 const path = yield* Path.Path;
1204 if (!allowNested) {
1205 return entrypoint;
1206 }
1207
1208 const entrypointFile = path.basename(entrypoint);
1209 const candidates = (yield* fs.readDirectory(artifactDir, { recursive: true }))
1210 .map((file) => file.replaceAll("\\", "/"))
1211 .filter((file) => {
1212 const parts = file.split("/");
1213 return (
1214 path.basename(file) === entrypointFile &&
1215 !parts.includes("node_modules") &&
1216 !parts.includes(".next")
1217 );
1218 })
1219 .sort((a, b) => {
1220 const depth = a.split(/[\\/]/).length - b.split(/[\\/]/).length;
1221 return depth === 0 ? a.localeCompare(b) : depth;
1222 });
1223
1224 if (candidates.length === 1) return candidates[0]!;
1225 if (candidates.length > 1) {
1226 return yield* Effect.fail(
1227 new Error(
1228 `Next.js standalone output has multiple ${entrypointFile} files (${candidates.join(", ")}). Cannot determine the application entrypoint.`,
1229 ),
1230 );
1231 }
1232
1233 return yield* Effect.fail(
1234 new Error(
1235 `Could not find framework entrypoint ${entrypoint} inside ${artifactDir}.`,
1236 ),
1237 );
1238});
1239
1240function buildBun(options: ComputeAutoBuildOptions) {
1241 return Effect.gen(function* () {
1242 const fs = yield* FileSystem.FileSystem;
1243 const path = yield* Path.Path;
1244 const entrypoint = yield* resolveBunEntrypoint(
1245 options.appPath,
1246 options.entrypoint,
1247 );
1248 const absoluteEntrypoint = path.join(options.appPath, entrypoint);
1249 const temp = yield* makeTempArtifactDir("bundle");
1250
1251 const build = Effect.gen(function* () {
1252 yield* runBuildCommand({
1253 command: [
1254 "bun",
1255 "build",
1256 shellQuote(absoluteEntrypoint),
1257 "--outdir",
1258 shellQuote(temp.artifactDir),
1259 "--target",
1260 "bun",
1261 "--sourcemap=external",
1262 ].join(" "),
1263 cwd: options.appPath,
1264 env: processBuildEnv(options.env),
1265 outputLimitBytes: options.outputLimitBytes,
1266 timeoutSeconds: options.timeoutSeconds,
1267 });
1268
1269 const budget = makeStagingBudget();
1270 yield* withStagingDeadline(
1271 validateStagedDirectory(temp.artifactDir, budget),
1272 budget,
1273 );
1274
1275 const outputFiles = (yield* fs.readDirectory(temp.artifactDir))
1276 .filter((file) => file.endsWith(".js"))
1277 .sort();
1278 if (outputFiles.length === 0) {
1279 return yield* Effect.fail(
1280 new Error("Bun build produced no JavaScript output."),
1281 );
1282 }
1283
1284 const expected = `${path.basename(
1285 absoluteEntrypoint,
1286 path.extname(absoluteEntrypoint),
1287 )}.js`;
1288 return {
1289 directory: temp.artifactDir,
1290 entrypoint: outputFiles.includes(expected) ? expected : outputFiles[0]!,
1291 cleanup: temp.cleanup,
1292 };
1293 });
1294
1295 return yield* build.pipe(
1296 Effect.catch((error) =>
1297 temp.cleanup.pipe(Effect.andThen(Effect.fail(error))),
1298 ),
1299 );
1300 });
1301}
1302
1303const resolveBunEntrypoint = Effect.fn(function* (
1304 appPath: string,
1305 entrypoint: string | undefined,
1306) {
1307 const fs = yield* FileSystem.FileSystem;
1308 const path = yield* Path.Path;
1309 const candidate = entrypoint ?? (yield* readPackageMain(appPath));
1310 if (!candidate) {
1311 return yield* Effect.fail(
1312 new Error(
1313 "Prisma Compute auto-build needs an entrypoint for Bun apps. Set `entrypoint` or package.json `main`.",
1314 ),
1315 );
1316 }
1317 const normalized = yield* normalizeEntrypoint(candidate);
1318 const entrypointPath = path.join(appPath, normalized);
1319 if (!(yield* fs.exists(entrypointPath))) {
1320 return yield* Effect.fail(
1321 new Error(`Entrypoint file does not exist: ${entrypointPath}`),
1322 );
1323 }
1324 const realAppPath = yield* fs.realPath(appPath);
1325 const realEntrypointPath = yield* fs.realPath(entrypointPath);
1326 yield* assertPathWithinRoots(
1327 realEntrypointPath,
1328 [realAppPath],
1329 entrypointPath,
1330 );
1331 return normalized;
1332});
1333
1334const makeTempArtifactDir = Effect.fn(function* (leaf = "app") {
1335 const fs = yield* FileSystem.FileSystem;
1336 const path = yield* Path.Path;
1337 const tempDir = yield* fs.makeTempDirectory({
1338 prefix: "alchemy-prisma-compute-build-",
1339 });
1340 const artifactDir = path.join(tempDir, leaf);
1341 yield* fs.makeDirectory(artifactDir, { recursive: true });
1342 return {
1343 artifactDir,
1344 cleanup: fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore),
1345 };
1346});
1347
1348const hasRootFile = Effect.fn(function* (
1349 appPath: string,
1350 filenames: readonly string[],
1351) {
1352 const fs = yield* FileSystem.FileSystem;
1353 const entries = yield* fs
1354 .readDirectory(appPath)
1355 .pipe(Effect.catch(() => Effect.succeed([] as string[])));
1356 return entries.some((entry) => filenames.includes(entry));
1357});
1358
1359const hasPackageDependency = Effect.fn(function* (
1360 appPath: string,
1361 packageNames: readonly string[],
1362) {
1363 const parsed = yield* readPackageJson(appPath);
1364 if (!parsed) return false;
1365 const deps = isRecord(parsed.dependencies) ? parsed.dependencies : {};
1366 const devDeps = isRecord(parsed.devDependencies)
1367 ? parsed.devDependencies
1368 : {};
1369 return packageNames.some((name) => name in deps || name in devDeps);
1370});
1371
1372const readPackageMain = Effect.fn(function* (appPath: string) {
1373 const parsed = yield* readPackageJson(appPath);
1374 return typeof parsed?.main === "string" ? parsed.main : undefined;
1375});
1376
1377const readPackageScript = Effect.fn(function* (
1378 appPath: string,
1379 scriptName: string,
1380) {
1381 const parsed = yield* readPackageJson(appPath);
1382 const scripts = isRecord(parsed?.scripts) ? parsed.scripts : undefined;
1383 const script = scripts?.[scriptName];
1384 return typeof script === "string" && script.trim() !== ""
1385 ? script
1386 : undefined;
1387});
1388
1389const readPackageJson = Effect.fn(function* (appPath: string) {
1390 const fs = yield* FileSystem.FileSystem;
1391 const path = yield* Path.Path;
1392 const packagePath = path.join(appPath, "package.json");
1393 const text = yield* fs
1394 .readFileString(packagePath)
1395 .pipe(
1396 Effect.catch((error) =>
1397 error._tag === "PlatformError" && error.reason._tag === "NotFound"
1398 ? Effect.succeed(undefined)
1399 : Effect.fail(error),
1400 ),
1401 );
1402 if (!text) return undefined;
1403 return yield* Effect.try({
1404 try: () => JSON.parse(text) as Record<string, unknown>,
1405 catch: (cause) =>
1406 new Error(`Failed to parse package.json in ${appPath}: ${cause}`),
1407 });
1408});
1409
1410const readNestCliConfig = Effect.fn(function* (appPath: string) {
1411 const parsed = yield* readJsonObjectFile(appPath, NEST_CLI_FILENAME);
1412 return {
1413 sourceRoot:
1414 typeof parsed?.sourceRoot === "string" ? parsed.sourceRoot : undefined,
1415 entryFile:
1416 typeof parsed?.entryFile === "string" ? parsed.entryFile : undefined,
1417 };
1418});
1419
1420const readNestjsOutDir = Effect.fn(function* (appPath: string) {
1421 const fs = yield* FileSystem.FileSystem;
1422 const path = yield* Path.Path;
1423 for (const fileName of NEST_TSCONFIG_FILENAMES) {
1424 const filePath = path.join(appPath, fileName);
1425 const text = yield* fs
1426 .readFileString(filePath)
1427 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1428 if (text === undefined) continue;
1429 const outDir = yield* parseTsconfigOutDir(text);
1430 if (outDir !== undefined) return outDir;
1431 }
1432 return "dist";
1433});
1434
1435const parseTsconfigOutDir = Effect.fn(function* (content: string) {
1436 const parsed = yield* Effect.try({
1437 try: () =>
1438 JSON.parse(stripJsonComments(content)) as {
1439 compilerOptions?: { outDir?: unknown };
1440 },
1441 catch: (cause) =>
1442 cause instanceof Error ? cause : new Error(String(cause)),
1443 }).pipe(Effect.catch(() => Effect.succeed(undefined)));
1444 const outDir = parsed?.compilerOptions?.outDir;
1445 return typeof outDir === "string" ? normalizeRelativePath(outDir) : undefined;
1446});
1447
1448const readJsonObjectFile = Effect.fn(function* (
1449 directory: string,
1450 fileName: string,
1451) {
1452 const fs = yield* FileSystem.FileSystem;
1453 const path = yield* Path.Path;
1454 const text = yield* fs
1455 .readFileString(path.join(directory, fileName))
1456 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1457 if (text === undefined) return undefined;
1458 return yield* Effect.try({
1459 try: () => {
1460 const parsed = JSON.parse(text) as unknown;
1461 return isRecord(parsed) ? parsed : undefined;
1462 },
1463 catch: (cause) =>
1464 cause instanceof Error ? cause : new Error(String(cause)),
1465 }).pipe(Effect.catch(() => Effect.succeed(undefined)));
1466});
1467
1468const directoryExists = Effect.fn(function* (dirPath: string) {
1469 const fs = yield* FileSystem.FileSystem;
1470 const stat = yield* fs
1471 .stat(dirPath)
1472 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1473 return stat?.type === "Directory";
1474});
1475
1476const stageTracedPath = Effect.fn(function* (
1477 sourcePath: string,
1478 destinationPath: string,
1479 allowedRoots: readonly string[],
1480 targetRoot: string,
1481 budget: StagingBudget,
1482) {
1483 const fs = yield* FileSystem.FileSystem;
1484 const path = yield* Path.Path;
1485 const symlinkTarget = yield* fs
1486 .readLink(sourcePath)
1487 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1488 const stat = yield* fs
1489 .stat(sourcePath)
1490 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1491 if (stat === undefined && symlinkTarget === undefined) {
1492 return yield* Effect.fail(
1493 new Error(`NestJS dependency disappeared while staging: ${sourcePath}`),
1494 );
1495 }
1496
1497 yield* assertSafeStagingDestination(destinationPath, targetRoot);
1498 yield* fs.makeDirectory(path.dirname(destinationPath), { recursive: true });
1499 if (symlinkTarget !== undefined) {
1500 const validatedTarget = yield* validateStagedSymlink(
1501 sourcePath,
1502 allowedRoots,
1503 );
1504 yield* accountStagingEntry(budget, sourcePath, 0);
1505 yield* assertPathWithinRoots(
1506 path.resolve(path.dirname(destinationPath), validatedTarget),
1507 [targetRoot],
1508 destinationPath,
1509 );
1510 yield* fs.remove(destinationPath, { recursive: true, force: true });
1511 yield* fs.symlink(validatedTarget, destinationPath);
1512 return;
1513 }
1514
1515 if (stat?.type === "File") {
1516 yield* assertRealPathWithinRoots(sourcePath, allowedRoots);
1517 const size = yield* safeFileSize(sourcePath, stat.size);
1518 yield* accountStagingEntry(budget, sourcePath, size);
1519 yield* fs.copyFile(sourcePath, destinationPath);
1520 const copied = yield* fs.stat(destinationPath);
1521 if (copied.type !== "File" || Number(copied.size) !== size) {
1522 yield* fs.remove(destinationPath, { force: true }).pipe(Effect.ignore);
1523 return yield* Effect.fail(
1524 new Error(`Compute artifact file changed while staging: ${sourcePath}`),
1525 );
1526 }
1527 yield* fs.chmod(destinationPath, stat.mode & 0o777);
1528 return;
1529 }
1530
1531 yield* accountStagingEntry(budget, sourcePath, 0);
1532 return yield* Effect.fail(
1533 new Error(
1534 `Unsupported filesystem entry in NestJS dependency trace: ${sourcePath}`,
1535 ),
1536 );
1537});
1538
1539const makeStagingBudget = (): StagingBudget => ({
1540 entries: 0,
1541 totalBytes: 0,
1542 deadline: Date.now() + STAGING_TIMEOUT_SECONDS * 1000,
1543});
1544
1545const withStagingDeadline = <A, E, R>(
1546 effect: Effect.Effect<A, E, R>,
1547 budget: StagingBudget,
1548): Effect.Effect<A, E | Error, R> =>
1549 effect.pipe(
1550 Effect.timeoutOption(
1551 Duration.millis(Math.max(1, budget.deadline - Date.now())),
1552 ),
1553 Effect.flatMap((result) =>
1554 Option.isSome(result)
1555 ? Effect.succeed(result.value)
1556 : Effect.fail(
1557 new Error(
1558 `Compute artifact staging timed out after ${STAGING_TIMEOUT_SECONDS} seconds.`,
1559 ),
1560 ),
1561 ),
1562 );
1563
1564const accountStagingEntry = (
1565 budget: StagingBudget,
1566 source: string,
1567 size: number,
1568) => {
1569 if (Date.now() > budget.deadline) {
1570 return Effect.fail(
1571 new Error(
1572 `Compute artifact staging timed out after ${STAGING_TIMEOUT_SECONDS} seconds.`,
1573 ),
1574 );
1575 }
1576 if (size > STAGING_MAX_FILE_BYTES) {
1577 return Effect.fail(
1578 new Error(
1579 `Compute artifact file exceeds the ${STAGING_MAX_FILE_BYTES} byte per-file safety limit: ${source}`,
1580 ),
1581 );
1582 }
1583 const entries = budget.entries + 1;
1584 if (entries > STAGING_MAX_ENTRIES) {
1585 return Effect.fail(
1586 new Error(
1587 `Compute artifact staging exceeded the ${STAGING_MAX_ENTRIES} entry safety limit.`,
1588 ),
1589 );
1590 }
1591 const totalBytes = budget.totalBytes + size;
1592 if (
1593 !Number.isSafeInteger(totalBytes) ||
1594 totalBytes > STAGING_MAX_TOTAL_BYTES
1595 ) {
1596 return Effect.fail(
1597 new Error(
1598 `Compute artifact staging exceeded the ${STAGING_MAX_TOTAL_BYTES} byte total safety limit.`,
1599 ),
1600 );
1601 }
1602 budget.entries = entries;
1603 budget.totalBytes = totalBytes;
1604 return Effect.void;
1605};
1606
1607const safeFileSize = (source: string, rawSize: ByteSize.ByteSize) => {
1608 const size = ByteSize.toNumberUnsafe(rawSize);
1609 return Number.isSafeInteger(size) && size >= 0
1610 ? Effect.succeed(size)
1611 : Effect.fail(
1612 new Error(`Compute artifact has an invalid file size: ${source}`),
1613 );
1614};
1615
1616const assertRealPathWithinRoots = Effect.fn(function* (
1617 source: string,
1618 allowedRoots: readonly string[],
1619) {
1620 const fs = yield* FileSystem.FileSystem;
1621 const realSource = yield* fs.realPath(source);
1622 yield* assertPathWithinRoots(realSource, allowedRoots, source);
1623 return realSource;
1624});
1625
1626const assertSafeStagingDestination = Effect.fn(function* (
1627 destination: string,
1628 targetRoot: string,
1629) {
1630 const fs = yield* FileSystem.FileSystem;
1631 const path = yield* Path.Path;
1632 const absoluteDestination = path.resolve(destination);
1633 const absoluteTargetRoot = path.resolve(targetRoot);
1634 yield* assertPathWithinRoots(
1635 absoluteDestination,
1636 [absoluteTargetRoot],
1637 destination,
1638 );
1639
1640 let existingAncestor = absoluteDestination;
1641 while (!(yield* fs.exists(existingAncestor))) {
1642 const isDanglingSymlink = yield* fs.readLink(existingAncestor).pipe(
1643 Effect.as(true),
1644 Effect.catch(() => Effect.succeed(false)),
1645 );
1646 if (isDanglingSymlink) {
1647 return yield* Effect.fail(
1648 new Error(
1649 `Compute artifact destination contains a dangling symlink: ${existingAncestor}`,
1650 ),
1651 );
1652 }
1653 const parent = path.dirname(existingAncestor);
1654 if (parent === existingAncestor) {
1655 return yield* Effect.fail(
1656 new Error(
1657 `Could not establish a safe compute artifact destination: ${destination}`,
1658 ),
1659 );
1660 }
1661 existingAncestor = parent;
1662 }
1663
1664 const realAncestor = yield* fs.realPath(existingAncestor);
1665 const realTargetRoot = yield* fs.realPath(absoluteTargetRoot);
1666 yield* assertPathWithinRoots(realAncestor, [realTargetRoot], destination);
1667});
1668
1669const assertPathWithinRoots = Effect.fn(function* (
1670 realSource: string,
1671 allowedRoots: readonly string[],
1672 displaySource: string,
1673) {
1674 const path = yield* Path.Path;
1675 if (
1676 allowedRoots.some((root) => {
1677 const relative = path.relative(root, realSource);
1678 return (
1679 relative !== ".." &&
1680 !relative.startsWith(`..${path.sep}`) &&
1681 !path.isAbsolute(relative)
1682 );
1683 })
1684 ) {
1685 return;
1686 }
1687 return yield* Effect.fail(
1688 new Error(
1689 `Compute artifact path escapes its staging root: ${displaySource}`,
1690 ),
1691 );
1692});
1693
1694const validateStagedSymlink = Effect.fn(function* (
1695 source: string,
1696 allowedRoots: readonly string[],
1697) {
1698 const fs = yield* FileSystem.FileSystem;
1699 const path = yield* Path.Path;
1700 const target = yield* fs.readLink(source);
1701 if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
1702 return yield* Effect.fail(
1703 new Error(`Compute artifact contains an absolute symlink: ${source}`),
1704 );
1705 }
1706 yield* assertRealPathWithinRoots(source, allowedRoots);
1707 return target;
1708});
1709
1710const validateStagedDirectory = Effect.fn(function* (
1711 root: string,
1712 budget: StagingBudget,
1713) {
1714 const fs = yield* FileSystem.FileSystem;
1715 const path = yield* Path.Path;
1716 const realRoot = yield* fs.realPath(root);
1717
1718 const visit: (
1719 directory: string,
1720 ) => Effect.Effect<void, unknown, FileSystem.FileSystem | Path.Path> =
1721 Effect.fn(function* (directory) {
1722 yield* assertRealPathWithinRoots(directory, [realRoot]);
1723 const entries = (yield* readDirectoryEntries(directory)).sort((a, b) =>
1724 a.name.localeCompare(b.name),
1725 );
1726 for (const entry of entries) {
1727 const source = path.join(directory, entry.name);
1728 if (entry.type === "SymbolicLink") {
1729 yield* validateStagedSymlink(source, [realRoot]);
1730 yield* accountStagingEntry(budget, source, 0);
1731 continue;
1732 }
1733 if (entry.type === "Directory") {
1734 yield* assertRealPathWithinRoots(source, [realRoot]);
1735 yield* accountStagingEntry(budget, source, 0);
1736 yield* visit(source);
1737 continue;
1738 }
1739 if (entry.type === "File") {
1740 yield* assertRealPathWithinRoots(source, [realRoot]);
1741 const stat = yield* fs.stat(source);
1742 yield* accountStagingEntry(
1743 budget,
1744 source,
1745 yield* safeFileSize(source, stat.size),
1746 );
1747 continue;
1748 }
1749 yield* accountStagingEntry(budget, source, 0);
1750 return yield* Effect.fail(
1751 new Error(
1752 `Unsupported filesystem entry in compute artifact: ${source}`,
1753 ),
1754 );
1755 }
1756 });
1757
1758 yield* visit(root);
1759});
1760
1761const resolveSourceRoot = Effect.fn(function* (startPath: string) {
1762 const fs = yield* FileSystem.FileSystem;
1763 const path = yield* Path.Path;
1764 const original = path.resolve(startPath);
1765 let current = original;
1766
1767 while (true) {
1768 if (
1769 (yield* fs.exists(path.join(current, ".git"))) ||
1770 (yield* fs.exists(path.join(current, "pnpm-workspace.yaml"))) ||
1771 (yield* fs.exists(path.join(current, "bun.lock"))) ||
1772 (yield* fs.exists(path.join(current, "bun.lockb"))) ||
1773 (yield* packageJsonDeclaresWorkspaces(current))
1774 ) {
1775 return current;
1776 }
1777
1778 const parent = path.dirname(current);
1779 if (parent === current) return original;
1780 current = parent;
1781 }
1782});
1783
1784const packageJsonDeclaresWorkspaces = Effect.fn(function* (directory: string) {
1785 const parsed = yield* readJsonObjectFile(directory, "package.json");
1786 return parsed?.workspaces !== undefined;
1787});
1788
1789const packageCliCommand = Effect.fn(function* (
1790 appPath: string,
1791 cliName: string,
1792 args: readonly string[],
1793 missingMessage: string,
1794) {
1795 const fs = yield* FileSystem.FileSystem;
1796 const path = yield* Path.Path;
1797 const sourceRoot = yield* resolveSourceRoot(appPath);
1798 const executable = process.platform === "win32" ? `${cliName}.cmd` : cliName;
1799 const candidates = Array.from(
1800 new Set(
1801 [appPath, sourceRoot].map((root) =>
1802 path.join(root, "node_modules", ".bin", executable),
1803 ),
1804 ),
1805 );
1806 for (const candidate of candidates) {
1807 const stat = yield* fs
1808 .stat(candidate)
1809 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1810 if (
1811 stat?.type === "File" &&
1812 (process.platform === "win32" || (stat.mode & 0o111) !== 0)
1813 ) {
1814 const argText = args.map(shellQuote).join(" ");
1815 // Package-manager shims resolve dependencies relative to their own path.
1816 // Resolve symlinked node_modules directories before invoking the shim.
1817 const executablePath = yield* fs.realPath(candidate);
1818 return `${shellQuote(executablePath)}${argText.length > 0 ? ` ${argText}` : ""}`;
1819 }
1820 }
1821 return yield* Effect.fail(new Error(missingMessage));
1822});
1823
1824const packageScriptCommand = (scriptName: string) =>
1825 [
1826 "if command -v bun >/dev/null 2>&1; then",
1827 `bun run ${shellQuote(scriptName)};`,
1828 "elif command -v npm >/dev/null 2>&1; then",
1829 `npm run ${shellQuote(scriptName)};`,
1830 "else",
1831 `echo ${shellQuote("Could not find bun or npm to run package scripts.")} >&2; exit 127;`,
1832 "fi",
1833 ].join(" ");
1834
1835const processBuildEnv = (
1836 env: Record<string, string | Redacted.Redacted<string> | undefined> = {},
1837) =>
1838 Object.fromEntries(
1839 Object.entries(env).flatMap(([key, value]) =>
1840 value === undefined
1841 ? []
1842 : [[key, Redacted.isRedacted(value) ? Redacted.value(value) : value]],
1843 ),
1844 ) as Record<string, string>;
1845
1846const isRecord = (value: unknown): value is Record<string, unknown> =>
1847 typeof value === "object" && value !== null;
1848
1849const normalizeRelativePath = (value: string) => {
1850 const raw = value.trim().replaceAll("\\", "/");
1851 if (raw.length === 0 || raw.startsWith("/") || /^[a-zA-Z]:\//.test(raw)) {
1852 return undefined;
1853 }
1854 const parts: string[] = [];
1855 for (const part of raw.split("/")) {
1856 if (part === "" || part === ".") continue;
1857 if (part === "..") {
1858 if (parts.length === 0) return undefined;
1859 parts.pop();
1860 continue;
1861 }
1862 parts.push(part);
1863 }
1864 return parts.join("/") || ".";
1865};
1866
1867const stripJsonComments = (content: string) => {
1868 let result = "";
1869 let inString = false;
1870 let escaped = false;
1871
1872 for (let i = 0; i < content.length; i++) {
1873 const char = content[i];
1874
1875 if (inString) {
1876 result += char;
1877 if (escaped) {
1878 escaped = false;
1879 } else if (char === "\\") {
1880 escaped = true;
1881 } else if (char === '"') {
1882 inString = false;
1883 }
1884 continue;
1885 }
1886
1887 if (char === '"') {
1888 inString = true;
1889 result += char;
1890 continue;
1891 }
1892
1893 const next = content[i + 1];
1894 if (char === "/" && next === "/") {
1895 while (i < content.length && content[i] !== "\n") {
1896 i++;
1897 }
1898 if (i < content.length) result += content[i];
1899 continue;
1900 }
1901 if (char === "/" && next === "*") {
1902 i += 2;
1903 while (
1904 i < content.length &&
1905 !(content[i] === "*" && content[i + 1] === "/")
1906 ) {
1907 i++;
1908 }
1909 i++;
1910 continue;
1911 }
1912
1913 result += char;
1914 }
1915
1916 return result;
1917};
1918
1919const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;