| 1 | import * as ByteSize from "effect/ByteSize"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import type { Scope } from "effect/Scope"; |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 12 | import { 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. |
| 16 | const 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 | |
| 24 | export type ComputeAutoBuildFramework = |
| 25 | | "auto" |
| 26 | | "nextjs" |
| 27 | | "nuxt" |
| 28 | | "astro" |
| 29 | | "nestjs" |
| 30 | | "tanstack-start" |
| 31 | | "vite" |
| 32 | | "bun"; |
| 33 | |
| 34 | export 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 | |
| 78 | export 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 | |
| 106 | export 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 | |
| 155 | interface 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 | |
| 164 | interface DirectoryEntry { |
| 165 | name: string; |
| 166 | type: "Directory" | "File" | "SymbolicLink" | "Other"; |
| 167 | } |
| 168 | |
| 169 | type BuildServices = |
| 170 | | ChildProcessSpawner |
| 171 | | FileSystem.FileSystem |
| 172 | | Path.Path |
| 173 | | Scope; |
| 174 | |
| 175 | export 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 | |
| 199 | const DEFAULT_BUILD_OUTPUT_LIMIT_BYTES = 1024 * 1024; |
| 200 | const DEFAULT_BUILD_TIMEOUT_SECONDS = 15 * 60; |
| 201 | const STAGING_MAX_ENTRIES = 50_000; |
| 202 | const STAGING_MAX_TOTAL_BYTES = 256 * 1024 * 1024; |
| 203 | const STAGING_MAX_FILE_BYTES = 128 * 1024 * 1024; |
| 204 | const STAGING_TIMEOUT_SECONDS = 2 * 60; |
| 205 | const PRISMA_MANAGEMENT_CREDENTIAL_ENV_NAMES = new Set([ |
| 206 | "PRISMA_SERVICE_TOKEN", |
| 207 | "PRISMA_API_TOKEN", |
| 208 | ]); |
| 209 | |
| 210 | interface StagingBudget { |
| 211 | entries: number; |
| 212 | totalBytes: number; |
| 213 | readonly deadline: number; |
| 214 | } |
| 215 | |
| 216 | const 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 | */ |
| 255 | export 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 | |
| 338 | const 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 | |
| 351 | const NEXT_CONFIG_FILENAMES = [ |
| 352 | "next.config.js", |
| 353 | "next.config.mjs", |
| 354 | "next.config.ts", |
| 355 | "next.config.mts", |
| 356 | ] as const; |
| 357 | |
| 358 | const 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 | |
| 366 | const 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 | |
| 374 | const NEST_CLI_FILENAME = "nest-cli.json"; |
| 375 | const NEST_TSCONFIG_FILENAMES = [ |
| 376 | "tsconfig.build.json", |
| 377 | "tsconfig.json", |
| 378 | ] as const; |
| 379 | const NEST_DEFAULT_COMPILED_ENTRYPOINTS = [ |
| 380 | "dist/src/main.js", |
| 381 | "dist/main.js", |
| 382 | ] as const; |
| 383 | |
| 384 | const TANSTACK_START_PACKAGES = [ |
| 385 | "@tanstack/react-start", |
| 386 | "@tanstack/solid-start", |
| 387 | ] as const; |
| 388 | |
| 389 | const 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 | |
| 542 | export 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 | |
| 562 | export 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 | |
| 659 | const 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 | |
| 733 | const 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 | |
| 830 | const 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 | |
| 881 | const 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 | |
| 908 | const 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 | |
| 921 | const 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 | |
| 974 | const 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 | |
| 995 | const 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 | |
| 1073 | const 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 | |
| 1106 | const 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 | |
| 1154 | const 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 | |
| 1197 | const 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 | |
| 1240 | function 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 | |
| 1303 | const 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 | |
| 1334 | const 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 | |
| 1348 | const 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 | |
| 1359 | const 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 | |
| 1372 | const readPackageMain = Effect.fn(function* (appPath: string) { |
| 1373 | const parsed = yield* readPackageJson(appPath); |
| 1374 | return typeof parsed?.main === "string" ? parsed.main : undefined; |
| 1375 | }); |
| 1376 | |
| 1377 | const 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 | |
| 1389 | const 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 | |
| 1410 | const 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 | |
| 1420 | const 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 | |
| 1435 | const 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 | |
| 1448 | const 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 | |
| 1468 | const 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 | |
| 1476 | const 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 | |
| 1539 | const makeStagingBudget = (): StagingBudget => ({ |
| 1540 | entries: 0, |
| 1541 | totalBytes: 0, |
| 1542 | deadline: Date.now() + STAGING_TIMEOUT_SECONDS * 1000, |
| 1543 | }); |
| 1544 | |
| 1545 | const 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 | |
| 1564 | const 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 | |
| 1607 | const 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 | |
| 1616 | const 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 | |
| 1626 | const 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 | |
| 1669 | const 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 | |
| 1694 | const 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 | |
| 1710 | const 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 | |
| 1761 | const 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 | |
| 1784 | const packageJsonDeclaresWorkspaces = Effect.fn(function* (directory: string) { |
| 1785 | const parsed = yield* readJsonObjectFile(directory, "package.json"); |
| 1786 | return parsed?.workspaces !== undefined; |
| 1787 | }); |
| 1788 | |
| 1789 | const 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 | |
| 1824 | const 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 | |
| 1835 | const 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 | |
| 1846 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 1847 | typeof value === "object" && value !== null; |
| 1848 | |
| 1849 | const 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 | |
| 1867 | const 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 | |
| 1919 | const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; |