| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import type { PlatformError } from "effect/PlatformError"; |
| 5 | import * as Predicate from "effect/Predicate"; |
| 6 | import * as NodeCrypto from "node:crypto"; |
| 7 | import 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 | */ |
| 16 | export 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 | */ |
| 37 | export 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. */ |
| 48 | export 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 | */ |
| 64 | export 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 | */ |
| 82 | export 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 | */ |
| 110 | export 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 | */ |
| 150 | export 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 | */ |
| 167 | export 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 | ); |