1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Stream from "effect/Stream";
6import type { PlatformError } from "effect/PlatformError";
7import { createRequire } from "node:module";
8import { createHash } from "node:crypto";
9import { gunzipSync } from "node:zlib";
10import * as HttpClient from "effect/http/HttpClient";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import { createPhysicalName } from "../../PhysicalName.ts";
13import { isResolved } from "../../Diff.ts";
14import * as Provider from "../../Provider.ts";
15import { Resource } from "../../Resource.ts";
16import { initialCwd } from "../../Util/Node.ts";
17import { sha256 } from "../../Util/sha256.ts";
18import { zipFiles, type ZipFile } from "../../Util/zip.ts";
19import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts";
20import { validateFunctionZip } from "../FunctionArtifact.ts";
21import { traceWebsiteFiles } from "./Trace.ts";
22import { packageWebsiteInChild } from "./Package.ts";
23import {
24 nativeArtifactError,
25 neonRuntimeTarget as runtimeTarget,
26} from "../NativeArtifact.ts";
27import { loadFrontendCore } from "../../Website/FrontendCore.ts";
28
29/** Build output staged after Website.Server finishes. */
30export interface WebsiteArtifactProps {
31 /** Application directory relative to the initial working directory. */
32 root: string;
33 /** Dedicated output directory, or the application root for Next.js. */
34 distDir: string;
35 /** Fetch entrypoint relative to the initial working directory. */
36 serverEntry?: string;
37 /** Static-file handler configuration, instead of a framework entrypoint. */
38 static?: {
39 /** Unmatched-path behavior. @default "none" */
40 notFoundHandling?: "none" | "spa" | "404-page";
41 /** Extensionless HTML behavior. @default "none" */
42 htmlHandling?: "none" | "drop-trailing-slash";
43 /** HTML file returned for misses, relative to output. @default "404.html" */
44 errorPage?: string;
45 };
46 /** Next.js includes .next, public assets, and configuration. */
47 layout?: "output" | "next";
48 /** Build dependency fingerprint; traced dependencies are also hashed. */
49 buildHash?: string;
50}
51
52/** Filesystem-only staging lifecycle; never owns cloud resources. */
53export interface WebsiteArtifact extends Resource<
54 "Neon.WebsiteArtifact",
55 WebsiteArtifactProps,
56 {
57 /** Private directory owned by this artifact resource. */
58 directory: string;
59 /** Validated, deterministic Function ZIP. */
60 artifactPath: string;
61 /** SHA-256 of the complete ZIP, including traced dependencies. */
62 hash: string;
63 }
64> {}
65
66export const WebsiteArtifact = Resource<WebsiteArtifact>(
67 "Neon.WebsiteArtifact",
68);
69
70/** Packaging failed before any Function deployment. */
71export class WebsiteArtifactError extends Data.TaggedError(
72 "WebsiteArtifactError",
73)<{
74 /** Sanitized explanation of the unsupported or unsafe artifact. */
75 message: string;
76}> {}
77
78const MAX_ENTRIES = 50_000;
79const MAX_BYTES = 250 * 1024 * 1024;
80const excluded = (file: string) =>
81 file
82 .split(/[\\/]/)
83 .some(
84 (part) =>
85 /^(?:\.git|\.alchemy|\.env.*|\.npmrc|\.yarnrc.*|\.netrc|\.pypirc|\.aws|\.ssh|credentials(?:\.json)?|service-account.*\.json|id_rsa|id_ed25519)$/.test(
86 part,
87 ) || /\.(?:pem|key|p12|pfx)$/.test(part),
88 );
89const sourceOnly = (file: string) => /(?:\.map|\.d\.[cm]?ts)$/.test(file);
90const native = (file: string) =>
91 /\.(?:node|dylib|dll|so(?:\.\d+)*)$/.test(file);
92const javascript = (file: string) => /\.[cm]?[jt]sx?$/.test(file);
93const nextExcluded = (relative: string) =>
94 /^\.next\/(?:cache|standalone|types|diagnostics|dev)(?:\/|$)/.test(
95 relative,
96 ) || /^\.next\/trace(?:-|$)/.test(relative);
97const fail = (message: string) =>
98 Effect.fail(new WebsiteArtifactError({ message }));
99
100const exactVersion = /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/;
101interface PackageManifest {
102 name?: string;
103 version?: string;
104 os?: string[];
105 cpu?: string[];
106 libc?: string[];
107 optionalDependencies?: Record<string, string>;
108}
109const compatible = (manifest: PackageManifest) =>
110 Object.entries(runtimeTarget).every(([key, value]) => {
111 const field = manifest[key as keyof typeof runtimeTarget];
112 return (
113 field === undefined ||
114 (Array.isArray(field) &&
115 field.every((item) => typeof item === "string") &&
116 !field.includes(`!${value}`) &&
117 (field.every((item) => item.startsWith("!")) || field.includes(value)))
118 );
119 });
120
121const validateBinary = (file: string, content: Uint8Array) => {
122 const message = nativeArtifactError(file, content);
123 if (message) throw new WebsiteArtifactError({ message });
124};
125
126/** Fetch only an exact platform package; never re-resolve the application's dependency graph. */
127const platformPackage = Effect.fn(
128 function* (name: string, version: string) {
129 if (
130 !/^@img\/sharp-(?:libvips-)?linux-arm64$/.test(name) ||
131 !exactVersion.test(version)
132 )
133 return yield* fail(
134 "Sharp must declare exact Linux ARM64 platform package versions.",
135 );
136 const client = (yield* HttpClient.HttpClient).pipe(
137 HttpClient.filterStatusOk,
138 );
139 const metadataResponse = yield* client.get(
140 `https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`,
141 );
142 const metadata = (yield* metadataResponse.json) as PackageManifest & {
143 dist?: { integrity?: string; tarball?: string };
144 };
145 if (
146 metadata.name !== name ||
147 metadata.version !== version ||
148 !compatible(metadata) ||
149 !metadata.dist?.integrity?.match(/^sha512-[A-Za-z0-9+/]+={0,2}$/)
150 )
151 return yield* fail(
152 `Invalid registry identity or integrity for ${name}@${version}.`,
153 );
154 const url = yield* Effect.try({
155 try: () => new URL(metadata.dist!.tarball!),
156 catch: () =>
157 new WebsiteArtifactError({
158 message: "Invalid platform package tarball URL.",
159 }),
160 });
161 if (
162 url.origin !== "https://registry.npmjs.org" ||
163 url.username ||
164 url.password ||
165 url.search ||
166 url.hash ||
167 url.pathname !== `/${name}/-/${name.split("/")[1]}-${version}.tgz`
168 )
169 return yield* fail(
170 "Platform packages must use the canonical npm registry tarball.",
171 );
172 const response = yield* client.get(url.href);
173 let compressedBytes = 0;
174 const chunks = yield* response.stream.pipe(
175 Stream.mapEffect((chunk) => {
176 compressedBytes += chunk.length;
177 return compressedBytes > MAX_BYTES
178 ? fail("Platform tarball exceeds its byte limit.")
179 : Effect.succeed(chunk);
180 }),
181 Stream.runCollect,
182 Effect.map((chunks) => [...chunks]),
183 );
184 const files = yield* Effect.try({
185 try: () => {
186 const compressed = Buffer.concat(chunks);
187 const integrity = `sha512-${createHash("sha512").update(compressed).digest("base64")}`;
188 if (integrity !== metadata.dist!.integrity)
189 throw new Error("Registry integrity mismatch");
190 const tar = gunzipSync(compressed, { maxOutputLength: MAX_BYTES });
191 const files = new Map<string, Uint8Array>();
192 const text = (start: number, size: number) =>
193 new TextDecoder()
194 .decode(tar.subarray(start, start + size))
195 .replace(/\0.*$/s, "");
196 let entries = 0;
197 for (let offset = 0; offset < tar.length;) {
198 if (++entries > MAX_ENTRIES) throw new Error("Too many tar entries");
199 if (tar.subarray(offset, offset + 512).every((byte) => byte === 0))
200 break;
201 if (offset + 512 > tar.length)
202 throw new Error("Truncated tar header");
203 const header = tar.subarray(offset, offset + 512);
204 const checksum = Number.parseInt(text(offset + 148, 8).trim(), 8);
205 const actual = header.reduce(
206 (sum, byte, index) =>
207 sum + (index >= 148 && index < 156 ? 32 : byte),
208 0,
209 );
210 if (checksum !== actual) throw new Error("Invalid tar checksum");
211 const prefix = text(offset + 345, 155);
212 const entry = `${prefix ? `${prefix}/` : ""}${text(offset, 100)}`;
213 const sizeText = text(offset + 124, 12).trim();
214 if (!/^[0-7]+$/.test(sizeText)) throw new Error("Invalid tar size");
215 const size = Number.parseInt(sizeText, 8);
216 const type = header[156];
217 if (
218 !entry.startsWith("package/") ||
219 entry.includes("\\") ||
220 entry
221 .replace(/\/$/, "")
222 .split("/")
223 .some((part) => !part || part === ".." || part === ".") ||
224 excluded(entry)
225 )
226 throw new Error("Unsafe tar path");
227 if (![0, 48, 53].includes(type!))
228 throw new Error("Non-regular tar entry");
229 if (
230 !Number.isSafeInteger(size) ||
231 size < 0 ||
232 offset + 512 + size > tar.length
233 )
234 throw new Error("Truncated tar entry");
235 if (type !== 53) {
236 const file = entry.slice(8);
237 if (!file || files.has(file) || files.size >= MAX_ENTRIES)
238 throw new Error("Invalid tar entries");
239 const content = tar.subarray(offset + 512, offset + 512 + size);
240 validateBinary(file, content);
241 files.set(file, content);
242 }
243 offset += 512 + Math.ceil(size / 512) * 512;
244 }
245 const manifest = JSON.parse(
246 Buffer.from(files.get("package.json")!).toString("utf8"),
247 ) as PackageManifest;
248 if (
249 manifest.name !== name ||
250 manifest.version !== version ||
251 !compatible(manifest)
252 )
253 throw new Error("Package identity or platform mismatch");
254 if (![...files.keys()].some(native))
255 throw new Error("Platform package contains no native library");
256 return files;
257 },
258 catch: () =>
259 new WebsiteArtifactError({
260 message: `Failed integrity, archive, or Linux ARM64 validation for ${name}@${version}.`,
261 }),
262 });
263 return files;
264 },
265 Effect.provide(FetchHttpClient.layer),
266 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
267 Effect.timeout("45 seconds"),
268);
269
270/**
271 * Preserve the traced module layout, materializing only selected symlink targets.
272 * Node 24 resolution hooks restore canonical package identity without ZIP links.
273 * The caller must archive the temporary directory before its Scope closes.
274 */
275export const stageWebsiteArtifact = Effect.fn(function* (
276 props: WebsiteArtifactProps,
277) {
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280 const root = yield* fs.realPath(path.resolve(initialCwd, props.root));
281 const dist = yield* fs.realPath(path.resolve(initialCwd, props.distDir));
282 const entry =
283 props.serverEntry === undefined
284 ? undefined
285 : yield* fs.realPath(path.resolve(initialCwd, props.serverEntry));
286 const within = (directory: string, file: string) => {
287 const relative = path.relative(directory, file);
288 return (
289 relative !== ".." &&
290 !relative.startsWith(`..${path.sep}`) &&
291 !path.isAbsolute(relative)
292 );
293 };
294 if (!entry && !props.static)
295 return yield* fail("A Fetch entrypoint or static options are required.");
296 if (props.layout !== "next" && root === dist)
297 return yield* fail(
298 "Output must be a dedicated directory, not the application root.",
299 );
300 if (!within(root, dist))
301 return yield* fail("Website output must be inside the application root.");
302 if (entry && !within(props.layout === "next" ? root : dist, entry))
303 return yield* fail("Fetch entrypoint escapes the build output.");
304 if (
305 props.static?.errorPage &&
306 (path.isAbsolute(props.static.errorPage) ||
307 !within(dist, path.resolve(dist, props.static.errorPage)))
308 )
309 return yield* fail("Error page escapes the build output.");
310 const standaloneConfig =
311 props.layout === "next" &&
312 entry !== undefined &&
313 (yield* fs.readFileString(entry)).includes(
314 "__NEXT_PRIVATE_STANDALONE_CONFIG",
315 );
316 const files = new Set<string>(entry ? [entry] : []);
317 const seeds = new Set<string>(entry ? [entry] : []);
318 const manifests: string[] = [];
319 let visited = 0;
320 const collect: (
321 source: string,
322 boundary?: string,
323 ancestors?: ReadonlySet<string>,
324 runtimePackage?: boolean,
325 ) => Effect.Effect<void, WebsiteArtifactError | PlatformError> = Effect.fn(
326 function* (
327 source,
328 boundary,
329 ancestors = new Set(),
330 runtimePackage = false,
331 ) {
332 if (++visited > MAX_ENTRIES)
333 return yield* fail("Website exceeds the 50,000-entry safety limit.");
334 const relative = path.relative(root, source).replaceAll("\\", "/");
335 if (
336 excluded(source) ||
337 sourceOnly(source) ||
338 (props.layout === "next" && nextExcluded(relative))
339 )
340 return;
341 const real = yield* fs.realPath(source);
342 if (excluded(real))
343 return yield* fail("An output symlink targets a sensitive file.");
344 if (!within(boundary ?? real, real))
345 return yield* fail("An output symlink escapes its selected directory.");
346 const stat = yield* fs.stat(real);
347 if (stat.type === "Directory") {
348 if (ancestors.has(real)) return yield* fail("Cyclic output symlink.");
349 const next = new Set([...ancestors, real]);
350 for (const name of (yield* fs.readDirectory(source)).sort()) {
351 if (name !== "node_modules")
352 yield* collect(
353 path.join(source, name),
354 boundary ?? real,
355 next,
356 runtimePackage,
357 );
358 }
359 } else if (stat.type === "File") {
360 files.add(source);
361 if (!runtimePackage && !props.static && javascript(source))
362 seeds.add(source);
363 if (props.layout === "next" && source.endsWith(".nft.json"))
364 manifests.push(source);
365 } else return yield* fail("Website output contains a non-regular file.");
366 },
367 );
368 const extras = yield* packSiteExtraFiles(
369 props.layout === "next" ? root : dist,
370 props.layout === "next" ? "next" : "client",
371 );
372 if (
373 props.layout === "next" &&
374 !(yield* fs.exists(path.join(root, ".next", "BUILD_ID")))
375 )
376 return yield* fail("Next.js output is missing .next/BUILD_ID.");
377 for (const extra of extras ?? []) {
378 if (standaloneConfig && /^next\.config\./.test(path.basename(extra.source)))
379 continue;
380 yield* collect(extra.source);
381 }
382 let nextPackageRoot: string | undefined;
383 if (props.layout === "next") {
384 for (const name of ["next.config.mts", "next.config.cts"]) {
385 const config = path.join(root, name);
386 if (!standaloneConfig && (yield* fs.exists(config)))
387 yield* collect(config);
388 }
389 const nextPackage = yield* Effect.try(() =>
390 createRequire(path.join(root, "package.json")).resolve(
391 "next/package.json",
392 ),
393 );
394 nextPackageRoot = path.dirname(nextPackage);
395 // Next's runtime alias tables are not statically evaluable by NFT.
396 yield* collect(nextPackageRoot, undefined, undefined, true);
397 }
398 for (const manifest of manifests) {
399 const text = yield* fs.readFileString(manifest);
400 const parsed = yield* Effect.try(
401 () => JSON.parse(text) as { files?: unknown },
402 );
403 if (
404 !Array.isArray(parsed.files) ||
405 !parsed.files.every((file): file is string => typeof file === "string")
406 )
407 return yield* fail("Invalid Next.js dependency manifest.");
408 for (const file of parsed.files) {
409 const source = path.resolve(path.dirname(manifest), file);
410 if (excluded(source))
411 return yield* fail("A Next.js trace selects a sensitive file.");
412 if (sourceOnly(source)) continue;
413 files.add(source);
414 }
415 }
416 let workspace = root;
417 for (let candidate = root; ; candidate = path.dirname(candidate)) {
418 if (yield* fs.exists(path.join(candidate, "pnpm-workspace.yaml"))) {
419 workspace = candidate;
420 break;
421 }
422 if (candidate === path.dirname(candidate)) break;
423 }
424 const traceBase = path.parse(root).root;
425 const tracedFiles =
426 seeds.size === 0
427 ? []
428 : yield* traceWebsiteFiles({
429 seeds: [...seeds],
430 base: traceBase,
431 root,
432 next: props.layout === "next",
433 }).pipe(
434 Effect.timeout("60 seconds"),
435 Effect.mapError(
436 () =>
437 new WebsiteArtifactError({
438 message:
439 "Runtime tracing failed. Node 24 and @vercel/nft are required in the deployment workspace.",
440 }),
441 ),
442 );
443 for (const file of tracedFiles)
444 if (!sourceOnly(file)) files.add(path.resolve(traceBase, file));
445 if (files.size > MAX_ENTRIES)
446 return yield* fail("Dependency trace exceeds the 50,000-entry limit.");
447 const selected = new Set<string>();
448 const links = new Map<string, string>();
449 const generated = new Map<string, Uint8Array>();
450 const replaced = new Set<string>();
451 const packageRoot = (file: string) => {
452 const parts = file.split(path.sep);
453 const index = parts.lastIndexOf("node_modules");
454 if (index < 0 || !parts[index + 1]) return undefined;
455 return parts
456 .slice(0, index + (parts[index + 1]!.startsWith("@") ? 3 : 2))
457 .join(path.sep);
458 };
459 const sharpRoots = new Set<string>();
460 for (const file of files) {
461 const real = yield* fs.realPath(file);
462 const pkg = packageRoot(real);
463 // Metadata-only trace entries do not load Sharp's native runtime.
464 if (pkg && path.basename(pkg) === "sharp" && javascript(real))
465 sharpRoots.add(pkg);
466 }
467 for (const sharpRoot of sharpRoots) {
468 if (!within(workspace, sharpRoot))
469 return yield* fail("Sharp escapes the application workspace.");
470 const installed = yield* fs.readFileString(
471 path.join(sharpRoot, "package.json"),
472 );
473 const sharp = yield* Effect.try(
474 () => JSON.parse(installed) as PackageManifest,
475 );
476 const packages = [
477 "@img/sharp-linux-arm64",
478 "@img/sharp-libvips-linux-arm64",
479 ];
480 for (const name of packages) {
481 const version = sharp.optionalDependencies?.[name];
482 if (!version || !exactVersion.test(version))
483 return yield* fail(
484 "This Sharp version does not declare exact Linux ARM64 prebuilt packages.",
485 );
486 const targetFiles = yield* platformPackage(name, version);
487 for (const [file, content] of targetFiles)
488 generated.set(path.join(path.dirname(sharpRoot), name, file), content);
489 }
490 // Only discard platform variants after their exact target replacements validated.
491 for (const name of Object.keys(sharp.optionalDependencies ?? {})) {
492 if (!name.startsWith("@img/sharp-")) continue;
493 const alias = path.join(path.dirname(sharpRoot), name);
494 replaced.add(alias);
495 if (yield* fs.exists(alias)) replaced.add(yield* fs.realPath(alias));
496 }
497 }
498 const isReplaced = (file: string) =>
499 [...replaced].some((pkg) => within(pkg, file));
500 for (const file of files) {
501 const real = yield* fs.realPath(file);
502 if (!within(workspace, file) || !within(workspace, real))
503 return yield* fail(
504 "A traced dependency escapes the application workspace.",
505 );
506 if (excluded(file) || excluded(real)) {
507 const sensitive = path
508 .relative(workspace, excluded(file) ? file : real)
509 .replaceAll("\\", "/")
510 .replace(/[^a-zA-Z0-9_./@+-]/g, "_");
511 return yield* fail(
512 `A traced dependency selects a sensitive file: ${sensitive}`,
513 );
514 }
515 if (isReplaced(file) || isReplaced(real)) continue;
516 const stat = yield* fs.stat(real);
517 if (stat.type === "File") selected.add(real);
518 else if (stat.type !== "Directory" || file === real)
519 return yield* fail("A traced dependency is not a regular file.");
520 if (file !== real) links.set(file, real);
521 }
522 if (nextPackageRoot)
523 links.set(path.join(root, "node_modules/next"), nextPackageRoot);
524 let base = root;
525 for (const file of [
526 ...selected,
527 ...generated.keys(),
528 ...links.keys(),
529 ...links.values(),
530 ])
531 while (!within(base, file)) base = path.dirname(base);
532 const directory = yield* fs.makeTempDirectoryScoped({
533 prefix: "alchemy-neon-website-",
534 });
535 const relative = (file: string) =>
536 path.join("files", path.relative(base, file)).replaceAll("\\", "/");
537 const staged = (file: string) => path.join(directory, relative(file));
538 let bytes = 0;
539 let entries = 0;
540 const copy = Effect.fn(function* (source: string, dest: string) {
541 if (yield* fs.exists(dest)) return;
542 const info = yield* fs.stat(source);
543 bytes += Number(info.size);
544 if (++entries > MAX_ENTRIES || bytes > MAX_BYTES)
545 return yield* fail(
546 "Materialized artifact exceeds its entry or expanded byte limit.",
547 );
548 const content = yield* fs.readFile(source);
549 yield* Effect.try({
550 try: () => validateBinary(path.relative(workspace, source), content),
551 catch: (error) =>
552 error instanceof WebsiteArtifactError
553 ? error
554 : new WebsiteArtifactError({ message: "Native validation failed." }),
555 });
556 if (native(source)) {
557 const pkg = packageRoot(source);
558 if (pkg && (yield* fs.exists(path.join(pkg, "package.json")))) {
559 const text = yield* fs.readFileString(path.join(pkg, "package.json"));
560 const manifest = yield* Effect.try(
561 () => JSON.parse(text) as PackageManifest,
562 );
563 if (!compatible(manifest))
564 return yield* fail("Native package excludes Linux ARM64 glibc.");
565 }
566 }
567 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
568 yield* fs.writeFile(dest, content);
569 yield* fs.chmod(dest, info.mode & 0o777);
570 });
571 for (const [file, content] of generated) {
572 bytes += content.length;
573 if (++entries > MAX_ENTRIES || bytes > MAX_BYTES)
574 return yield* fail(
575 "Materialized artifact exceeds its entry or expanded byte limit.",
576 );
577 yield* fs.makeDirectory(path.dirname(staged(file)), { recursive: true });
578 yield* fs.writeFile(staged(file), content);
579 }
580 const sorted = [...selected].sort();
581 for (const file of sorted) yield* copy(file, staged(file));
582 // Workspace packages and npm aliases cannot self-resolve from a canonical node_modules name.
583 for (const [alias, target] of links) {
584 const parts = alias.split(path.sep);
585 const index = parts.lastIndexOf("node_modules");
586 const name = parts.slice(index + 1).join(path.sep);
587 if (
588 index < 0 ||
589 target.endsWith(`${path.sep}node_modules${path.sep}${name}`)
590 )
591 continue;
592 if (selected.has(target)) yield* copy(target, staged(alias));
593 else
594 for (const file of sorted)
595 if (within(target, file))
596 yield* copy(
597 file,
598 path.join(staged(alias), path.relative(target, file)),
599 );
600 }
601 if (props.static) {
602 const { makeNeonServeEntrySource } = yield* loadFrontendCore;
603 let source = makeNeonServeEntrySource({
604 ...props.static,
605 clientDirExpression: `fileURLToPath(new URL(${JSON.stringify(`./${relative(dist)}/`)}, import.meta.url))`,
606 });
607 if (props.static.errorPage)
608 source = source.replace(
609 'lookup("/404.html")',
610 `lookup(${JSON.stringify(`/${props.static.errorPage}`)})`,
611 );
612 yield* fs.writeFileString(path.join(directory, "index.mjs"), source);
613 } else {
614 if (!entry) return yield* fail("Missing Fetch entrypoint.");
615 const aliases = [...links]
616 .map(([alias, target]) => [relative(alias), relative(target)])
617 .sort(([a], [b]) => b!.length - a!.length);
618 yield* fs.makeDirectory(staged(root), { recursive: true });
619 yield* fs.writeFileString(
620 path.join(directory, "index.mjs"),
621 [
622 'import { registerHooks, createRequire } from "node:module";',
623 'import { fileURLToPath } from "node:url";',
624 `const aliases = ${JSON.stringify(aliases)}.map(([from, to]) => [new URL(from, import.meta.url).href, new URL(to, import.meta.url).href]);`,
625 'const canonical = url => { for (const [from, to] of aliases) if (url === from || url.startsWith(from + "/")) return to + url.slice(from.length); return url; };',
626 "registerHooks({ resolve(specifier, context, next) {",
627 ' if (context.parentURL && !specifier.startsWith("node:") && !specifier.startsWith("#")) {',
628 ' if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:")) {',
629 " const url = new URL(specifier, context.parentURL).href; const mapped = canonical(url); if (mapped !== url) return next(mapped, context);",
630 ' } else if (!specifier.includes(":")) {',
631 ' const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");',
632 ' if (aliases.some(([, target]) => target.endsWith("/node_modules/" + name) && context.parentURL.startsWith(target + "/"))) return next(specifier, context);',
633 ' for (let dir = new URL(".", context.parentURL); ; dir = new URL("..", dir)) {',
634 ' const candidate = new URL("node_modules/" + name, dir).href;',
635 " const target = aliases.find(([from]) => from === candidate)?.[1];",
636 ' if (target?.endsWith("/node_modules/" + name)) { if (context.parentURL.startsWith(target + "/")) return next(specifier, context); const parentURL = target + "/__alchemy_resolve__.mjs"; return context.conditions.includes("require") ? next(createRequire(parentURL).resolve(specifier), context) : next(specifier, { ...context, parentURL }); }',
637 ' if (new URL("..", dir).href === dir.href) break;',
638 " }",
639 " }",
640 " }",
641 " const result = next(specifier, context); return { ...result, url: canonical(result.url) };",
642 "} });",
643 `process.chdir(fileURLToPath(new URL(${JSON.stringify(`./${relative(root)}/`)}, import.meta.url)));`,
644 `const entry = await import(new URL(${JSON.stringify(`./${relative(entry)}`)}, import.meta.url).href);`,
645 "export default entry.default;",
646 "",
647 ].join("\n"),
648 );
649 }
650 return { directory };
651});
652
653/** Package a staging tree deterministically after all dependency changes. */
654export const packageWebsiteArtifact = Effect.fn(function* (
655 props: WebsiteArtifactProps,
656) {
657 const fs = yield* FileSystem.FileSystem;
658 const path = yield* Path.Path;
659 const { directory } = yield* stageWebsiteArtifact(props);
660 const files: ZipFile[] = [];
661 const walk: (relative: string) => Effect.Effect<void, PlatformError> =
662 Effect.fn(function* (relative) {
663 for (const name of (yield* fs.readDirectory(
664 path.join(directory, relative),
665 )).sort()) {
666 const file = path.join(relative, name);
667 const absolute = path.join(directory, file);
668 const stat = yield* fs.stat(absolute);
669 if (stat.type === "Directory") yield* walk(file);
670 else
671 files.push({
672 path: file.replaceAll("\\", "/"),
673 content: yield* fs.readFile(absolute),
674 mode: 0o100000 | (stat.mode & 0o777),
675 });
676 }
677 });
678 yield* walk("");
679 const archive = yield* zipFiles(files);
680 yield* validateFunctionZip(archive);
681 return { archive, hash: yield* sha256(archive) };
682});
683
684export const WebsiteArtifactProvider = () =>
685 Provider.effect(
686 WebsiteArtifact,
687 Effect.gen(function* () {
688 const fs = yield* FileSystem.FileSystem;
689 const path = yield* Path.Path;
690 return {
691 list: () => Effect.succeed([]),
692 diff: Effect.fn(function* ({ news, output }) {
693 if (!isResolved(news) || !output)
694 return { action: "update" as const };
695 if (!(yield* fs.exists(output.artifactPath)))
696 return { action: "update" as const };
697 if (
698 (yield* sha256(yield* fs.readFile(output.artifactPath))) !==
699 output.hash
700 )
701 return { action: "update" as const };
702 // Dependencies outside the framework root can change without its build hash.
703 const { hash } = yield* packageWebsiteInChild(news).pipe(
704 Effect.scoped,
705 );
706 return {
707 action:
708 hash === output.hash ? ("noop" as const) : ("update" as const),
709 };
710 }),
711 reconcile: Effect.fn(function* ({ id, news }) {
712 const name = yield* createPhysicalName({ id, maxLength: 80 });
713 const directory = path.join(
714 initialCwd,
715 ".alchemy",
716 "neon-websites",
717 name,
718 );
719 const { archive, hash } = yield* packageWebsiteInChild(news).pipe(
720 Effect.scoped,
721 );
722 yield* fs.makeDirectory(directory, { recursive: true });
723 const artifactPath = path.join(directory, `${hash}.zip`);
724 yield* fs.writeFile(artifactPath, archive);
725 for (const old of yield* fs.readDirectory(directory))
726 if (/^[a-f0-9]{64}\.zip$/.test(old) && old !== `${hash}.zip`)
727 yield* fs.remove(path.join(directory, old), { force: true });
728 return { directory, artifactPath, hash };
729 }),
730 delete: Effect.fn(function* ({ output }) {
731 yield* fs.remove(output.directory, { recursive: true, force: true });
732 }),
733 };
734 }),
735 );