1/**
2 * `@alchemy.run/frontend-frameworks/vinext/source` — alchemy Worker
3 * source provider for vinext. Implements the `WorkerSourceModule`
4 * contract structurally (no alchemy import).
5 *
6 * `build()` runs Vite's `createBuilder` in a child process with
7 * `@alchemy.run/cloudflare-runtime/vite` injected (the
8 * `vite-plugin-cloudflare:alchemy` presence plugin), then vinext prerender
9 * and a platform-agnostic cache seed (`putText` into KV HTTP).
10 * `hash()` is a project-tree memo. `dev()` is Vite's own server with
11 * the same plugin stack.
12 */
13import cloudflare from "@alchemy.run/cloudflare-runtime/vite";
14import type {
15 BindingHook,
16 BindingServices,
17 HyperdriveOrigin,
18 Assets as RuntimeAssets,
19 DurableObjectNamespace as RuntimeDurableObject,
20 QueueConsumer as RuntimeQueueConsumer,
21 RuntimeServices,
22} from "@alchemy.run/cloudflare-runtime/core";
23import type * as Context from "effect/Context";
24import * as Data from "effect/Data";
25import * as Effect from "effect/Effect";
26import * as FileSystem from "effect/FileSystem";
27import * as Path from "effect/Path";
28import type { PlatformError } from "effect/PlatformError";
29import * as Redacted from "effect/Redacted";
30import type * as Scope from "effect/Scope";
31import * as NodeCrypto from "node:crypto";
32import { createRequire } from "node:module";
33import nodePath from "node:path";
34import { pathToFileURL } from "node:url";
35import { resolveViteDevPort } from "../core/DevPort.ts";
36import { runBuildChild } from "../core/BuildChild.ts";
37import { toOutputFile, type BuildOutput } from "../core/BuildOutput.ts";
38import { ModuleLoadError } from "../core/Loader.ts";
39import {
40 ALCHEMY_CLOUDFLARE_VITE_INJECTED,
41 DEFAULT_WORKER_ENTRY,
42 makeVinextPluginOptions,
43} from "./cloudflare.ts";
44import {
45 VINEXT_CACHE_BINDING,
46 VINEXT_KV_CACHE_BINDING,
47} from "./PrerenderCache.ts";
48import {
49 resolveVinextRoot,
50 runVinextPrerenderIfConfigured,
51} from "./Prerender.ts";
52import { kvHttpNamespaceFromEnv, kvHttpSink } from "./cache/kv-http.ts";
53import { makeVinextCachePlugin } from "./cache/plugin.ts";
54import { seedPrerenderTo } from "./cache/seed.ts";
55
56const PROVIDER = "@alchemy.run/frontend-frameworks/vinext/source";
57
58const VINEXT_SERVER_ENTRIES = [
59 "dist/server/index.js",
60 "dist/server/ssr/index.js",
61] as const;
62
63const RSC_MANIFEST = {
64 "virtual:vite-rsc/assets-manifest": "__vite_rsc_assets_manifest.js",
65 "virtual:vite-rsc/environment-imports": "__vite_rsc_env_imports_manifest.js",
66} as const;
67
68const PACKAGE_VERSION: string = (
69 createRequire(import.meta.url)("../../package.json") as { version: string }
70).version;
71
72// ─────────────────────────────────────────────────────────────────────
73// Structural mirror of alchemy's Worker source contract
74// ─────────────────────────────────────────────────────────────────────
75
76export interface SourceHash {
77 readonly bundle: string | undefined;
78 readonly assets: string | undefined;
79 readonly input: string | undefined;
80 readonly additionalWorkspaces: Array<string> | undefined;
81}
82
83export interface BundleFile {
84 readonly path: string;
85 readonly content: string | Uint8Array;
86 readonly hash: string;
87}
88
89export interface BundleOutput {
90 readonly files: [BundleFile, ...Array<BundleFile>];
91 readonly hash: string;
92}
93
94export interface AssetReadResult {
95 directory: string;
96 config: Record<string, unknown> | undefined;
97 manifest: Record<string, { hash: string; size: number }>;
98 _headers: string | undefined;
99 _redirects: string | undefined;
100 hash: string;
101}
102
103export interface SourceBuildOutput {
104 readonly bundle: BundleOutput | undefined;
105 readonly assets: AssetReadResult | undefined;
106 readonly hash: SourceHash;
107}
108
109export interface SourceContext {
110 readonly id: string;
111 readonly workerName: string;
112 readonly compatibility: {
113 readonly date: string;
114 readonly flags: Array<string>;
115 };
116 readonly entry:
117 | { readonly kind: "external" }
118 | { readonly kind: "effect"; readonly exports: Record<string, unknown> };
119 readonly stack: { readonly name: string; readonly stage: string };
120 readonly env: Record<string, unknown> | undefined;
121 readonly extraOptions: unknown;
122 readonly assets: unknown;
123}
124
125export interface DevContext extends SourceContext {
126 readonly worker: {
127 readonly bindings: Array<BindingHook<BindingServices>>;
128 readonly durableObjectNamespaces: Array<
129 RuntimeDurableObject & { uniqueKey: string }
130 >;
131 readonly hyperdrives: Record<string, Required<HyperdriveOrigin>>;
132 readonly queueConsumers: Effect.Effect<Array<RuntimeQueueConsumer>>;
133 readonly assets: RuntimeAssets | undefined;
134 };
135 readonly runtimeContext: Context.Context<RuntimeServices>;
136}
137
138export interface ServerDevHandle {
139 readonly mode: "server";
140 readonly url: URL;
141}
142
143export class SourceProviderError extends Data.TaggedError(
144 "Cloudflare.Workers.SourceProviderError",
145)<{
146 readonly provider: string;
147 readonly message: string;
148 readonly cause?: unknown;
149}> {}
150
151export type VinextSourceError =
152 | SourceProviderError
153 | PlatformError
154 | ModuleLoadError;
155export type SourceRequirements = FileSystem.FileSystem | Path.Path;
156
157export interface SourceProvider {
158 readonly ownsAssets: boolean;
159 readonly build: (
160 ctx: SourceContext,
161 ) => Effect.Effect<SourceBuildOutput, VinextSourceError, SourceRequirements>;
162 readonly hash: (
163 ctx: SourceContext,
164 previous: SourceHash | undefined,
165 ) => Effect.Effect<
166 Partial<SourceHash>,
167 VinextSourceError,
168 SourceRequirements
169 >;
170 readonly dev: (
171 ctx: DevContext,
172 ) => Effect.Effect<
173 ServerDevHandle,
174 VinextSourceError,
175 SourceRequirements | Scope.Scope
176 >;
177}
178
179export interface VinextMemoOptions {
180 readonly include?: Array<string> | undefined;
181 readonly exclude?: Array<string> | undefined;
182 readonly lockfile?: boolean | undefined;
183}
184
185export interface VinextSourceOptions {
186 readonly rootDir?: string | undefined;
187 readonly main?: string | undefined;
188 readonly memo?: VinextMemoOptions | undefined;
189 readonly viteEnvironments?: {
190 readonly entry?: string;
191 readonly children?: ReadonlyArray<string>;
192 };
193}
194
195export interface VinextBuildChildConfig {
196 readonly rootDir: string;
197 readonly main: string | undefined;
198 readonly compatibilityDate: string;
199 readonly compatibilityFlags: Array<string>;
200 readonly viteEnvironments:
201 | { entry?: string; children?: ReadonlyArray<string> }
202 | undefined;
203}
204
205const sha256 = (input: string | Uint8Array): string =>
206 NodeCrypto.createHash("sha256").update(input).digest("hex");
207
208const stableValue = (value: unknown): unknown => {
209 if (Array.isArray(value)) return value.map(stableValue);
210 if (
211 value !== null &&
212 typeof value === "object" &&
213 value.constructor === Object
214 ) {
215 return Object.fromEntries(
216 Object.entries(value as Record<string, unknown>)
217 .sort(([a], [b]) => a.localeCompare(b))
218 .map(([key, nested]) => [key, stableValue(nested)]),
219 );
220 }
221 return value;
222};
223
224const sha256Object = (value: unknown): string =>
225 sha256(JSON.stringify(stableValue(value)));
226
227const isBindingMarker = (value: unknown): boolean =>
228 typeof value === "object" && value !== null && "~alchemy/Kind" in value;
229
230// ─────────────────────────────────────────────────────────────────────
231// Hash / assets (same contract as waku/source)
232// ─────────────────────────────────────────────────────────────────────
233
234const LOCKFILE_NAMES = [
235 "bun.lock",
236 "bun.lockb",
237 "package-lock.json",
238 "pnpm-lock.yaml",
239 "yarn.lock",
240];
241
242const escapeRegExpChar = (char: string): string =>
243 /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
244
245const globBodyToRegExpSource = (glob: string): string => {
246 let source = "";
247 let i = 0;
248 while (i < glob.length) {
249 const char = glob[i]!;
250 if (char === "*") {
251 if (glob[i + 1] === "*") {
252 if (glob[i + 2] === "/") {
253 source += "(?:[^/]+/)*";
254 i += 3;
255 } else {
256 source += ".*";
257 i += 2;
258 }
259 } else {
260 source += "[^/]*";
261 i += 1;
262 }
263 } else if (char === "?") {
264 source += "[^/]";
265 i += 1;
266 } else {
267 source += escapeRegExpChar(char);
268 i += 1;
269 }
270 }
271 return source;
272};
273
274const compileIgnoreRule = (raw: string): RegExp | undefined => {
275 let rule = raw.trim();
276 if (rule.length === 0 || rule.startsWith("#") || rule.startsWith("!")) {
277 return undefined;
278 }
279 if (rule.endsWith("/")) rule = rule.slice(0, -1);
280 if (rule.length === 0) return undefined;
281 const anchored = rule.includes("/");
282 if (rule.startsWith("/")) rule = rule.slice(1);
283 const body = globBodyToRegExpSource(rule);
284 const prefix = anchored ? "^" : "^(?:.*/)?";
285 return new RegExp(`${prefix}${body}(?:/.*)?$`);
286};
287
288const compileIgnoreRules = (rules: ReadonlyArray<string>): Array<RegExp> =>
289 rules
290 .map(compileIgnoreRule)
291 .filter((regex): regex is RegExp => regex !== undefined);
292
293const compileIncludeGlobs = (globs: ReadonlyArray<string>): Array<RegExp> =>
294 globs.map((glob) => new RegExp(`^${globBodyToRegExpSource(glob)}$`));
295
296const matchesAny = (regexes: ReadonlyArray<RegExp>, path: string): boolean =>
297 regexes.some((regex) => regex.test(path));
298
299const findUp = Effect.fn(function* (
300 startDir: string,
301 filenames: ReadonlyArray<string>,
302) {
303 const fs = yield* FileSystem.FileSystem;
304 const path = yield* Path.Path;
305 let dir = startDir;
306 for (;;) {
307 for (const filename of filenames) {
308 const candidate = path.join(dir, filename);
309 if (yield* fs.exists(candidate)) return candidate;
310 }
311 const parent = path.dirname(dir);
312 if (parent === dir) return undefined;
313 dir = parent;
314 }
315});
316
317const readGitIgnoreRules = Effect.fn(function* (rootDir: string) {
318 const fs = yield* FileSystem.FileSystem;
319 const path = yield* Path.Path;
320 const chain: Array<Array<string>> = [];
321 let dir = rootDir;
322 for (;;) {
323 const gitignore = path.join(dir, ".gitignore");
324 if (yield* fs.exists(gitignore)) {
325 chain.unshift((yield* fs.readFileString(gitignore)).split("\n"));
326 }
327 if (yield* fs.exists(path.join(dir, ".git"))) break;
328 const parent = path.dirname(dir);
329 if (parent === dir) break;
330 dir = parent;
331 }
332 return chain.flat();
333});
334
335interface ResolvedMemo {
336 readonly include: Array<RegExp> | undefined;
337 readonly exclude: Array<RegExp>;
338 readonly lockfile: boolean;
339}
340
341const resolveMemo = Effect.fn(function* (
342 rootDir: string,
343 distDir: string,
344 memo: VinextMemoOptions | undefined,
345) {
346 const exclude =
347 memo?.exclude !== undefined
348 ? compileIgnoreRules(memo.exclude)
349 : compileIgnoreRules(yield* readGitIgnoreRules(rootDir));
350 return {
351 include:
352 memo?.include !== undefined
353 ? compileIncludeGlobs(memo.include)
354 : undefined,
355 exclude: [
356 ...compileIgnoreRules(["node_modules", ".git", `/${distDir}`]),
357 ...exclude,
358 ],
359 lockfile: memo?.lockfile ?? !(memo?.include || memo?.exclude),
360 } satisfies ResolvedMemo;
361});
362
363const hashDirectory = Effect.fn(function* (
364 rootDir: string,
365 memo: ResolvedMemo,
366) {
367 const fs = yield* FileSystem.FileSystem;
368 const path = yield* Path.Path;
369 const files: Array<string> = [];
370 const walk: (dir: string, rel: string) => Effect.Effect<void, PlatformError> =
371 Effect.fn(function* (dir: string, rel: string) {
372 const entries = (yield* fs.readDirectory(dir)).sort();
373 for (const entry of entries) {
374 const relPath = rel === "" ? entry : `${rel}/${entry}`;
375 if (matchesAny(memo.exclude, relPath)) continue;
376 const absPath = path.join(dir, entry);
377 const stat = yield* fs.stat(absPath);
378 if (stat.type === "Directory") {
379 yield* walk(absPath, relPath);
380 } else if (stat.type === "File") {
381 if (
382 memo.include !== undefined &&
383 !matchesAny(memo.include, relPath)
384 ) {
385 continue;
386 }
387 files.push(relPath);
388 }
389 }
390 });
391 yield* walk(rootDir, "");
392 if (memo.lockfile) {
393 const lockfile = yield* findUp(rootDir, LOCKFILE_NAMES);
394 if (lockfile !== undefined) {
395 const relative = path.relative(rootDir, lockfile).replaceAll("\\", "/");
396 if (!files.includes(relative)) files.push(relative);
397 }
398 }
399 const hashes = yield* Effect.forEach(
400 files.sort(),
401 Effect.fn(function* (file) {
402 return `${file}:${sha256(yield* fs.readFile(path.join(rootDir, file)))}`;
403 }),
404 { concurrency: 16 },
405 );
406 return sha256Object(hashes);
407});
408
409const hashVinextInput = Effect.fn(function* (params: {
410 rootDir: string;
411 memo: VinextMemoOptions | undefined;
412 options: VinextSourceOptions;
413 workspaces: Iterable<string>;
414}) {
415 const path = yield* Path.Path;
416 const memo = yield* resolveMemo(params.rootDir, "dist", params.memo);
417 const workspaceMemo = yield* resolveMemo(params.rootDir, "dist", undefined);
418 const [root, ...workspaceHashes] = yield* Effect.all(
419 [
420 hashDirectory(params.rootDir, memo),
421 ...Array.from(params.workspaces).map((workspace) =>
422 Effect.gen(function* () {
423 const resolved = path.resolve(params.rootDir, workspace);
424 const rules = yield* readGitIgnoreRules(resolved);
425 const hash = yield* hashDirectory(resolved, {
426 ...workspaceMemo,
427 exclude: [...workspaceMemo.exclude, ...compileIgnoreRules(rules)],
428 });
429 return `${path.relative(params.rootDir, resolved).replaceAll("\\", "/")}:${hash}`;
430 }),
431 ),
432 ],
433 { concurrency: 4 },
434 );
435 return {
436 hash: sha256Object({
437 version: PACKAGE_VERSION,
438 root,
439 workspaces: workspaceHashes.sort(),
440 options: {
441 main: params.options.main,
442 viteEnvironments: params.options.viteEnvironments,
443 },
444 }),
445 workspaces: Array.from(params.workspaces).map((workspace) =>
446 path
447 .relative(params.rootDir, path.resolve(params.rootDir, workspace))
448 .replaceAll("\\", "/"),
449 ),
450 };
451});
452
453const SPECIAL_ASSET_FILES = [".assetsignore", "_headers", "_redirects"];
454
455const maybeReadString = Effect.fn(function* (file: string) {
456 const fs = yield* FileSystem.FileSystem;
457 return yield* fs.readFileString(file).pipe(
458 Effect.catchIf(
459 (error) =>
460 error._tag === "PlatformError" && error.reason._tag === "NotFound",
461 () => Effect.succeed(undefined),
462 ),
463 );
464});
465
466const readClientAssets = Effect.fn(function* (
467 directory: string,
468 config: Record<string, unknown> | undefined,
469) {
470 const fs = yield* FileSystem.FileSystem;
471 const path = yield* Path.Path;
472 const [entries, ignoreFile, _headers, _redirects] = yield* Effect.all([
473 fs.readDirectory(directory, { recursive: true }),
474 maybeReadString(path.join(directory, ".assetsignore")),
475 maybeReadString(path.join(directory, "_headers")),
476 maybeReadString(path.join(directory, "_redirects")),
477 ]);
478 const ignores = compileIgnoreRules([
479 ...SPECIAL_ASSET_FILES,
480 ...(ignoreFile?.split("\n") ?? []),
481 ]);
482 const manifest = new Map<string, { hash: string; size: number }>();
483 yield* Effect.forEach(
484 entries,
485 Effect.fn(function* (name) {
486 const relPath = name.replaceAll("\\", "/");
487 if (matchesAny(ignores, relPath)) return;
488 const file = path.join(directory, name);
489 const stat = yield* fs.stat(file);
490 if (stat.type !== "File") return;
491 const content = yield* fs.readFile(file);
492 manifest.set(relPath.startsWith("/") ? relPath : `/${relPath}`, {
493 hash: sha256(content).slice(0, 32),
494 size: Number(stat.size),
495 });
496 }),
497 { concurrency: 16 },
498 );
499 const sortedManifest = Object.fromEntries(
500 Array.from(manifest.entries()).sort((a, b) => a[0].localeCompare(b[0])),
501 );
502 return {
503 directory,
504 config,
505 manifest: sortedManifest,
506 _headers,
507 _redirects,
508 hash: sha256Object({
509 config,
510 manifest: sortedManifest,
511 _headers,
512 _redirects,
513 }),
514 } satisfies AssetReadResult;
515});
516
517const assetsConfigOf = (
518 assets: unknown,
519): Record<string, unknown> | undefined => {
520 if (assets === undefined || assets === null || typeof assets !== "object") {
521 return undefined;
522 }
523 const {
524 directory: _d,
525 hash: _h,
526 ...config
527 } = assets as Record<string, unknown>;
528 return Object.keys(config).length > 0 ? config : undefined;
529};
530
531// ─────────────────────────────────────────────────────────────────────
532// Vite createBuilder + output collection
533// ─────────────────────────────────────────────────────────────────────
534
535type ViteModule = typeof import("vite");
536
537const loadVite = async (projectRoot: string): Promise<ViteModule> => {
538 try {
539 const require = createRequire(nodePath.join(projectRoot, "package.json"));
540 return await import(
541 /* @vite-ignore */ pathToFileURL(require.resolve("vite")).href
542 );
543 } catch {
544 return await import("vite");
545 }
546};
547
548const getDefine = (env: Record<string, unknown>) =>
549 Object.fromEntries(
550 Object.entries(env).flatMap(([key, raw]) => {
551 if (!key.startsWith("VITE_")) return [];
552 const value = Redacted.isRedacted(raw) ? Redacted.value(raw) : raw;
553 return [[`import.meta.env.${key}`, JSON.stringify(value)] as const];
554 }),
555 );
556
557const relativeJsImports = (code: string): readonly string[] => {
558 const specs = new Set<string>();
559 for (const match of code.matchAll(
560 /(?:from|import)\s*["'](\.\.?\/[^"']+\.js)["']/g,
561 )) {
562 specs.add(match[1]!);
563 }
564 return [...specs];
565};
566
567interface EnvironmentLike {
568 readonly name: string;
569 readonly config: {
570 readonly base: string;
571 readonly root: string;
572 readonly build: { readonly outDir: string };
573 };
574}
575
576const makeOutputCollector = (entryEnvironment: string) => {
577 let clientDirectory: string | undefined;
578 let serverEntry: string | undefined;
579 const serverChunks = new Map<string, { content: string | Uint8Array }>();
580 const maybeExternalWorkspaces = new Set<string>();
581
582 const fileName = (name: string, environment: EnvironmentLike) => {
583 const outDir = environment.config.build.outDir;
584 const relativeOutDir = nodePath.isAbsolute(outDir)
585 ? nodePath.relative(environment.config.root, outDir)
586 : outDir;
587 return `${relativeOutDir.replaceAll("\\", "/")}/${name}`;
588 };
589
590 const plugin = {
591 name: "alchemy:vinext-build-output",
592 sharedDuringBuild: true,
593 async writeBundle(
594 this: {
595 environment: EnvironmentLike;
596 getModuleIds: () => Iterable<string>;
597 },
598 _opts: unknown,
599 bundle: Record<
600 string,
601 {
602 type: string;
603 fileName: string;
604 isEntry?: boolean;
605 code?: string;
606 source?: string | Uint8Array;
607 imports?: Array<string>;
608 }
609 >,
610 ) {
611 const root = nodePath.resolve(this.environment.config.root);
612 for (const id of this.getModuleIds()) {
613 if (
614 !nodePath.isAbsolute(id) ||
615 id.includes("node_modules") ||
616 id.startsWith(root)
617 ) {
618 continue;
619 }
620 maybeExternalWorkspaces.add(nodePath.dirname(id));
621 }
622 if (this.environment.name === "client") {
623 clientDirectory = nodePath.resolve(
624 root,
625 this.environment.config.build.outDir,
626 );
627 return;
628 }
629 const files = Object.values(bundle);
630 if (this.environment.name === entryEnvironment) {
631 const entryChunk = files.find(
632 (file) => file.type === "chunk" && file.isEntry,
633 );
634 if (entryChunk) {
635 serverEntry = fileName(entryChunk.fileName, this.environment);
636 }
637 }
638 for (const file of files) {
639 if (file.type === "chunk") {
640 for (const spec of file.imports ?? []) {
641 if (spec in RSC_MANIFEST) {
642 const manifest = RSC_MANIFEST[spec as keyof typeof RSC_MANIFEST];
643 serverChunks.set(fileName(manifest, this.environment), {
644 content: "",
645 });
646 }
647 }
648 }
649 const name = fileName(file.fileName, this.environment);
650 const content =
651 file.type === "chunk" ? (file.code ?? "") : (file.source ?? "");
652 serverChunks.set(name, { content });
653 }
654 },
655 };
656
657 return {
658 plugin,
659 snapshot: () => ({
660 clientDirectory,
661 serverEntry,
662 serverChunks,
663 externalWorkspaces: maybeExternalWorkspaces,
664 }),
665 };
666};
667
668const readRscManifestsFromDisk = Effect.fn(function* (
669 root: string,
670 chunks: Map<string, { content: string | Uint8Array }>,
671) {
672 const fs = yield* FileSystem.FileSystem;
673 const path = yield* Path.Path;
674 for (const [name, chunk] of chunks) {
675 if (chunk.content !== "") continue;
676 const filePath = path.join(root, name);
677 if (yield* fs.exists(filePath)) {
678 chunks.set(name, { content: yield* fs.readFile(filePath) });
679 } else {
680 chunks.delete(name);
681 }
682 }
683});
684
685const syncVinextServerModules = Effect.fn(function* (
686 root: string,
687 chunks: Map<string, { content: string | Uint8Array }>,
688) {
689 const fs = yield* FileSystem.FileSystem;
690 const path = yield* Path.Path;
691 const pending: string[] = [...VINEXT_SERVER_ENTRIES];
692 const seen = new Set<string>();
693 while (pending.length > 0) {
694 const bundlePath = pending.pop()!;
695 if (seen.has(bundlePath)) continue;
696 seen.add(bundlePath);
697 const filePath = path.join(root, bundlePath);
698 if (!(yield* fs.exists(filePath))) continue;
699 const content = yield* fs.readFileString(filePath);
700 chunks.set(bundlePath, { content });
701 const dir = nodePath.posix.dirname(bundlePath);
702 for (const spec of relativeJsImports(content)) {
703 const next = nodePath.posix.normalize(nodePath.posix.join(dir, spec));
704 if (!next.startsWith("dist/server/") || chunks.has(next)) continue;
705 pending.push(next);
706 }
707 }
708});
709
710const collectExternalWorkspaces = Effect.fn(function* (dirs: Iterable<string>) {
711 const path = yield* Path.Path;
712 const found = yield* Effect.forEach(
713 dirs,
714 (directory) =>
715 findUp(directory, ["package.json"]).pipe(
716 Effect.map((file) =>
717 file !== undefined ? path.dirname(file) : undefined,
718 ),
719 ),
720 { concurrency: "unbounded" },
721 );
722 return new Set(found.filter((dir): dir is string => dir !== undefined));
723});
724
725/**
726 * Child-process vinext production build. `runBuildChild` sets cwd to the
727 * project root before importing this module.
728 */
729export const buildInChild = (config: VinextBuildChildConfig) =>
730 Effect.gen(function* () {
731 const path = yield* Path.Path;
732 const root = path.resolve(config.rootDir);
733 process.env[ALCHEMY_CLOUDFLARE_VITE_INJECTED] = "1";
734 const cachePlugin = yield* makeVinextCachePlugin(root, "kv");
735 const pluginOptions = makeVinextPluginOptions({
736 root,
737 main: config.main,
738 compatibilityDate: config.compatibilityDate,
739 compatibilityFlags: config.compatibilityFlags,
740 viteEnvironments: config.viteEnvironments,
741 });
742 const collector = makeOutputCollector(
743 pluginOptions.viteEnvironments?.entry ?? "rsc",
744 );
745 const vite = yield* Effect.promise(() => loadVite(root));
746 yield* Effect.promise(async () => {
747 const builder = await vite.createBuilder(
748 {
749 root,
750 plugins: [
751 cachePlugin,
752 cloudflare(pluginOptions),
753 collector.plugin as never,
754 ],
755 logLevel: "warn",
756 },
757 null,
758 );
759 await builder.buildApp();
760 });
761 yield* runVinextPrerenderIfConfigured(root);
762 const snap = collector.snapshot();
763 yield* readRscManifestsFromDisk(root, snap.serverChunks);
764 yield* syncVinextServerModules(root, snap.serverChunks);
765 if (snap.serverChunks.size === 0) {
766 return {
767 distDirectory: path.join(root, "dist"),
768 clientDirectory: snap.clientDirectory,
769 serverModules: undefined,
770 externalWorkspaces: yield* collectExternalWorkspaces(
771 snap.externalWorkspaces,
772 ),
773 } satisfies BuildOutput;
774 }
775 const names = Array.from(snap.serverChunks.keys()).sort((a, b) => {
776 if (a === snap.serverEntry) return -1;
777 if (b === snap.serverEntry) return 1;
778 return a.localeCompare(b);
779 });
780 const serverModules = yield* Effect.forEach(names, (name) => {
781 const chunk = snap.serverChunks.get(name)!;
782 return toOutputFile(name, chunk.content);
783 });
784 return {
785 distDirectory: path.join(root, "dist"),
786 clientDirectory: snap.clientDirectory,
787 serverModules,
788 externalWorkspaces: yield* collectExternalWorkspaces(
789 snap.externalWorkspaces,
790 ),
791 } satisfies BuildOutput;
792 });
793
794const resolveVinextCacheEnv = (env: Record<string, unknown>) =>
795 Effect.gen(function* () {
796 const resolved = { ...env };
797 for (const key of [VINEXT_KV_CACHE_BINDING, VINEXT_CACHE_BINDING]) {
798 const value = env[key];
799 if (value == null || isBindingMarker(value)) continue;
800 resolved[key] = Effect.isEffect(value)
801 ? yield* value as Effect.Effect<unknown>
802 : value;
803 }
804 return resolved;
805 });
806
807/**
808 * Same `seedPrerenderTo(sink.putText)` as Redis / S3. The Worker provider
809 * layer supplies the distilled KV HTTP client. Closed as `Effect<void>`
810 * so the source contract stays `SourceRequirements`.
811 */
812const seedVinextPrerenderCache = (
813 rootDir: string,
814 env: Record<string, unknown>,
815): Effect.Effect<void> =>
816 Effect.gen(function* () {
817 const namespace = kvHttpNamespaceFromEnv(env);
818 if (namespace === undefined) return;
819 const path = yield* Path.Path;
820 yield* seedPrerenderTo(kvHttpSink(namespace), {
821 rootDir: path.resolve(rootDir),
822 label: "Cloudflare KV",
823 }).pipe(
824 Effect.mapError(
825 (cause) =>
826 new SourceProviderError({
827 provider: PROVIDER,
828 message: "Failed to seed the vinext prerender cache.",
829 cause,
830 }),
831 ),
832 );
833 }) as Effect.Effect<void>;
834
835export const makeVinextSourceProvider = (
836 options: VinextSourceOptions,
837): SourceProvider => {
838 const rootDirOf = (path: Path.Path): string =>
839 options.rootDir !== undefined
840 ? path.resolve(options.rootDir)
841 : process.cwd();
842
843 return {
844 ownsAssets: true,
845 build: Effect.fn(function* (ctx) {
846 const path = yield* Path.Path;
847 const rootDir = rootDirOf(path);
848 const output = yield* runBuildChild({
849 runtime: "node",
850 module: import.meta.url,
851 rootDir,
852 framework: "vinext",
853 config: {
854 rootDir,
855 main: options.main,
856 compatibilityDate: ctx.compatibility.date,
857 compatibilityFlags: ctx.compatibility.flags,
858 viteEnvironments: options.viteEnvironments,
859 } satisfies VinextBuildChildConfig,
860 }).pipe(
861 Effect.mapError(
862 (error) =>
863 new SourceProviderError({
864 provider: PROVIDER,
865 message: "vinext build failed",
866 cause: error.cause ?? error,
867 }),
868 ),
869 );
870 if (
871 output.serverModules === undefined ||
872 output.serverModules.length === 0
873 ) {
874 return yield* Effect.fail(
875 new SourceProviderError({
876 provider: PROVIDER,
877 message: "vinext build produced no server modules",
878 }),
879 );
880 }
881 const files = output.serverModules.map((file) => ({
882 path: file.name.replaceAll("\\", "/"),
883 content: file.content,
884 hash: file.hash,
885 })) as [BundleFile, ...Array<BundleFile>];
886 const bundle: BundleOutput = {
887 files,
888 hash: sha256Object(files.map((file) => [file.path, file.hash])),
889 };
890 const [assets, input] = yield* Effect.all(
891 [
892 output.clientDirectory !== undefined
893 ? readClientAssets(
894 output.clientDirectory,
895 assetsConfigOf(ctx.assets),
896 )
897 : Effect.succeed(undefined),
898 hashVinextInput({
899 rootDir,
900 memo: options.memo,
901 options,
902 workspaces: output.externalWorkspaces,
903 }),
904 ],
905 { concurrency: "unbounded" },
906 );
907 yield* seedVinextPrerenderCache(
908 rootDir,
909 yield* resolveVinextCacheEnv(ctx.env ?? {}),
910 );
911 return {
912 bundle,
913 assets,
914 hash: {
915 bundle: bundle.hash,
916 assets: assets?.hash,
917 input: input.hash,
918 additionalWorkspaces: input.workspaces,
919 },
920 };
921 }),
922 hash: Effect.fn(function* (_ctx, previous) {
923 const path = yield* Path.Path;
924 const input = yield* hashVinextInput({
925 rootDir: rootDirOf(path),
926 memo: options.memo,
927 options,
928 workspaces: previous?.additionalWorkspaces ?? [],
929 });
930 return { input: input.hash, additionalWorkspaces: input.workspaces };
931 }),
932 dev: Effect.fn(function* (ctx) {
933 const path = yield* Path.Path;
934 const rootDir = rootDirOf(path);
935 process.env[ALCHEMY_CLOUDFLARE_VITE_INJECTED] = "1";
936 const cachePlugin = yield* makeVinextCachePlugin(rootDir, "kv");
937 const vite = yield* Effect.promise(() => loadVite(rootDir));
938 const port = yield* resolveViteDevPort(vite.version).pipe(
939 Effect.mapError(
940 (error) =>
941 new SourceProviderError({
942 provider: PROVIDER,
943 message: "Failed to allocate a vinext dev port",
944 cause: error,
945 }),
946 ),
947 );
948 const pluginOptions = makeVinextPluginOptions({
949 root: rootDir,
950 main: options.main ?? DEFAULT_WORKER_ENTRY,
951 compatibilityDate: ctx.compatibility.date,
952 compatibilityFlags: ctx.compatibility.flags,
953 viteEnvironments: options.viteEnvironments,
954 worker: {
955 name: ctx.workerName,
956 bindings: ctx.worker.bindings,
957 durableObjectNamespaces: ctx.worker.durableObjectNamespaces,
958 hyperdrives: ctx.worker.hyperdrives,
959 queueConsumers: yield* ctx.worker.queueConsumers,
960 assets: ctx.worker.assets,
961 },
962 context: ctx.runtimeContext,
963 });
964 const server = yield* Effect.acquireRelease(
965 Effect.promise(async () => {
966 const devServer = await vite.createServer({
967 root: rootDir,
968 define: getDefine(ctx.env ?? {}),
969 plugins: [cachePlugin, cloudflare(pluginOptions)],
970 server: { port, strictPort: false },
971 });
972 await devServer.listen();
973 return devServer;
974 }),
975 (devServer) => Effect.promise(() => devServer.close()),
976 );
977 const local = server.resolvedUrls?.local[0];
978 if (!local) {
979 return yield* Effect.fail(
980 new SourceProviderError({
981 provider: PROVIDER,
982 message: "vinext dev server started without a local URL",
983 }),
984 );
985 }
986 return { mode: "server", url: new URL(local) } satisfies ServerDevHandle;
987 }),
988 };
989};
990
991const sourceModule = {
992 make: (
993 options: unknown,
994 ): Effect.Effect<SourceProvider, SourceProviderError> => {
995 if (
996 options !== undefined &&
997 (typeof options !== "object" || options === null)
998 ) {
999 return Effect.fail(
1000 new SourceProviderError({
1001 provider: PROVIDER,
1002 message: `Invalid options for ${PROVIDER}: expected an object, got ${typeof options}`,
1003 }),
1004 );
1005 }
1006 return Effect.succeed(
1007 makeVinextSourceProvider((options ?? {}) as VinextSourceOptions),
1008 );
1009 },
1010};
1011
1012export default sourceModule;