1import { make as makeVite } from "@alchemy.run/frontend-frameworks/vite";
2import { make as makeNext } from "@alchemy.run/frontend-frameworks/nextjs/node";
3import { make as makeVinext } from "@alchemy.run/frontend-frameworks/vinext/node";
4import { createComputeArchive } from "@/Prisma/ComputeArchive";
5import { stageWebsiteArtifact } from "@/Prisma/Website/Artifact";
6import { findAvailablePort } from "@/Util/Node";
7import { PlatformServices } from "@/Util/PlatformServices";
8import { describe, expect, it } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
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";
18
19const services = Layer.mergeAll(PlatformServices, FetchHttpClient.layer);
20
21const fixture = Effect.fn(function* (name: "vite" | "next") {
22 const fs = yield* FileSystem.FileSystem;
23 const path = yield* Path.Path;
24 const fixtures = yield* path.fromFileUrl(
25 new URL("./fixtures/website-artifact/", import.meta.url),
26 );
27 const root = yield* Effect.acquireRelease(
28 fs.makeTempDirectory({ directory: fixtures, prefix: "build-" }),
29 (directory) =>
30 fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie),
31 );
32 yield* fs.copy(path.join(fixtures, name), root);
33 yield* fs.writeFileString(
34 path.join(root, ".env.private"),
35 "MUST_NOT_SHIP=secret",
36 );
37 yield* fs.writeFileString(
38 path.join(root, "unrelated-private-file.txt"),
39 "not a runtime dependency",
40 );
41 return root;
42});
43
44const extract = Effect.fn(function* (archive: Uint8Array) {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
47 const root = yield* fs.makeTempDirectoryScoped({
48 prefix: "prisma-website-extracted-",
49 });
50 const archivePath = path.join(root, "artifact.tar.gz");
51 yield* fs.writeFile(archivePath, archive);
52 const spawner = yield* ChildProcessSpawner;
53 const child = yield* spawner.spawn(
54 ChildProcess.make("tar", ["-xzf", archivePath, "-C", root], {
55 stdin: "ignore",
56 stdout: "ignore",
57 stderr: "inherit",
58 }),
59 );
60 expect(yield* child.exitCode).toBe(0);
61 const manifest = yield* fs.readFileString(
62 path.join(root, "compute.manifest.json"),
63 );
64 expect(JSON.parse(manifest).entrypoint).toBe("bundle/server.mjs");
65 return path.join(root, "bundle");
66});
67
68const serve = Effect.fn(function* (directory: string) {
69 const port = yield* findAvailablePort();
70 const runtime = yield* Effect.sync(() => process.execPath);
71 const spawner = yield* ChildProcessSpawner;
72 yield* spawner.spawn(
73 ChildProcess.make(runtime, ["server.mjs"], {
74 cwd: directory,
75 env: { PORT: String(port), HOST: "127.0.0.1", NODE_ENV: "production" },
76 stdin: "ignore",
77 stdout: "ignore",
78 stderr: "inherit",
79 killSignal: "SIGKILL",
80 }),
81 );
82 const url = `http://127.0.0.1:${port}`;
83 yield* HttpClient.get(`${url}/health`).pipe(
84 Effect.flatMap((response) =>
85 response.status === 200
86 ? Effect.void
87 : Effect.fail(new Error(`HTTP ${response.status}`)),
88 ),
89 Effect.timeout("1 second"),
90 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 10 }),
91 );
92 return url;
93});
94
95const listFiles = Effect.fn(function* (root: string) {
96 const fs = yield* FileSystem.FileSystem;
97 return yield* fs.readDirectory(root, { recursive: true });
98});
99
100describe.sequential(
101 "Prisma Website artifacts",
102 { tags: ["provider:prisma", "provider:prisma:website", "local"] },
103 () => {
104 it.effect(
105 "ignores dangling optional-package resolution links while retaining the runtime fallback",
106 () =>
107 Effect.scoped(
108 Effect.gen(function* () {
109 const fs = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
111 const root = yield* fixture("vite");
112 const dist = path.join(root, "dist");
113 const modules = path.join(root, "node_modules");
114 yield* fs.makeDirectory(dist);
115 yield* fs.makeDirectory(modules);
116 yield* fs.symlink(
117 path.join(root, "missing-optional-runtime"),
118 path.join(modules, "optional-runtime"),
119 );
120 const entry = path.join(dist, "server.cjs");
121 yield* fs.writeFileString(
122 entry,
123 'try { module.exports = require("optional-runtime"); } catch { module.exports = require("./fallback.cjs"); }',
124 );
125 yield* fs.writeFileString(
126 path.join(dist, "fallback.cjs"),
127 'module.exports = "fallback";',
128 );
129 const staged = yield* stageWebsiteArtifact({
130 root,
131 distDir: dist,
132 serverEntry: entry,
133 });
134 const files = yield* listFiles(staged.directory);
135 expect(files.some((file) => file.endsWith("fallback.cjs"))).toBe(
136 true,
137 );
138 expect(
139 files.some((file) => file.includes("optional-runtime")),
140 ).toBe(false);
141 }),
142 ).pipe(Effect.provide(services)),
143 { tags: ["unit"] },
144 );
145
146 it.effect(
147 "rejects a dangling symlink in the build output",
148 () =>
149 Effect.scoped(
150 Effect.gen(function* () {
151 const fs = yield* FileSystem.FileSystem;
152 const path = yield* Path.Path;
153 const root = yield* fixture("vite");
154 const dist = path.join(root, "dist");
155 yield* fs.makeDirectory(dist);
156 yield* fs.symlink(
157 path.join(root, "missing-build-asset"),
158 path.join(dist, "required-asset"),
159 );
160 const error = yield* stageWebsiteArtifact({
161 root,
162 distDir: dist,
163 static: {},
164 }).pipe(Effect.flip);
165 expect(String(error)).toContain("required-asset");
166 }),
167 ).pipe(Effect.provide(services)),
168 { tags: ["unit"] },
169 );
170
171 it.effect(
172 "rejects output symlinks that would package the source tree",
173 () =>
174 Effect.scoped(
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const path = yield* Path.Path;
178 const root = yield* fixture("vite");
179 const dist = path.join(root, "dist");
180 yield* fs.makeDirectory(dist);
181 yield* fs.symlink(root, path.join(dist, "source"));
182 const error = yield* stageWebsiteArtifact({
183 root,
184 distDir: dist,
185 static: {},
186 }).pipe(Effect.flip);
187 expect(String(error)).toContain(
188 "symlink escapes its selected directory",
189 );
190 }),
191 ).pipe(Effect.provide(services)),
192 { tags: ["unit"] },
193 );
194
195 it.live(
196 "builds Vite and serves the extracted artifact without the source tree",
197 () =>
198 Effect.scoped(
199 Effect.gen(function* () {
200 const fs = yield* FileSystem.FileSystem;
201 const path = yield* Path.Path;
202 const root = yield* fixture("vite");
203 const framework = yield* makeVite({
204 root,
205 target: "@alchemy.run/frontend-frameworks/vite/node",
206 });
207 const built = yield* framework.build({ root });
208 const dist = built.distDirectory!;
209 yield* fs.writeFileString(
210 path.join(dist, ".env"),
211 "BUILD_SECRET=hidden",
212 );
213 const staged = yield* stageWebsiteArtifact({
214 root,
215 distDir: dist,
216 serverEntry: path.join(dist, built.serverModules![0]!.name),
217 });
218 const archive = yield* createComputeArchive(staged);
219 const extracted = yield* extract(archive);
220 const files = yield* listFiles(extracted);
221 expect(files.some((file) => file.endsWith(".env"))).toBe(false);
222 expect(
223 files.some((file) => file.includes("unrelated-private-file")),
224 ).toBe(false);
225 yield* fs.remove(root, { recursive: true });
226 const url = yield* serve(extracted);
227 const html = yield* HttpClient.get(url).pipe(
228 Effect.flatMap((response) => response.text),
229 );
230 expect(html).toContain("Website artifact Vite build");
231 const asset = html.match(/src="([^"]+\.js)"/)?.[1];
232 expect(asset).toBeDefined();
233 expect(
234 yield* HttpClient.get(`${url}${asset}`).pipe(
235 Effect.flatMap((response) => response.text),
236 ),
237 ).toContain("built-by-real-vite");
238 expect((yield* HttpClient.get(`${url}/deep/link`)).status).toBe(
239 200,
240 );
241 }),
242 ).pipe(Effect.provide(services)),
243 { tags: ["provider:prisma:compute"], timeout: 90_000 },
244 );
245
246 it.live(
247 "packages static output without copying source or installed dependencies",
248 () =>
249 Effect.scoped(
250 Effect.gen(function* () {
251 const fs = yield* FileSystem.FileSystem;
252 const path = yield* Path.Path;
253 const root = yield* fixture("vite");
254 const framework = yield* makeVite({
255 root,
256 target: "@alchemy.run/frontend-frameworks/vite/node",
257 });
258 const built = yield* framework.build({ root });
259 yield* fs.writeFileString(
260 path.join(built.distDirectory!, "missing.html"),
261 "Artifact not found",
262 );
263 const staged = yield* stageWebsiteArtifact({
264 root,
265 distDir: built.distDirectory!,
266 static: {
267 notFoundHandling: "404-page",
268 errorPage: "missing.html",
269 },
270 });
271 const extracted = yield* extract(
272 yield* createComputeArchive(staged),
273 );
274 expect(
275 (yield* listFiles(extracted)).some((file) =>
276 file.includes("node_modules"),
277 ),
278 ).toBe(false);
279 yield* fs.remove(root, { recursive: true });
280 const url = yield* serve(extracted);
281 const missing = yield* HttpClient.get(`${url}/missing`);
282 expect(missing.status).toBe(404);
283 expect(yield* missing.text).toBe("Artifact not found");
284 }),
285 ).pipe(Effect.provide(services)),
286 { tags: ["provider:prisma:compute"], timeout: 90_000 },
287 );
288
289 it.live(
290 "builds vinext and preserves vendored package paths in the extracted artifact",
291 () =>
292 Effect.scoped(
293 Effect.gen(function* () {
294 const path = yield* Path.Path;
295 const root = yield* path.fromFileUrl(
296 new URL(
297 "../../../../examples/prisma-website-vinext/",
298 import.meta.url,
299 ),
300 );
301 const framework = yield* makeVinext({ root });
302 const built = yield* framework.build({ root });
303 const staged = yield* stageWebsiteArtifact({
304 root,
305 distDir: built.distDirectory,
306 serverEntry: path.join(
307 built.distDirectory,
308 built.serverModules[0]!.name,
309 ),
310 });
311 const extracted = yield* extract(
312 yield* createComputeArchive(staged),
313 );
314 const files = yield* listFiles(extracted);
315 expect(
316 files.some(
317 (file) =>
318 file.includes("/deps/.pnpm/pathslash@") &&
319 file.endsWith("/dist/index.js"),
320 ),
321 ).toBe(true);
322 const url = yield* serve(extracted);
323 const home = yield* HttpClient.get(url);
324 expect(home.status).toBe(200);
325 expect(yield* home.text).toContain("Hello from vinext on Prisma!");
326 const asset = yield* HttpClient.get(`${url}/example.json`);
327 expect(yield* asset.json).toEqual({
328 framework: "vinext",
329 greeting: "Hello from vinext on Prisma!",
330 });
331 const api = yield* HttpClient.get(`${url}/api/hello?name=artifact`);
332 expect(yield* api.json).toEqual({
333 name: "artifact",
334 greeting: "hello",
335 });
336 }),
337 ).pipe(Effect.provide(services)),
338 { tags: ["provider:prisma:compute"], timeout: 120_000 },
339 );
340
341 it.live(
342 "builds Next.js and traces config, runtime packages, SSR, and public assets",
343 () =>
344 Effect.scoped(
345 Effect.gen(function* () {
346 const fs = yield* FileSystem.FileSystem;
347 const path = yield* Path.Path;
348 const root = yield* fixture("next");
349 const framework = yield* makeNext({ root });
350 const built = yield* framework.build({ root });
351 const staged = yield* stageWebsiteArtifact({
352 root,
353 distDir: built.distDirectory!,
354 serverEntry: path.join(root, built.serverModules![0]!.name),
355 layout: "next",
356 });
357 const extracted = yield* extract(
358 yield* createComputeArchive(staged),
359 );
360 const files = yield* listFiles(extracted);
361 expect(files.some((file) => file.endsWith("site-config.mjs"))).toBe(
362 true,
363 );
364 expect(files.some((file) => file.endsWith(".next/BUILD_ID"))).toBe(
365 true,
366 );
367 expect(files.some((file) => file.includes(".next/cache/"))).toBe(
368 false,
369 );
370 expect(
371 files.some((file) => file.includes("unrelated-private-file")),
372 ).toBe(false);
373 expect(files.some((file) => file.endsWith(".env.private"))).toBe(
374 false,
375 );
376 yield* fs.remove(root, { recursive: true });
377 const url = yield* serve(extracted);
378 expect(
379 yield* HttpClient.get(url).pipe(
380 Effect.flatMap((response) => response.text),
381 ),
382 ).toContain("Next artifact config dependency");
383 expect(
384 yield* HttpClient.get(`${url}/artifact.txt`).pipe(
385 Effect.flatMap((response) => response.text),
386 ),
387 ).toBe("Next public artifact\n");
388 }),
389 ).pipe(Effect.provide(services)),
390 { tags: ["provider:prisma:compute"], timeout: 120_000 },
391 );
392 },
393);