1import {
2 runBuildCommand,
3 runComputeAutoBuild,
4 runComputeStaticBuild,
5} from "@/Prisma/ComputeBuild";
6import { createComputeArchive } from "@/Prisma/ComputeArchive";
7import { PlatformServices } from "@/Util/PlatformServices";
8import { findAvailablePort } from "@/Util/Node";
9import { describe, expect, it } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as Schedule from "effect/Schedule";
14import * as FetchHttpClient from "effect/http/FetchHttpClient";
15import * as HttpClient from "effect/http/HttpClient";
16import * as ChildProcess from "effect/process/ChildProcess";
17import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
18import { gunzipSync } from "node:zlib";
19
20const inspectBuildEnvironmentCommand = [
21 JSON.stringify(process.execPath),
22 "-e",
23 JSON.stringify(
24 "process.stdout.write(JSON.stringify({ serviceToken: process.env.PRISMA_SERVICE_TOKEN ?? null, apiToken: process.env.PRISMA_API_TOKEN ?? null, inherited: process.env.ALCHEMY_BUILD_INHERITED_SENTINEL ?? null }))",
25 ),
26].join(" ");
27
28const restoreProcessEnv = (name: string, value: string | undefined) => {
29 if (value === undefined) {
30 delete process.env[name];
31 } else {
32 process.env[name] = value;
33 }
34};
35
36describe(
37 "Prisma Compute auto-build",
38 { tags: ["unit", "provider:prisma", "provider:prisma:compute", "local"] },
39 () => {
40 it.effect("builds a Bun app from package.json main", () =>
41 Effect.gen(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const root = yield* fs.makeTempDirectory({
45 prefix: "alchemy-prisma-auto-bun-",
46 });
47 yield* fs.makeDirectory(path.join(root, "src"));
48 yield* fs.writeFileString(
49 path.join(root, "package.json"),
50 JSON.stringify({ main: "src/server.ts" }),
51 );
52 yield* fs.writeFileString(
53 path.join(root, "src", "server.ts"),
54 "console.log('auto bun');",
55 );
56
57 const artifact = yield* runComputeAutoBuild({
58 appPath: root,
59 framework: "bun",
60 });
61 const entrypointText = yield* fs.readFileString(
62 path.join(artifact.directory, artifact.entrypoint),
63 );
64
65 expect(artifact.entrypoint).toBe("server.js");
66 expect(entrypointText).toContain("auto bun");
67
68 yield* artifact.cleanup;
69 expect(yield* fs.exists(artifact.directory)).toBe(false);
70 }).pipe(Effect.provide(PlatformServices)),
71 );
72
73 it.effect("builds a NestJS app from local CLI output", () =>
74 Effect.gen(function* () {
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const root = yield* fs.makeTempDirectory({
78 prefix: "alchemy-prisma-auto-nest-",
79 });
80 const binDir = path.join(root, "node_modules", ".bin");
81 const nestBin = path.join(binDir, "nest");
82 yield* fs.makeDirectory(binDir, { recursive: true });
83 yield* fs.writeFileString(
84 path.join(root, "package.json"),
85 JSON.stringify({ dependencies: { "@nestjs/core": "0.0.0-test" } }),
86 );
87 yield* fs.writeFileString(nestBin, "#!/usr/bin/env sh\nexit 0\n");
88 yield* fs.chmod(nestBin, 0o755);
89 yield* fs.makeDirectory(path.join(root, "dist"), { recursive: true });
90 yield* fs.writeFileString(
91 path.join(root, "dist", "main.js"),
92 "console.log('nest server');",
93 );
94
95 const artifact = yield* runComputeAutoBuild({
96 appPath: root,
97 framework: "nestjs",
98 });
99
100 expect(artifact.entrypoint).toBe("dist/main.js");
101 expect(artifact.defaultPort).toBe(3000);
102 expect(
103 yield* fs.readFileString(
104 path.join(artifact.directory, "dist/main.js"),
105 ),
106 ).toContain("nest server");
107
108 yield* artifact.cleanup;
109 expect(yield* fs.exists(artifact.directory)).toBe(false);
110 }).pipe(Effect.provide(PlatformServices)),
111 );
112
113 it.effect("auto-detects NestJS before the Bun fallback", () =>
114 Effect.gen(function* () {
115 const fs = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const root = yield* fs.makeTempDirectory({
118 prefix: "alchemy-prisma-auto-nest-detect-",
119 });
120 const binDir = path.join(root, "node_modules", ".bin");
121 const nestBin = path.join(binDir, "nest");
122 yield* fs.makeDirectory(binDir, { recursive: true });
123 yield* fs.writeFileString(
124 path.join(root, "package.json"),
125 JSON.stringify({ dependencies: { "@nestjs/core": "0.0.0-test" } }),
126 );
127 yield* fs.writeFileString(nestBin, "#!/usr/bin/env sh\nexit 0\n");
128 yield* fs.chmod(nestBin, 0o755);
129 yield* fs.makeDirectory(path.join(root, "dist", "src"), {
130 recursive: true,
131 });
132 yield* fs.writeFileString(
133 path.join(root, "dist", "src", "main.js"),
134 "console.log('auto nest');",
135 );
136
137 const artifact = yield* runComputeAutoBuild({ appPath: root });
138
139 expect(artifact.entrypoint).toBe("dist/src/main.js");
140
141 yield* artifact.cleanup;
142 expect(yield* fs.exists(artifact.directory)).toBe(false);
143 }).pipe(Effect.provide(PlatformServices)),
144 );
145
146 it.effect("forwards output limits to framework build commands", () =>
147 Effect.gen(function* () {
148 const fs = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const root = yield* fs.makeTempDirectory({
151 prefix: "alchemy-prisma-auto-build-limit-",
152 });
153 const binDir = path.join(root, "node_modules", ".bin");
154 const nestBin = path.join(binDir, "nest");
155 yield* fs.makeDirectory(binDir, { recursive: true });
156 yield* fs.writeFileString(
157 path.join(root, "package.json"),
158 JSON.stringify({ dependencies: { "@nestjs/core": "0.0.0-test" } }),
159 );
160 yield* fs.writeFileString(
161 nestBin,
162 "#!/usr/bin/env sh\nprintf '123456789'\n",
163 );
164 yield* fs.chmod(nestBin, 0o755);
165
166 const error = yield* runComputeAutoBuild({
167 appPath: root,
168 framework: "nestjs",
169 outputLimitBytes: 8,
170 }).pipe(Effect.flip);
171
172 expect((error as Error).message).toContain(
173 "Build stdout exceeded the 8 byte output safety limit",
174 );
175 }).pipe(Effect.provide(PlatformServices)),
176 );
177
178 it.live("forwards deadlines to framework build commands", () =>
179 Effect.gen(function* () {
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const root = yield* fs.makeTempDirectory({
183 prefix: "alchemy-prisma-auto-build-timeout-",
184 });
185 const binDir = path.join(root, "node_modules", ".bin");
186 const nestBin = path.join(binDir, "nest");
187 yield* fs.makeDirectory(binDir, { recursive: true });
188 yield* fs.writeFileString(
189 path.join(root, "package.json"),
190 JSON.stringify({ dependencies: { "@nestjs/core": "0.0.0-test" } }),
191 );
192 yield* fs.writeFileString(nestBin, "#!/usr/bin/env sh\nsleep 10\n");
193 yield* fs.chmod(nestBin, 0o755);
194
195 const error = yield* runComputeAutoBuild({
196 appPath: root,
197 framework: "nestjs",
198 timeoutSeconds: 0.05,
199 }).pipe(Effect.flip);
200
201 expect((error as Error).message).toContain(
202 "Build command timed out after 0.05 seconds",
203 );
204 }).pipe(Effect.provide(PlatformServices)),
205 );
206
207 it.effect(
208 "resolves NestJS config output and stages traced dependencies",
209 () =>
210 Effect.gen(function* () {
211 const fs = yield* FileSystem.FileSystem;
212 const path = yield* Path.Path;
213 const root = yield* fs.makeTempDirectory({
214 prefix: "alchemy-prisma-auto-nest-trace-",
215 });
216 const binDir = path.join(root, "node_modules", ".bin");
217 const nestBin = path.join(binDir, "nest");
218 yield* fs.makeDirectory(binDir, { recursive: true });
219 yield* fs.writeFileString(
220 path.join(root, "package.json"),
221 JSON.stringify({ dependencies: { "@nestjs/core": "0.0.0-test" } }),
222 );
223 yield* fs.writeFileString(
224 path.join(root, "nest-cli.json"),
225 JSON.stringify({ sourceRoot: "app", entryFile: "bootstrap" }),
226 );
227 yield* fs.writeFileString(
228 path.join(root, "tsconfig.json"),
229 [
230 "{",
231 " // Keep URLs with // intact while reading compiler options.",
232 ' "compilerOptions": {',
233 ' "outDir": "build",',
234 ' "sourceMappingURL": "https://example.com//maps"',
235 " }",
236 "}",
237 ].join("\n"),
238 );
239 yield* fs.writeFileString(nestBin, "#!/usr/bin/env sh\nexit 0\n");
240 yield* fs.chmod(nestBin, 0o755);
241
242 const usedDep = path.join(root, "node_modules", "used-dep");
243 const unusedDep = path.join(root, "node_modules", "unused-dep");
244 yield* fs.makeDirectory(usedDep, { recursive: true });
245 yield* fs.writeFileString(
246 path.join(usedDep, "package.json"),
247 JSON.stringify({ name: "used-dep", main: "index.js" }),
248 );
249 yield* fs.writeFileString(
250 path.join(usedDep, "index.js"),
251 "module.exports = 'used';",
252 );
253 yield* fs.makeDirectory(unusedDep, { recursive: true });
254 yield* fs.writeFileString(
255 path.join(unusedDep, "package.json"),
256 JSON.stringify({ name: "unused-dep", main: "index.js" }),
257 );
258 yield* fs.writeFileString(
259 path.join(unusedDep, "index.js"),
260 "module.exports = 'unused';",
261 );
262 yield* fs.makeDirectory(path.join(root, "build", "app"), {
263 recursive: true,
264 });
265 yield* fs.writeFileString(
266 path.join(root, "build", "app", "bootstrap.js"),
267 "const used = require('used-dep'); console.log(used);",
268 );
269
270 const artifact = yield* runComputeAutoBuild({
271 appPath: root,
272 framework: "nestjs",
273 });
274
275 expect(artifact.entrypoint).toBe("build/app/bootstrap.js");
276 expect(
277 yield* fs.exists(
278 path.join(
279 artifact.directory,
280 "node_modules",
281 "used-dep",
282 "index.js",
283 ),
284 ),
285 ).toBe(true);
286 expect(
287 yield* fs.exists(
288 path.join(artifact.directory, "node_modules", "unused-dep"),
289 ),
290 ).toBe(false);
291
292 yield* artifact.cleanup;
293 expect(yield* fs.exists(artifact.directory)).toBe(false);
294 }).pipe(Effect.provide(PlatformServices)),
295 );
296
297 it.effect(
298 "uses a project-local framework CLI and copies Next.js extras",
299 () =>
300 Effect.gen(function* () {
301 const fs = yield* FileSystem.FileSystem;
302 const path = yield* Path.Path;
303 const root = yield* fs.makeTempDirectory({
304 prefix: "alchemy-prisma-auto-next-",
305 });
306 const binDir = path.join(root, "node_modules", ".bin");
307 const nextBin = path.join(binDir, "next");
308 yield* fs.makeDirectory(binDir, { recursive: true });
309 yield* fs.writeFileString(
310 path.join(root, "package.json"),
311 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
312 );
313 yield* fs.writeFileString(
314 nextBin,
315 [
316 "#!/bin/sh",
317 "mkdir -p .next/standalone .next/static public",
318 "printf 'next server' > .next/standalone/server.js",
319 "printf 'next static' > .next/static/app.js",
320 "printf 'next public' > public/asset.txt",
321 "",
322 ].join("\n"),
323 );
324 yield* fs.chmod(nextBin, 0o755);
325
326 const artifact = yield* runComputeAutoBuild({
327 appPath: root,
328 framework: "nextjs",
329 });
330
331 expect(artifact.entrypoint).toBe("server.js");
332 expect(artifact.defaultPort).toBe(3000);
333 expect(
334 yield* fs.readFileString(
335 path.join(artifact.directory, "server.js"),
336 ),
337 ).toBe("next server");
338 expect(
339 yield* fs.readFileString(
340 path.join(artifact.directory, ".next", "static", "app.js"),
341 ),
342 ).toBe("next static");
343 expect(
344 yield* fs.readFileString(
345 path.join(artifact.directory, "public", "asset.txt"),
346 ),
347 ).toBe("next public");
348
349 yield* artifact.cleanup;
350 expect(yield* fs.exists(artifact.directory)).toBe(false);
351 }).pipe(Effect.provide(PlatformServices)),
352 );
353
354 it.effect("uses an installed workspace framework CLI", () =>
355 Effect.gen(function* () {
356 const fs = yield* FileSystem.FileSystem;
357 const path = yield* Path.Path;
358 const root = yield* fs.makeTempDirectory({
359 prefix: "alchemy-prisma-auto-workspace-cli-",
360 });
361 const appPath = path.join(root, "apps", "web");
362 const binDir = path.join(root, "node_modules", ".bin");
363 const nextBin = path.join(binDir, "next");
364 yield* fs.makeDirectory(appPath, { recursive: true });
365 yield* fs.makeDirectory(binDir, { recursive: true });
366 yield* fs.writeFileString(
367 path.join(root, "package.json"),
368 JSON.stringify({ workspaces: ["apps/*"] }),
369 );
370 yield* fs.writeFileString(
371 path.join(appPath, "package.json"),
372 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
373 );
374 yield* fs.writeFileString(
375 nextBin,
376 [
377 "#!/bin/sh",
378 "mkdir -p .next/standalone",
379 "printf 'workspace next server' > .next/standalone/server.js",
380 "",
381 ].join("\n"),
382 );
383 yield* fs.chmod(nextBin, 0o755);
384
385 const artifact = yield* runComputeAutoBuild({
386 appPath,
387 framework: "nextjs",
388 });
389
390 expect(
391 yield* fs.readFileString(path.join(artifact.directory, "server.js")),
392 ).toBe("workspace next server");
393 yield* artifact.cleanup;
394 }).pipe(Effect.provide(PlatformServices)),
395 );
396
397 it.effect("never downloads a missing framework CLI", () =>
398 Effect.gen(function* () {
399 const fs = yield* FileSystem.FileSystem;
400 const path = yield* Path.Path;
401 const root = yield* fs.makeTempDirectory({
402 prefix: "alchemy-prisma-auto-no-network-cli-",
403 });
404 const fakeBin = path.join(root, "fake-bin");
405 const fallbackMarker = path.join(root, "network-fallback-ran");
406 yield* fs.makeDirectory(fakeBin, { recursive: true });
407 yield* fs.writeFileString(
408 path.join(root, "package.json"),
409 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
410 );
411 for (const executable of ["npx", "bunx"]) {
412 const executablePath = path.join(fakeBin, executable);
413 yield* fs.writeFileString(
414 executablePath,
415 `#!/bin/sh\nprintf invoked > ${JSON.stringify(fallbackMarker)}\nexit 127\n`,
416 );
417 yield* fs.chmod(executablePath, 0o755);
418 }
419
420 const error = yield* runComputeAutoBuild({
421 appPath: root,
422 framework: "nextjs",
423 env: { PATH: fakeBin },
424 }).pipe(Effect.flip);
425
426 expect(error).toBeInstanceOf(Error);
427 expect((error as Error).message).toContain(
428 "Could not find an installed Next.js CLI",
429 );
430 expect(yield* fs.exists(fallbackMarker)).toBe(false);
431 }).pipe(Effect.provide(PlatformServices)),
432 );
433
434 it.effect("rejects framework symlinks that escape the output root", () =>
435 Effect.gen(function* () {
436 const fs = yield* FileSystem.FileSystem;
437 const path = yield* Path.Path;
438 const root = yield* fs.makeTempDirectory({
439 prefix: "alchemy-prisma-auto-link-escape-",
440 });
441 const binDir = path.join(root, "node_modules", ".bin");
442 const nextBin = path.join(binDir, "next");
443 const standalone = path.join(root, ".next", "standalone");
444 yield* fs.makeDirectory(binDir, { recursive: true });
445 yield* fs.makeDirectory(standalone, { recursive: true });
446 yield* fs.writeFileString(
447 path.join(root, "package.json"),
448 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
449 );
450 yield* fs.writeFileString(nextBin, "#!/bin/sh\nexit 0\n");
451 yield* fs.chmod(nextBin, 0o755);
452 yield* fs.writeFileString(
453 path.join(standalone, "server.js"),
454 "console.log('server');",
455 );
456 yield* fs.writeFileString(path.join(root, "outside.txt"), "secret");
457 yield* fs.symlink(
458 "../../outside.txt",
459 path.join(standalone, "outside.txt"),
460 );
461
462 const error = yield* runComputeAutoBuild({
463 appPath: root,
464 framework: "nextjs",
465 }).pipe(Effect.flip);
466
467 expect(error).toBeInstanceOf(Error);
468 expect((error as Error).message).toContain(
469 "Compute artifact path escapes its staging root",
470 );
471 }).pipe(Effect.provide(PlatformServices)),
472 );
473
474 it.effect(
475 "rejects a framework output directory outside the application",
476 () =>
477 Effect.gen(function* () {
478 const fs = yield* FileSystem.FileSystem;
479 const path = yield* Path.Path;
480 const root = yield* fs.makeTempDirectory({
481 prefix: "alchemy-prisma-auto-output-root-escape-",
482 });
483 const outside = yield* fs.makeTempDirectory({
484 prefix: "alchemy-prisma-auto-external-output-",
485 });
486 const binDir = path.join(root, "node_modules", ".bin");
487 const nextBin = path.join(binDir, "next");
488 yield* fs.makeDirectory(binDir, { recursive: true });
489 yield* fs.makeDirectory(path.join(root, ".next"), {
490 recursive: true,
491 });
492 yield* fs.writeFileString(
493 path.join(root, "package.json"),
494 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
495 );
496 yield* fs.writeFileString(nextBin, "#!/bin/sh\nexit 0\n");
497 yield* fs.chmod(nextBin, 0o755);
498 yield* fs.writeFileString(
499 path.join(outside, "server.js"),
500 "console.log('server');",
501 );
502 yield* fs.symlink(outside, path.join(root, ".next", "standalone"));
503
504 const error = yield* runComputeAutoBuild({
505 appPath: root,
506 framework: "nextjs",
507 }).pipe(Effect.flip);
508
509 expect(error).toBeInstanceOf(Error);
510 expect((error as Error).message).toContain(
511 "Compute artifact path escapes its staging root",
512 );
513 }).pipe(Effect.provide(PlatformServices)),
514 );
515
516 it.effect(
517 "rejects an oversized framework output file before copying it",
518 () =>
519 Effect.gen(function* () {
520 const fs = yield* FileSystem.FileSystem;
521 const path = yield* Path.Path;
522 const root = yield* fs.makeTempDirectory({
523 prefix: "alchemy-prisma-auto-oversized-file-",
524 });
525 const binDir = path.join(root, "node_modules", ".bin");
526 const nextBin = path.join(binDir, "next");
527 const standalone = path.join(root, ".next", "standalone");
528 const server = path.join(standalone, "server.js");
529 yield* fs.makeDirectory(binDir, { recursive: true });
530 yield* fs.makeDirectory(standalone, { recursive: true });
531 yield* fs.writeFileString(
532 path.join(root, "package.json"),
533 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
534 );
535 yield* fs.writeFileString(nextBin, "#!/bin/sh\nexit 0\n");
536 yield* fs.chmod(nextBin, 0o755);
537 yield* fs.writeFileString(server, "");
538 yield* fs.truncate(server, 128 * 1024 * 1024 + 1);
539
540 const error = yield* runComputeAutoBuild({
541 appPath: root,
542 framework: "nextjs",
543 }).pipe(Effect.flip);
544
545 expect(error).toBeInstanceOf(Error);
546 expect((error as Error).message).toContain(
547 "134217728 byte per-file safety limit",
548 );
549 }).pipe(Effect.provide(PlatformServices)),
550 );
551
552 it.effect(
553 "detects nested Next.js standalone entrypoints in monorepos",
554 () =>
555 Effect.gen(function* () {
556 const fs = yield* FileSystem.FileSystem;
557 const path = yield* Path.Path;
558 const root = yield* fs.makeTempDirectory({
559 prefix: "alchemy-prisma-auto-next-monorepo-",
560 });
561 const binDir = path.join(root, "node_modules", ".bin");
562 const nextBin = path.join(binDir, "next");
563 yield* fs.makeDirectory(binDir, { recursive: true });
564 yield* fs.writeFileString(
565 path.join(root, "package.json"),
566 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
567 );
568 yield* fs.writeFileString(
569 nextBin,
570 [
571 "#!/bin/sh",
572 "mkdir -p .next/standalone/examples/prisma-nextjs .next/standalone/node_modules/pkg .next/static public",
573 "printf 'nested next server' > .next/standalone/examples/prisma-nextjs/server.js",
574 "printf 'dependency server' > .next/standalone/node_modules/pkg/server.js",
575 "printf 'next static' > .next/static/app.js",
576 "printf 'next public' > public/asset.txt",
577 "",
578 ].join("\n"),
579 );
580 yield* fs.chmod(nextBin, 0o755);
581
582 const artifact = yield* runComputeAutoBuild({
583 appPath: root,
584 framework: "nextjs",
585 });
586
587 expect(artifact.entrypoint).toBe("examples/prisma-nextjs/server.js");
588 expect(
589 yield* fs.readFileString(
590 path.join(artifact.directory, artifact.entrypoint),
591 ),
592 ).toBe("nested next server");
593 expect(
594 yield* fs.readFileString(
595 path.join(
596 artifact.directory,
597 "examples",
598 "prisma-nextjs",
599 ".next",
600 "static",
601 "app.js",
602 ),
603 ),
604 ).toBe("next static");
605 expect(
606 yield* fs.readFileString(
607 path.join(
608 artifact.directory,
609 "examples",
610 "prisma-nextjs",
611 "public",
612 "asset.txt",
613 ),
614 ),
615 ).toBe("next public");
616
617 yield* artifact.cleanup;
618 expect(yield* fs.exists(artifact.directory)).toBe(false);
619 }).pipe(Effect.provide(PlatformServices)),
620 );
621
622 it.effect("rejects ambiguous Next.js standalone entrypoints", () =>
623 Effect.gen(function* () {
624 const fs = yield* FileSystem.FileSystem;
625 const path = yield* Path.Path;
626 const root = yield* fs.makeTempDirectory({
627 prefix: "alchemy-prisma-auto-next-ambiguous-",
628 });
629 const binDir = path.join(root, "node_modules", ".bin");
630 const nextBin = path.join(binDir, "next");
631 yield* fs.makeDirectory(binDir, { recursive: true });
632 yield* fs.writeFileString(
633 path.join(root, "package.json"),
634 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
635 );
636 yield* fs.writeFileString(
637 nextBin,
638 [
639 "#!/bin/sh",
640 "mkdir -p .next/standalone/apps/web .next/standalone/apps/admin",
641 "printf 'web server' > .next/standalone/apps/web/server.js",
642 "printf 'admin server' > .next/standalone/apps/admin/server.js",
643 "",
644 ].join("\n"),
645 );
646 yield* fs.chmod(nextBin, 0o755);
647
648 const error = yield* runComputeAutoBuild({
649 appPath: root,
650 framework: "nextjs",
651 }).pipe(Effect.flip);
652
653 expect(error).toBeInstanceOf(Error);
654 expect((error as Error).message).toContain("multiple server.js files");
655 }).pipe(Effect.provide(PlatformServices)),
656 );
657
658 it.effect(
659 "materializes Bun package aliases for Next.js standalone output",
660 () =>
661 Effect.gen(function* () {
662 const fs = yield* FileSystem.FileSystem;
663 const path = yield* Path.Path;
664 const root = yield* fs.makeTempDirectory({
665 prefix: "alchemy-prisma-auto-next-bun-aliases-",
666 });
667 const binDir = path.join(root, "node_modules", ".bin");
668 const nextBin = path.join(binDir, "next");
669 yield* fs.makeDirectory(binDir, { recursive: true });
670 yield* fs.writeFileString(
671 path.join(root, "package.json"),
672 JSON.stringify({ dependencies: { next: "0.0.0-test" } }),
673 );
674 yield* fs.writeFileString(
675 nextBin,
676 [
677 "#!/bin/sh",
678 "set -eu",
679 "mkdir -p .next/standalone/examples/prisma-nextjs",
680 "mkdir -p .next/standalone/node_modules/.bun/@swc+helpers@0.5.15/node_modules/@swc/helpers",
681 "mkdir -p .next/standalone/node_modules/.bun/node_modules/@swc",
682 "printf 'nested next server' > .next/standalone/examples/prisma-nextjs/server.js",
683 'printf \'{"name":"@swc/helpers"}\' > .next/standalone/node_modules/.bun/@swc+helpers@0.5.15/node_modules/@swc/helpers/package.json',
684 "ln -s ../../@swc+helpers@0.5.15/node_modules/@swc/helpers .next/standalone/node_modules/.bun/node_modules/@swc/helpers",
685 "",
686 ].join("\n"),
687 );
688 yield* fs.chmod(nextBin, 0o755);
689
690 const artifact = yield* runComputeAutoBuild({
691 appPath: root,
692 framework: "nextjs",
693 });
694
695 expect(artifact.entrypoint).toBe("examples/prisma-nextjs/server.js");
696 expect(
697 yield* fs.readFileString(
698 path.join(
699 artifact.directory,
700 "node_modules",
701 "@swc",
702 "helpers",
703 "package.json",
704 ),
705 ),
706 ).toBe('{"name":"@swc/helpers"}');
707
708 yield* artifact.cleanup;
709 expect(yield* fs.exists(artifact.directory)).toBe(false);
710 }).pipe(Effect.provide(PlatformServices)),
711 );
712
713 it.live("auto-detects a Vite static SPA", () =>
714 Effect.gen(function* () {
715 const fs = yield* FileSystem.FileSystem;
716 const path = yield* Path.Path;
717 const { root } = yield* copyStaticBuildFixture;
718 const artifact = yield* runComputeAutoBuild({ appPath: root });
719 yield* Effect.addFinalizer(() => artifact.cleanup);
720 const index = yield* fs.readFileString(
721 path.join(root, "dist/index.html"),
722 );
723
724 expect(artifact.entrypoint).toBe("server.mjs");
725 expect(artifact.defaultPort).toBe(8080);
726 expect(artifact.archiveIgnorePrefix).toBe("public");
727 expect(artifact.requiredFiles).toEqual(["public/index.html"]);
728 expect(index).toContain("<h1>app shell</h1>");
729 expect(index).toContain("/assets/app.js");
730 expect(
731 yield* fs.readFileString(
732 path.join(artifact.directory, "public", "index.html"),
733 ),
734 ).toBe(index);
735
736 yield* withStaticSiteServer(artifact.directory, (origin) =>
737 Effect.gen(function* () {
738 const response = yield* HttpClient.get(`${origin}/dashboard`);
739 expect(response.status).toBe(200);
740 expect(yield* response.text).toBe(index);
741 }),
742 );
743
744 yield* artifact.cleanup;
745 expect(yield* fs.exists(artifact.directory)).toBe(false);
746 }).pipe(Effect.provide(PlatformServices)),
747 );
748
749 it.live("detects TanStack Start before the Vite fallback", () =>
750 Effect.gen(function* () {
751 const fs = yield* FileSystem.FileSystem;
752 const path = yield* Path.Path;
753 const { root } = yield* copyStaticBuildFixture;
754 // Declaring Start without its plugins must not silently deploy a SPA.
755 yield* fs.writeFileString(
756 path.join(root, "package.json"),
757 JSON.stringify({
758 type: "module",
759 dependencies: { "@tanstack/react-start": "*" },
760 devDependencies: { vite: "*" },
761 }),
762 );
763
764 const error = yield* runComputeAutoBuild({ appPath: root }).pipe(
765 Effect.flip,
766 );
767
768 expect((error as Error).message).toContain(
769 "TanStack Start build did not produce a Nitro node server entrypoint at .output/server/index.mjs",
770 );
771 expect(
772 yield* fs.readFileString(path.join(root, "dist/index.html")),
773 ).toContain("/assets/app.js");
774 expect(
775 yield* fs.exists(path.join(root, ".output/server/index.mjs")),
776 ).toBe(false);
777 }).pipe(Effect.provide(PlatformServices)),
778 );
779
780 it.effect("bounds retained build stdout", () =>
781 Effect.gen(function* () {
782 const error = yield* runBuildCommand({
783 command: "printf '123456789'",
784 outputLimitBytes: 8,
785 }).pipe(Effect.flip);
786
787 expect(error).toBeInstanceOf(Error);
788 expect((error as Error).message).toContain(
789 "Build stdout exceeded the 8 byte output safety limit",
790 );
791 }).pipe(Effect.provide(PlatformServices)),
792 );
793
794 it.effect("bounds retained build stderr", () =>
795 Effect.gen(function* () {
796 const error = yield* runBuildCommand({
797 command: "printf '123456789' >&2",
798 outputLimitBytes: 8,
799 }).pipe(Effect.flip);
800
801 expect(error).toBeInstanceOf(Error);
802 expect((error as Error).message).toContain(
803 "Build stderr exceeded the 8 byte output safety limit",
804 );
805 }).pipe(Effect.provide(PlatformServices)),
806 );
807
808 it.effect("does not expose failed build output in errors", () =>
809 Effect.gen(function* () {
810 const error = yield* runBuildCommand({
811 command: "printf 'BUILD_SECRET_SENTINEL' >&2; exit 17",
812 }).pipe(Effect.flip);
813
814 expect(error).toBeInstanceOf(Error);
815 expect((error as Error).message).toContain("exit code 17");
816 expect((error as Error).message).toContain("stderr: 21 bytes");
817 expect((error as Error).message).not.toContain("BUILD_SECRET_SENTINEL");
818 }).pipe(Effect.provide(PlatformServices)),
819 );
820
821 it.effect("withholds ambient Prisma credentials from build commands", () =>
822 Effect.gen(function* () {
823 const previousServiceToken = process.env.PRISMA_SERVICE_TOKEN;
824 const previousApiToken = process.env.PRISMA_API_TOKEN;
825 const previousInherited = process.env.ALCHEMY_BUILD_INHERITED_SENTINEL;
826 process.env.PRISMA_SERVICE_TOKEN = "ambient-service-token";
827 process.env.PRISMA_API_TOKEN = "ambient-api-token";
828 process.env.ALCHEMY_BUILD_INHERITED_SENTINEL = "inherited-value";
829
830 try {
831 const result = yield* runBuildCommand({
832 command: inspectBuildEnvironmentCommand,
833 });
834
835 expect(JSON.parse(result.stdout)).toEqual({
836 serviceToken: null,
837 apiToken: null,
838 inherited: "inherited-value",
839 });
840 } finally {
841 restoreProcessEnv("PRISMA_SERVICE_TOKEN", previousServiceToken);
842 restoreProcessEnv("PRISMA_API_TOKEN", previousApiToken);
843 restoreProcessEnv(
844 "ALCHEMY_BUILD_INHERITED_SENTINEL",
845 previousInherited,
846 );
847 }
848 }).pipe(Effect.provide(PlatformServices)),
849 );
850
851 it.effect("allows explicitly configured Prisma build credentials", () =>
852 Effect.gen(function* () {
853 const result = yield* runBuildCommand({
854 command: inspectBuildEnvironmentCommand,
855 env: {
856 PRISMA_SERVICE_TOKEN: "explicit-service-token",
857 PRISMA_API_TOKEN: "explicit-api-token",
858 },
859 });
860
861 expect(JSON.parse(result.stdout)).toEqual({
862 serviceToken: "explicit-service-token",
863 apiToken: "explicit-api-token",
864 inherited: null,
865 });
866 }).pipe(Effect.provide(PlatformServices)),
867 );
868
869 it.live("interrupts a silent build at its configured deadline", () =>
870 Effect.gen(function* () {
871 const error = yield* runBuildCommand({
872 command: "sleep 10",
873 timeoutSeconds: 0.05,
874 }).pipe(Effect.flip);
875
876 expect(error).toBeInstanceOf(Error);
877 expect((error as Error).message).toContain(
878 "Build command timed out after 0.05 seconds",
879 );
880 }).pipe(Effect.provide(PlatformServices)),
881 );
882
883 it.live("kills descendant processes when a build times out", () =>
884 Effect.gen(function* () {
885 if (process.platform === "win32") return;
886 const fs = yield* FileSystem.FileSystem;
887 const path = yield* Path.Path;
888 const root = yield* fs.makeTempDirectory({
889 prefix: "alchemy-prisma-build-process-group-",
890 });
891 const marker = path.join(root, "descendant-survived");
892
893 yield* runBuildCommand({
894 command: `(sleep 0.4; printf survived > ${JSON.stringify(marker)}) & sleep 10`,
895 timeoutSeconds: 0.05,
896 }).pipe(Effect.flip);
897 yield* Effect.sleep("700 millis");
898
899 expect(yield* fs.exists(marker)).toBe(false);
900 }).pipe(Effect.provide(PlatformServices)),
901 );
902
903 it.live("reaps descendants left by a successful build shell", () =>
904 Effect.gen(function* () {
905 if (process.platform === "win32") return;
906 const fs = yield* FileSystem.FileSystem;
907 const path = yield* Path.Path;
908 const root = yield* fs.makeTempDirectory({
909 prefix: "alchemy-prisma-build-success-process-group-",
910 });
911 const marker = path.join(root, "descendant-survived");
912
913 yield* runBuildCommand({
914 command: `(sleep 0.4; printf survived > ${JSON.stringify(marker)}) >/dev/null 2>&1 &`,
915 });
916 yield* Effect.sleep("700 millis");
917
918 expect(yield* fs.exists(marker)).toBe(false);
919 }).pipe(Effect.provide(PlatformServices)),
920 );
921 },
922);
923
924describe(
925 "Prisma Compute static-site build",
926 { tags: ["provider:prisma", "provider:prisma:compute", "local"] },
927 () => {
928 it.live(
929 "packages a prebuilt index without running a command",
930 () =>
931 Effect.gen(function* () {
932 const fs = yield* FileSystem.FileSystem;
933 const path = yield* Path.Path;
934 const root = yield* fs.makeTempDirectoryScoped({
935 prefix: "alchemy-prisma-static-index-",
936 });
937 yield* fs.writeFileString(
938 path.join(root, "index.html"),
939 "hello static",
940 );
941
942 const artifact = yield* runComputeStaticBuild({
943 appPath: root,
944 outdir: ".",
945 });
946 yield* Effect.addFinalizer(() => artifact.cleanup);
947
948 yield* withStaticSiteServer(artifact.directory, (origin) =>
949 Effect.gen(function* () {
950 const response = yield* HttpClient.get(`${origin}/`);
951 expect(response.status).toBe(200);
952 expect(yield* response.text).toBe("hello static");
953 }),
954 );
955
956 yield* artifact.cleanup;
957 }).pipe(Effect.provide(PlatformServices)),
958 { tags: ["unit"] },
959 );
960
961 it.live("packages and serves static files with SPA fallback", () =>
962 Effect.gen(function* () {
963 const fs = yield* FileSystem.FileSystem;
964 const path = yield* Path.Path;
965 const { root, command } = yield* copyStaticBuildFixture;
966 const artifact = yield* runComputeStaticBuild({
967 appPath: root,
968 command,
969 outdir: "dist",
970 spa: true,
971 });
972 yield* Effect.addFinalizer(() => artifact.cleanup);
973 const index = yield* fs.readFileString(
974 path.join(root, "dist/index.html"),
975 );
976 const script = yield* fs.readFileString(
977 path.join(root, "dist/assets/app.js"),
978 );
979 const scriptStat = yield* fs.stat(
980 path.join(root, "dist/assets/app.js"),
981 );
982 const stylesheet = yield* fs.readFileString(
983 path.join(root, "dist/assets/index.css"),
984 );
985 const docs = yield* fs.readFileString(
986 path.join(root, "dist/docs/index.html"),
987 );
988
989 expect(artifact.requiredFiles).toEqual(["public/index.html"]);
990 expect(artifact.entrypoint).toBe("server.mjs");
991 expect(artifact.defaultPort).toBe(8080);
992 expect(index).toContain("<h1>app shell</h1>");
993 expect(script).toMatch(/console\.log\(["'`]asset["'`]\)/);
994 expect(
995 yield* fs.readFileString(
996 path.join(artifact.directory, "public", "index.html"),
997 ),
998 ).toBe(index);
999 expect(yield* fs.exists(path.join(root, "server.mjs"))).toBe(false);
1000
1001 const archive = yield* createComputeArchive({
1002 directory: artifact.directory,
1003 entrypoint: artifact.entrypoint,
1004 ignore: ["assets/*.map", "*.mjs"],
1005 ignorePrefix: artifact.archiveIgnorePrefix,
1006 requiredFiles: artifact.requiredFiles,
1007 });
1008 const excludedIndexError = yield* createComputeArchive({
1009 directory: artifact.directory,
1010 entrypoint: artifact.entrypoint,
1011 ignore: ["index.html"],
1012 ignorePrefix: artifact.archiveIgnorePrefix,
1013 requiredFiles: artifact.requiredFiles,
1014 }).pipe(Effect.flip);
1015 expect(excludedIndexError.message).toContain(
1016 "Required file not found in compute artifact: public/index.html",
1017 );
1018 const extracted = yield* extractStaticArchive(archive);
1019 expect(yield* fs.exists(path.join(extracted, "server.mjs"))).toBe(true);
1020 expect(
1021 yield* fs.exists(path.join(extracted, "public/assets/app.mjs")),
1022 ).toBe(true);
1023 expect(
1024 yield* fs.exists(path.join(extracted, "public/assets/app.js.map")),
1025 ).toBe(false);
1026
1027 for (const directory of [artifact.directory, extracted]) {
1028 yield* withStaticSiteServer(directory, (origin) =>
1029 Effect.gen(function* () {
1030 const rootResponse = yield* HttpClient.get(`${origin}/`);
1031 expect(rootResponse.status).toBe(200);
1032 expect(rootResponse.headers["content-type"]).toContain(
1033 "text/html",
1034 );
1035 expect(yield* rootResponse.text).toBe(index);
1036
1037 const assetResponse = yield* HttpClient.get(
1038 `${origin}/assets/app.js`,
1039 );
1040 expect(assetResponse.status).toBe(200);
1041 expect(assetResponse.headers["content-type"]).toContain(
1042 "javascript",
1043 );
1044 expect(yield* assetResponse.text).toBe(script);
1045
1046 const cssResponse = yield* HttpClient.get(
1047 `${origin}/assets/index.css`,
1048 );
1049 expect(cssResponse.status).toBe(200);
1050 expect(cssResponse.headers["content-type"]).toContain("text/css");
1051 expect(yield* cssResponse.text).toBe(stylesheet);
1052
1053 const directoryResponse = yield* HttpClient.get(
1054 `${origin}/docs/`,
1055 );
1056 expect(directoryResponse.status).toBe(200);
1057 expect(yield* directoryResponse.text).toBe(docs);
1058 yield* assertDirectoryRedirects(origin);
1059
1060 const directoryHead = yield* HttpClient.head(`${origin}/docs/`);
1061 expect(directoryHead.status).toBe(200);
1062 expect(directoryHead.headers["content-type"]).toContain(
1063 "text/html",
1064 );
1065 expect(yield* directoryHead.text).toBe("");
1066
1067 const encodedResponse = yield* HttpClient.get(
1068 `${origin}/docs%20%231.html`,
1069 );
1070 expect(encodedResponse.status).toBe(200);
1071 expect(yield* encodedResponse.text).toBe("encoded asset\n");
1072
1073 const fallbackResponse = yield* HttpClient.get(
1074 `${origin}/dashboard/settings`,
1075 );
1076 expect(fallbackResponse.status).toBe(200);
1077 expect(yield* fallbackResponse.text).toBe(index);
1078
1079 const fallbackHead = yield* HttpClient.head(
1080 `${origin}/dashboard/settings`,
1081 );
1082 expect(fallbackHead.status).toBe(200);
1083 expect(yield* fallbackHead.text).toBe("");
1084
1085 const headResponse = yield* HttpClient.head(
1086 `${origin}/assets/app.js`,
1087 );
1088 expect(headResponse.status).toBe(200);
1089 expect(headResponse.headers["content-length"]).toBe(
1090 String(scriptStat.size),
1091 );
1092 expect(headResponse.headers["content-type"]).toBe(
1093 assetResponse.headers["content-type"],
1094 );
1095 expect(yield* headResponse.text).toBe("");
1096
1097 const postResponse = yield* HttpClient.post(`${origin}/`);
1098 expect(postResponse.status).toBe(405);
1099 expect(postResponse.headers["allow"]).toBe("GET, HEAD");
1100 expect(yield* postResponse.text).toBe("Method Not Allowed");
1101
1102 for (const invalidPath of [
1103 "/..%2Fsecret",
1104 "/%2e%2e%2fsecret",
1105 "/%00",
1106 "/%E0%A4%A",
1107 "/%ZZ",
1108 ]) {
1109 const invalidResponse = yield* HttpClient.get(
1110 `${origin}${invalidPath}`,
1111 );
1112 expect(invalidResponse.status).toBe(400);
1113 expect(yield* invalidResponse.text).toBe("Bad Request");
1114 }
1115 }),
1116 );
1117 }
1118
1119 yield* artifact.cleanup;
1120 expect(yield* fs.exists(artifact.directory)).toBe(false);
1121 }).pipe(Effect.provide(PlatformServices)),
1122 );
1123
1124 it.live("serves a custom root page without enabling SPA fallback", () =>
1125 Effect.gen(function* () {
1126 const fs = yield* FileSystem.FileSystem;
1127 const path = yield* Path.Path;
1128 const { root, command } = yield* copyStaticBuildFixture;
1129 const artifact = yield* runComputeStaticBuild({
1130 appPath: root,
1131 command,
1132 outdir: "dist",
1133 indexPage: "home.html",
1134 });
1135 yield* Effect.addFinalizer(() => artifact.cleanup);
1136 const docs = yield* fs.readFileString(
1137 path.join(root, "dist/docs/index.html"),
1138 );
1139 expect(artifact.requiredFiles).toEqual(["public/home.html"]);
1140 const archive = yield* createComputeArchive({
1141 directory: artifact.directory,
1142 entrypoint: artifact.entrypoint,
1143 requiredFiles: artifact.requiredFiles,
1144 });
1145 const excludedIndexError = yield* createComputeArchive({
1146 directory: artifact.directory,
1147 entrypoint: artifact.entrypoint,
1148 ignore: ["home.html"],
1149 ignorePrefix: artifact.archiveIgnorePrefix,
1150 requiredFiles: artifact.requiredFiles,
1151 }).pipe(Effect.flip);
1152 expect(excludedIndexError.message).toContain(
1153 "Required file not found in compute artifact: public/home.html",
1154 );
1155 const extracted = yield* extractStaticArchive(archive);
1156
1157 for (const directory of [artifact.directory, extracted]) {
1158 yield* withStaticSiteServer(directory, (origin) =>
1159 Effect.gen(function* () {
1160 const rootResponse = yield* HttpClient.get(`${origin}/`);
1161 expect(rootResponse.status).toBe(200);
1162 expect(yield* rootResponse.text).toBe("static home\n");
1163
1164 const rootHead = yield* HttpClient.head(`${origin}/`);
1165 expect(rootHead.status).toBe(200);
1166 expect(rootHead.headers["content-length"]).toBe("12");
1167 expect(yield* rootHead.text).toBe("");
1168
1169 const directoryResponse = yield* HttpClient.get(
1170 `${origin}/docs/`,
1171 );
1172 expect(directoryResponse.status).toBe(200);
1173 expect(yield* directoryResponse.text).toBe(docs);
1174 yield* assertDirectoryRedirects(origin);
1175
1176 const missingResponse = yield* HttpClient.get(
1177 `${origin}/missing`,
1178 );
1179 expect(missingResponse.status).toBe(404);
1180 expect(yield* missingResponse.text).toBe("Not Found");
1181
1182 const missingHead = yield* HttpClient.head(`${origin}/missing`);
1183 expect(missingHead.status).toBe(404);
1184 expect(yield* missingHead.text).toBe("");
1185 }),
1186 );
1187 }
1188
1189 yield* artifact.cleanup;
1190 }).pipe(Effect.provide(PlatformServices)),
1191 );
1192
1193 it.effect(
1194 "scopes archive ignores to the static output",
1195 () =>
1196 Effect.gen(function* () {
1197 const fs = yield* FileSystem.FileSystem;
1198 const path = yield* Path.Path;
1199 const root = yield* fs.makeTempDirectory({
1200 prefix: "alchemy-prisma-static-site-ignore-",
1201 });
1202 yield* fs.makeDirectory(path.join(root, "dist", "assets"), {
1203 recursive: true,
1204 });
1205 yield* fs.writeFileString(
1206 path.join(root, "dist", "index.html"),
1207 "static home",
1208 );
1209 yield* fs.writeFileString(
1210 path.join(root, "dist", "assets", "app.mjs"),
1211 "app",
1212 );
1213 yield* fs.writeFileString(
1214 path.join(root, "dist", "assets", "app.js.map"),
1215 "source map",
1216 );
1217
1218 const artifact = yield* runComputeStaticBuild({
1219 appPath: root,
1220 command: "true",
1221 outdir: "dist",
1222 });
1223 const archive = yield* createComputeArchive({
1224 directory: artifact.directory,
1225 entrypoint: artifact.entrypoint,
1226 ignore: ["assets/*.map", "*.mjs"],
1227 ignorePrefix: artifact.archiveIgnorePrefix,
1228 requiredFiles: artifact.requiredFiles,
1229 }).pipe(Effect.ensuring(artifact.cleanup));
1230 const tarText = yield* Effect.sync(() =>
1231 new TextDecoder().decode(gunzipSync(archive)),
1232 );
1233
1234 expect(tarText).toContain("bundle/server.mjs");
1235 expect(tarText).toContain("bundle/public/index.html");
1236 expect(tarText).toContain("bundle/public/assets/app.mjs");
1237 expect(tarText).not.toContain("bundle/public/assets/app.js.map");
1238 }).pipe(Effect.provide(PlatformServices)),
1239 { tags: ["unit"] },
1240 );
1241
1242 it.effect(
1243 "rejects unsafe output and index paths",
1244 () =>
1245 Effect.gen(function* () {
1246 const fs = yield* FileSystem.FileSystem;
1247 const root = yield* fs.makeTempDirectory({
1248 prefix: "alchemy-prisma-static-site-paths-",
1249 });
1250
1251 const outdirError = yield* runComputeStaticBuild({
1252 appPath: root,
1253 command: "true",
1254 outdir: "../dist",
1255 }).pipe(Effect.flip);
1256 expect((outdirError as Error).message).toContain(
1257 "outdir must be a relative path",
1258 );
1259
1260 const indexError = yield* runComputeStaticBuild({
1261 appPath: root,
1262 command: "true",
1263 outdir: "dist",
1264 indexPage: "../index.html",
1265 }).pipe(Effect.flip);
1266 expect((indexError as Error).message).toContain(
1267 "indexPage must be a relative file path",
1268 );
1269 }).pipe(Effect.provide(PlatformServices)),
1270 { tags: ["unit"] },
1271 );
1272
1273 it.effect(
1274 "fails when the build output or index page is missing",
1275 () =>
1276 Effect.gen(function* () {
1277 const fs = yield* FileSystem.FileSystem;
1278 const path = yield* Path.Path;
1279 const root = yield* fs.makeTempDirectory({
1280 prefix: "alchemy-prisma-static-site-missing-",
1281 });
1282
1283 const outputError = yield* runComputeStaticBuild({
1284 appPath: root,
1285 command: "true",
1286 outdir: "dist",
1287 }).pipe(Effect.flip);
1288 expect((outputError as Error).message).toContain(
1289 "did not produce an output directory",
1290 );
1291
1292 yield* fs.makeDirectory(path.join(root, "dist"));
1293 const indexError = yield* runComputeStaticBuild({
1294 appPath: root,
1295 command: "true",
1296 outdir: "dist",
1297 }).pipe(Effect.flip);
1298 expect((indexError as Error).message).toContain(
1299 "did not produce index.html",
1300 );
1301 }).pipe(Effect.provide(PlatformServices)),
1302 { tags: ["unit"] },
1303 );
1304 },
1305);
1306
1307const assertDirectoryRedirects = Effect.fn(function* (origin: string) {
1308 const query = "?from=%2Fclient%2Froute&next=%2F%2Fevil.example";
1309 for (const pathname of ["/docs", "//docs", "/%2Fdocs"]) {
1310 for (const method of ["GET", "HEAD"] as const) {
1311 const url = `${origin}${pathname}${query}`;
1312 const response = yield* (
1313 method === "GET" ? HttpClient.get(url) : HttpClient.head(url)
1314 ).pipe(
1315 Effect.provideService(FetchHttpClient.RequestInit, {
1316 redirect: "manual",
1317 }),
1318 );
1319 expect(response.status).toBe(301);
1320 const location = response.headers["location"];
1321 expect(location).toBe(
1322 pathname === "/%2Fdocs" ? `/%2Fdocs/${query}` : `/docs/${query}`,
1323 );
1324 const target = yield* Effect.sync(() => new URL(location!, origin));
1325 expect(target.origin).toBe(origin);
1326 expect(target.search).toBe(query);
1327 expect(decodeURIComponent(target.pathname).replace(/^\/+/, "/")).toBe(
1328 "/docs/",
1329 );
1330 expect(yield* response.text).toBe("");
1331
1332 const assetUrl = yield* Effect.sync(
1333 () => new URL("./style.css", target).href,
1334 );
1335 const asset = yield* HttpClient.get(assetUrl);
1336 expect(asset.status).toBe(200);
1337 expect(asset.headers["content-type"]).toContain("text/css");
1338 expect(yield* asset.text).toContain(".directory-marker");
1339 }
1340 }
1341});
1342
1343const copyStaticBuildFixture = Effect.gen(function* () {
1344 const fs = yield* FileSystem.FileSystem;
1345 const path = yield* Path.Path;
1346 const fixture = yield* path.fromFileUrl(
1347 new URL("./fixtures/compute-static-build/", import.meta.url),
1348 );
1349 const nodeModules = yield* path.fromFileUrl(
1350 new URL("../../node_modules/", import.meta.url),
1351 );
1352 const root = yield* fs.makeTempDirectoryScoped({
1353 prefix: "alchemy-prisma-static-build-",
1354 });
1355 yield* fs.copy(fixture, root);
1356 yield* fs.symlink(nodeModules, path.join(root, "node_modules"));
1357 const executable = yield* Effect.sync(() => process.execPath);
1358 return {
1359 root,
1360 command: `${JSON.stringify(executable)} ${JSON.stringify(path.join(nodeModules, "vite/bin/vite.js"))} build`,
1361 };
1362});
1363
1364const extractStaticArchive = Effect.fn(function* (archive: Uint8Array) {
1365 const fs = yield* FileSystem.FileSystem;
1366 const path = yield* Path.Path;
1367 const spawner = yield* ChildProcessSpawner;
1368 const root = yield* fs.makeTempDirectoryScoped({
1369 prefix: "alchemy-prisma-static-archive-",
1370 });
1371 const archivePath = path.join(root, "bundle.tar.gz");
1372 yield* fs.writeFile(archivePath, archive);
1373 const handle = yield* spawner.spawn(
1374 ChildProcess.make("tar", ["-xzf", archivePath, "-C", root], {
1375 stdin: "ignore",
1376 stdout: "ignore",
1377 stderr: "inherit",
1378 }),
1379 );
1380 expect(yield* handle.exitCode).toBe(0);
1381 return path.join(root, "bundle");
1382});
1383
1384const withStaticSiteServer = <A, E, R>(
1385 directory: string,
1386 use: (origin: string) => Effect.Effect<A, E, R>,
1387) =>
1388 Effect.scoped(
1389 Effect.gen(function* () {
1390 const spawner = yield* ChildProcessSpawner;
1391 const port = yield* findAvailablePort();
1392 const executable = yield* Effect.sync(() => process.execPath);
1393 yield* spawner.spawn(
1394 ChildProcess.make(executable, ["server.mjs"], {
1395 cwd: directory,
1396 env: { PORT: String(port) },
1397 stdin: "ignore",
1398 stdout: "ignore",
1399 stderr: "inherit",
1400 killSignal: "SIGKILL",
1401 }),
1402 );
1403 const origin = `http://127.0.0.1:${port}`;
1404 yield* HttpClient.head(`${origin}/`).pipe(
1405 Effect.flatMap((response) =>
1406 response.status === 200
1407 ? Effect.void
1408 : Effect.fail(
1409 new Error(
1410 `Static server readiness returned ${response.status}`,
1411 ),
1412 ),
1413 ),
1414 Effect.timeout("1 second"),
1415 Effect.retry({ schedule: Schedule.spaced("100 millis"), times: 10 }),
1416 );
1417 return yield* use(origin);
1418 }),
1419 ).pipe(Effect.provide(FetchHttpClient.layer));