1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import type { PlatformError } from "effect/PlatformError";
5import * as Predicate from "effect/Predicate";
6import * as NodeCrypto from "node:crypto";
7import type { NodeServeEntryOptions } from "./NodeServe.ts";
8
9/**
10 * A single server module produced by a framework build.
11 *
12 * `name` is the module path relative to the build's `distDirectory`
13 * (e.g. `server/index.js`). Binary content is normalized to `Buffer` so it
14 * survives a JSON round-trip through `dist/build.json`.
15 */
16export interface OutputFile {
17 name: string;
18 content: string | Uint8Array;
19 hash: string;
20}
21
22/**
23 * The framework-integration build contract.
24 *
25 * Every framework integration (Vite, Waku, Astro, SvelteKit, Next.js, ...)
26 * produces this shape:
27 *
28 * - `clientDirectory` — the static-assets directory, captured as a path so
29 * files written after the bundler finishes (SSG HTML, prerendered pages)
30 * ride along.
31 * - `serverModules` — the worker modules, **entry first**, each with a sha256
32 * content hash. `undefined` for assets-only builds.
33 * - `externalWorkspaces` — workspace roots (directories containing a
34 * `package.json`) of modules imported from outside the project root, used
35 * for watch/memoization.
36 */
37export interface BuildOutput {
38 /** Root output directory of the build (e.g. `<root>/dist`). */
39 distDirectory?: string | undefined;
40 clientDirectory: string | undefined;
41 serverModules: Array<OutputFile> | undefined;
42 externalWorkspaces: Set<string>;
43 /** Portable handler and asset configuration, independent of a listening Node server. */
44 nodeServe?: NodeServeEntryOptions | undefined;
45}
46
47/** Create an {@link OutputFile}, hashing the content with sha256. */
48export const toOutputFile = (
49 name: string,
50 content: string | Uint8Array,
51): Effect.Effect<OutputFile> =>
52 Effect.sync(() => ({
53 name,
54 // Keep one binary representation across framework collectors.
55 content: typeof content === "string" ? content : Buffer.from(content),
56 hash: NodeCrypto.createHash("sha256").update(content).digest("hex"),
57 }));
58
59/**
60 * Sort server modules entry-first (the module named `entry` comes first, the
61 * rest sorted lexicographically) — the order the `BuildOutput` contract
62 * requires.
63 */
64export const sortServerModules = (
65 modules: Array<OutputFile>,
66 entry: string | undefined,
67): Array<OutputFile> =>
68 [...modules].sort((a, b) => {
69 if (a.name === entry) return -1;
70 if (b.name === entry) return 1;
71 return a.name.localeCompare(b.name);
72 });
73
74/**
75 * Serialize a {@link BuildOutput} for persistence (`dist/build.json`).
76 * Sets are serialized as sorted arrays; binary modules use base64 rather
77 * than expanding every byte into a JSON array element.
78 *
79 * @internal harness plumbing (the e2e harness's persistence mechanism), not
80 * part of the public framework-integration API.
81 */
82export const stringifyBuildOutput = (output: BuildOutput): string =>
83 JSON.stringify(
84 {
85 ...output,
86 serverModules: output.serverModules?.map((module) => ({
87 ...module,
88 content:
89 typeof module.content === "string"
90 ? module.content
91 : {
92 type: "Buffer",
93 encoding: "base64",
94 data: Buffer.from(module.content).toString("base64"),
95 },
96 })),
97 externalWorkspaces: Array.from(output.externalWorkspaces).sort(),
98 },
99 null,
100 2,
101 );
102
103/**
104 * Parse a persisted {@link BuildOutput}, reviving `Buffer` content and the
105 * `externalWorkspaces` Set (tolerating the legacy `{}` serialization).
106 *
107 * @internal harness plumbing (the e2e harness's persistence mechanism), not
108 * part of the public framework-integration API.
109 */
110export const parseBuildOutput = (content: string): BuildOutput => {
111 const parsed = JSON.parse(content) as BuildOutput & {
112 externalWorkspaces: unknown;
113 };
114 parsed.serverModules = parsed.serverModules?.map((module) => {
115 const value = module.content;
116 if (
117 Predicate.hasProperty(value, "type") &&
118 value.type === "Buffer" &&
119 Predicate.hasProperty(value, "data")
120 ) {
121 return {
122 ...module,
123 content:
124 typeof value.data === "string"
125 ? Buffer.from(value.data, "base64")
126 : Buffer.from(value.data as Array<number>),
127 };
128 }
129 return module;
130 });
131 parsed.externalWorkspaces = new Set(
132 Array.isArray(parsed.externalWorkspaces)
133 ? (parsed.externalWorkspaces as Array<string>)
134 : [],
135 );
136 return parsed as BuildOutput;
137};
138
139/**
140 * Persist a {@link BuildOutput} to disk (conventionally `dist/build.json`,
141 * so preview/serve flows stay uniform across frameworks).
142 *
143 * Creates the target's parent directory if it does not exist — with a nested
144 * project root (e.g. `fixtures/x/app`) the framework writes its own output to
145 * `app/dist`, so the harness-level `<cwd>/dist` may not exist yet.
146 *
147 * @internal harness plumbing (the e2e harness's persistence mechanism), not
148 * part of the public framework-integration API.
149 */
150export const writeBuildOutput = (
151 filePath: string,
152 output: BuildOutput,
153): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
154 Effect.gen(function* () {
155 const fs = yield* FileSystem.FileSystem;
156 const path = yield* Path.Path;
157 yield* fs.makeDirectory(path.dirname(filePath), { recursive: true });
158 yield* fs.writeFileString(filePath, stringifyBuildOutput(output));
159 });
160
161/**
162 * Read a persisted {@link BuildOutput} from disk.
163 *
164 * @internal harness plumbing (the e2e harness's persistence mechanism), not
165 * part of the public framework-integration API.
166 */
167export const readBuildOutput = (
168 path: string,
169): Effect.Effect<BuildOutput, PlatformError, FileSystem.FileSystem> =>
170 Effect.flatMap(FileSystem.FileSystem, (fs) =>
171 Effect.map(fs.readFileString(path), parseBuildOutput),
172 );