1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { dotAlchemyDirectory } from "../../AlchemyContext.ts";
5import { isPathWithin } from "../../Util/isPathWithin.ts";
6import type { PlatformError } from "effect/PlatformError";
7import { createRequire } from "node:module";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { initialCwd } from "../../Util/Node.ts";
12import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts";
13import { createComputeArchive } from "../ComputeArchive.ts";
14import { loadFrontendCore } from "../../Website/FrontendCore.ts";
15
16/** Resolved framework output to stage after Website.Server has built it. */
17export interface WebsiteArtifactProps {
18 /** Application root, relative to the initial working directory. */
19 root: string;
20 /** Build output directory, relative to the initial working directory. */
21 distDir: string;
22 /** Node serve entry, relative to the initial working directory. */
23 serverEntry?: string;
24 /** Generate the shared static-file server instead of tracing a framework entry. */
25 static?: {
26 /** Unmatched-path behavior. @default "none" */
27 notFoundHandling?: "none" | "spa" | "404-page";
28 /** Extensionless HTML handling. @default "none" */
29 htmlHandling?: "none" | "drop-trailing-slash";
30 /** HTML file served for misses, relative to the static output. @default "404.html" */
31 errorPage?: string;
32 };
33 /** Next.js keeps its output and configuration beside the serve entry. */
34 layout?: "output" | "next";
35 /** Build output fingerprint; keeps the build dependency explicit. */
36 buildHash?: string;
37}
38
39/** Internal, filesystem-only artifact lifecycle; never creates cloud resources. */
40export interface WebsiteArtifact extends Resource<
41 "Prisma.WebsiteArtifact",
42 WebsiteArtifactProps,
43 {
44 /** Private directory owned by this artifact resource. */
45 directory: string;
46 /** Verified Compute tarball path. */
47 artifactPath: string;
48 /** SHA-256 of the tarball uploaded by Compute. */
49 hash: string;
50 }
51> {}
52
53export const WebsiteArtifact = Resource<WebsiteArtifact>(
54 "Prisma.WebsiteArtifact",
55);
56
57const MAX_ENTRIES = 50_000;
58const MAX_BYTES = 256 * 1024 * 1024;
59const MAX_FILE_BYTES = 128 * 1024 * 1024;
60const excluded = (relative: string) =>
61 relative
62 .split(/[\\/]/)
63 .some((part) =>
64 /^(?:\.git|\.alchemy|\.env(?:\..*)?|\.envrc|\.npmrc|\.yarnrc.*|\.netrc|\.pypirc|\.aws|\.ssh)$/.test(
65 part,
66 ),
67 );
68const javascript = (file: string) => /\.[cm]?[jt]sx?$/.test(file);
69const nextExcluded = (relative: string) =>
70 /^\.next\/(?:cache|standalone|types|diagnostics|dev)(?:\/|$)/.test(
71 relative,
72 ) || /^\.next\/trace(?:-|$)/.test(relative);
73
74const fail = (message: string) => Effect.fail(new Error(message));
75
76/**
77 * Stage real build files and NFT's reachable runtime graph without relocating
78 * individual modules. Keeping their relative layout preserves import.meta.url,
79 * package scopes, pnpm links, and dynamically loaded framework chunks.
80 * The caller owns the surrounding Scope and must archive before it closes.
81 */
82export const stageWebsiteArtifact = Effect.fn(function* (
83 props: WebsiteArtifactProps,
84) {
85 const fs = yield* FileSystem.FileSystem;
86 const path = yield* Path.Path;
87 const root = yield* fs.realPath(path.resolve(initialCwd, props.root));
88 const runtimeBase = process.cwd();
89 const dotAlchemy = yield* dotAlchemyDirectory;
90 const dist = yield* fs.realPath(path.resolve(initialCwd, props.distDir));
91 const entry =
92 props.serverEntry === undefined
93 ? undefined
94 : yield* fs.realPath(path.resolve(initialCwd, props.serverEntry));
95 if (entry === undefined && props.static === undefined) {
96 return yield* fail(
97 "A website artifact requires a Node server entry or static serving options.",
98 );
99 }
100 if (entry !== undefined && (yield* fs.stat(entry)).type !== "File") {
101 return yield* fail(`Website server entry is not a file: ${entry}`);
102 }
103 if (props.layout !== "next" && dist === root) {
104 return yield* fail(
105 "Website output must be a dedicated build directory, not the application root.",
106 );
107 }
108 const files = new Set<string>(entry === undefined ? [] : [entry]);
109 const seeds = new Set<string>(entry === undefined ? [] : [entry]);
110 const manifests: string[] = [];
111 let visited = 0;
112 const collect: (
113 source: string,
114 ancestors?: ReadonlySet<string>,
115 runtimePackage?: boolean,
116 boundary?: string,
117 ) => Effect.Effect<void, Error | PlatformError> = Effect.fn(function* (
118 source,
119 ancestors = new Set(),
120 runtimePackage = false,
121 boundary,
122 ) {
123 if (++visited > MAX_ENTRIES)
124 return yield* fail(
125 "Website artifact exceeds the 50,000-entry safety limit.",
126 );
127 const relative = path.relative(root, source).replaceAll("\\", "/");
128 if (
129 excluded(relative) ||
130 isPathWithin(dotAlchemy, source, runtimeBase) ||
131 (runtimePackage && /(?:\.map|\.d\.ts)$/.test(source)) ||
132 (props.layout === "next" && nextExcluded(relative))
133 )
134 return;
135 const real = yield* fs.realPath(source);
136 const relativeToBoundary = path.relative(boundary ?? real, real);
137 if (
138 relativeToBoundary === ".." ||
139 relativeToBoundary.startsWith(`..${path.sep}`) ||
140 path.isAbsolute(relativeToBoundary)
141 ) {
142 return yield* fail(
143 `Website output symlink escapes its selected directory: ${source}`,
144 );
145 }
146 const stat = yield* fs.stat(real);
147 if (stat.type === "Directory") {
148 if (ancestors.has(real))
149 return yield* fail(`Cyclic website output symlink: ${source}`);
150 const next = new Set([...ancestors, real]);
151 for (const name of yield* fs.readDirectory(source)) {
152 // Runtime dependencies are traced, never copied wholesale.
153 if (name === "node_modules") continue;
154 yield* collect(
155 path.join(source, name),
156 next,
157 runtimePackage,
158 boundary ?? real,
159 );
160 }
161 } else if (stat.type === "File") {
162 files.add(source);
163 if (!runtimePackage && props.static === undefined && javascript(source))
164 seeds.add(source);
165 if (props.layout === "next" && source.endsWith(".nft.json"))
166 manifests.push(source);
167 } else {
168 return yield* fail(`Unsupported website output file: ${source}`);
169 }
170 });
171
172 const extras = yield* packSiteExtraFiles(
173 props.layout === "next" ? root : dist,
174 props.layout === "next" ? "next" : "client",
175 );
176 if (
177 props.layout === "next" &&
178 !(yield* fs.exists(path.join(root, ".next", "BUILD_ID")))
179 ) {
180 return yield* fail(
181 "Next.js output is missing .next/BUILD_ID; run the production build before packaging.",
182 );
183 }
184 for (const extra of extras ?? []) yield* collect(extra.source);
185 // Also include config extensions not currently listed by packSiteExtraFiles.
186 if (props.layout === "next") {
187 for (const name of ["next.config.mts", "next.config.cts"]) {
188 const config = path.join(root, name);
189 if (yield* fs.exists(config)) yield* collect(config);
190 }
191 }
192
193 if (props.layout === "next") {
194 const nextPackage = yield* Effect.try(() =>
195 createRequire(path.join(root, "package.json")).resolve(
196 "next/package.json",
197 ),
198 );
199 const nextRoot = path.dirname(nextPackage);
200 // Next's custom server resolves internal modules through runtime alias
201 // tables that NFT cannot evaluate. Keep that package, not node_modules.
202 yield* collect(nextRoot, undefined, true);
203 yield* collect(path.join(nextRoot, "dist", "compiled", "webpack"));
204 }
205
206 for (const manifest of manifests) {
207 const text = yield* fs.readFileString(manifest);
208 const parsed = yield* Effect.try(
209 () => JSON.parse(text) as { files?: unknown },
210 );
211 if (
212 !Array.isArray(parsed.files) ||
213 !parsed.files.every((file): file is string => typeof file === "string")
214 ) {
215 return yield* fail(`Invalid Next.js trace manifest: ${manifest}`);
216 }
217 for (const file of parsed.files) {
218 const source = path.resolve(path.dirname(manifest), file);
219 if (
220 excluded(path.relative(root, source)) ||
221 isPathWithin(dotAlchemy, source, runtimeBase)
222 )
223 return yield* fail(
224 `A Next.js runtime dependency is a sensitive file: ${source}`,
225 );
226 files.add(source);
227 if (javascript(source)) seeds.add(source);
228 }
229 }
230
231 const traceBase = path.parse(root).root;
232 const trace =
233 seeds.size === 0
234 ? undefined
235 : yield* Effect.tryPromise({
236 try: () =>
237 import("@vercel/nft").then(({ nodeFileTrace }) =>
238 nodeFileTrace([...seeds], {
239 base: traceBase,
240 processCwd: root,
241 conditions: ["node", "production"],
242 analysis: {
243 emitGlobs: true,
244 computeFileReferences: true,
245 evaluatePureExpressions: true,
246 },
247 }),
248 ),
249 catch: (cause) =>
250 new Error(
251 "Failed to trace website runtime dependencies. Install @vercel/nft in the deployment workspace.",
252 { cause },
253 ),
254 }).pipe(Effect.timeout("90 seconds"));
255 if (trace !== undefined) {
256 for (const file of trace.fileList) {
257 const source = path.resolve(traceBase, file);
258 // NFT records symlinks encountered while resolving optional imports,
259 // even when their targets are absent. They are lookup evidence, not
260 // runtime files. Explicit build/manifest files remain required.
261 if (
262 !files.has(source) &&
263 trace.reasons.get(file)?.type.every((type) => type === "resolve") &&
264 !(yield* fs.exists(source))
265 )
266 continue;
267 files.add(source);
268 }
269 }
270 if (files.size > MAX_ENTRIES)
271 return yield* fail(
272 "Website dependency trace exceeds the 50,000-entry safety limit.",
273 );
274
275 const links = new Map<string, string>();
276 const selected = new Set<string>();
277 for (const file of files) {
278 if (
279 excluded(path.relative(root, file)) ||
280 isPathWithin(dotAlchemy, file, runtimeBase)
281 )
282 return yield* fail(
283 `A website runtime dependency is a sensitive file: ${file}`,
284 );
285 const link = yield* fs
286 .readLink(file)
287 .pipe(Effect.catch(() => Effect.succeed(undefined)));
288 if (link !== undefined) {
289 const target = yield* fs.realPath(file);
290 links.set(file, target);
291 if ((yield* fs.stat(target)).type === "File")
292 selected.add(yield* fs.realPath(target));
293 } else {
294 const stat = yield* fs.stat(file);
295 if (stat.type !== "File")
296 return yield* fail(
297 `A traced runtime dependency is not a file: ${file}`,
298 );
299 selected.add(file);
300 }
301 }
302 let base = root;
303 const within = (directory: string, file: string) => {
304 const relative = path.relative(directory, file);
305 return (
306 relative !== ".." &&
307 !relative.startsWith(`..${path.sep}`) &&
308 !path.isAbsolute(relative)
309 );
310 };
311 for (const file of [...selected, ...links.keys(), ...links.values()]) {
312 while (!within(base, file)) base = path.dirname(base);
313 }
314 const directory = yield* fs.makeTempDirectoryScoped({
315 prefix: "alchemy-prisma-website-",
316 });
317 const staged = (file: string) =>
318 path.join(directory, "files", path.relative(base, file));
319 let bytes = 0;
320 for (const file of [...selected].sort()) {
321 const stat = yield* fs.stat(file);
322 const size = Number(stat.size);
323 bytes += size;
324 if (size > MAX_FILE_BYTES || bytes > MAX_BYTES)
325 return yield* fail(
326 "Website artifact exceeds Prisma Compute's file or total byte safety limit.",
327 );
328 const dest = staged(file);
329 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
330 yield* fs.copyFile(file, dest);
331 yield* fs.chmod(dest, stat.mode & 0o777);
332 }
333 for (const [file, target] of links) {
334 // NFT also reports resolution-only links with no retained runtime files.
335 if (!(yield* fs.exists(staged(target)))) continue;
336 const dest = staged(file);
337 if (yield* fs.exists(dest)) continue;
338 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
339 yield* fs.symlink(path.relative(path.dirname(dest), staged(target)), dest);
340 }
341 if (props.static !== undefined) {
342 const { makeNodeServeEntrySource } = yield* loadFrontendCore;
343 const client = path.relative(directory, staged(dist)).replaceAll("\\", "/");
344 yield* fs.writeFileString(
345 path.join(directory, "server.mjs"),
346 makeNodeServeEntrySource({
347 ...props.static,
348 clientDirExpression: `fileURLToPath(new URL(${JSON.stringify(`./${client}/`)}, import.meta.url))`,
349 }),
350 );
351 return { directory, entrypoint: "server.mjs", requiredFiles: [] };
352 }
353 if (entry === undefined) return yield* fail("Missing website server entry.");
354 const entryRelative = path
355 .relative(directory, staged(entry))
356 .replaceAll("\\", "/");
357 const cwdRelative = path
358 .relative(directory, staged(root))
359 .replaceAll("\\", "/");
360 yield* fs.makeDirectory(staged(root), { recursive: true });
361 yield* fs.writeFileString(
362 path.join(directory, "server.mjs"),
363 [
364 'import { fileURLToPath } from "node:url";',
365 `process.chdir(fileURLToPath(new URL(${JSON.stringify(`./${cwdRelative}/`)}, import.meta.url)));`,
366 `await import(new URL(${JSON.stringify(`./${entryRelative}`)}, import.meta.url).href);`,
367 "",
368 ].join("\n"),
369 );
370 return {
371 directory,
372 entrypoint: "server.mjs",
373 requiredFiles: [entryRelative],
374 };
375});
376
377export const WebsiteArtifactProvider = () =>
378 Provider.effect(
379 WebsiteArtifact,
380 Effect.gen(function* () {
381 const fs = yield* FileSystem.FileSystem;
382 const path = yield* Path.Path;
383 const dotAlchemy = yield* dotAlchemyDirectory;
384 return {
385 list: () => Effect.succeed([]),
386 // Traced dependencies can change independently of the framework's hash.
387 diff: () => Effect.succeed({ action: "update" as const }),
388 reconcile: Effect.fn(function* ({ id, news }) {
389 const name = yield* createPhysicalName({ id, maxLength: 80 });
390 const directory = path.join(dotAlchemy, "prisma-websites", name);
391 return yield* Effect.scoped(
392 Effect.gen(function* () {
393 const staged = yield* stageWebsiteArtifact(news);
394 const archive = yield* createComputeArchive({
395 ...staged,
396 output: "file",
397 });
398 return yield* Effect.gen(function* () {
399 yield* fs.makeDirectory(directory, { recursive: true });
400 const artifactPath = path.join(
401 directory,
402 `${archive.sha256}.tar.gz`,
403 );
404 yield* fs.copyFile(archive.path, artifactPath);
405 for (const old of yield* fs.readDirectory(directory)) {
406 if (
407 old !== path.basename(artifactPath) &&
408 /^[a-f0-9]{64}\.tar\.gz$/.test(old)
409 ) {
410 yield* fs.remove(path.join(directory, old), {
411 force: true,
412 });
413 }
414 }
415 return { directory, artifactPath, hash: archive.sha256 };
416 }).pipe(Effect.ensuring(archive.cleanup));
417 }),
418 );
419 }),
420 delete: Effect.fn(function* ({ output }) {
421 yield* fs.remove(output.directory, { recursive: true, force: true });
422 }),
423 };
424 }),
425 );