| 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 | */ |
| 13 | import cloudflare from "@alchemy.run/cloudflare-runtime/vite"; |
| 14 | import 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"; |
| 23 | import type * as Context from "effect/Context"; |
| 24 | import * as Data from "effect/Data"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Path from "effect/Path"; |
| 28 | import type { PlatformError } from "effect/PlatformError"; |
| 29 | import * as Redacted from "effect/Redacted"; |
| 30 | import type * as Scope from "effect/Scope"; |
| 31 | import * as NodeCrypto from "node:crypto"; |
| 32 | import { createRequire } from "node:module"; |
| 33 | import nodePath from "node:path"; |
| 34 | import { pathToFileURL } from "node:url"; |
| 35 | import { resolveViteDevPort } from "../core/DevPort.ts"; |
| 36 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 37 | import { toOutputFile, type BuildOutput } from "../core/BuildOutput.ts"; |
| 38 | import { ModuleLoadError } from "../core/Loader.ts"; |
| 39 | import { |
| 40 | ALCHEMY_CLOUDFLARE_VITE_INJECTED, |
| 41 | DEFAULT_WORKER_ENTRY, |
| 42 | makeVinextPluginOptions, |
| 43 | } from "./cloudflare.ts"; |
| 44 | import { |
| 45 | VINEXT_CACHE_BINDING, |
| 46 | VINEXT_KV_CACHE_BINDING, |
| 47 | } from "./PrerenderCache.ts"; |
| 48 | import { |
| 49 | resolveVinextRoot, |
| 50 | runVinextPrerenderIfConfigured, |
| 51 | } from "./Prerender.ts"; |
| 52 | import { kvHttpNamespaceFromEnv, kvHttpSink } from "./cache/kv-http.ts"; |
| 53 | import { makeVinextCachePlugin } from "./cache/plugin.ts"; |
| 54 | import { seedPrerenderTo } from "./cache/seed.ts"; |
| 55 | |
| 56 | const PROVIDER = "@alchemy.run/frontend-frameworks/vinext/source"; |
| 57 | |
| 58 | const VINEXT_SERVER_ENTRIES = [ |
| 59 | "dist/server/index.js", |
| 60 | "dist/server/ssr/index.js", |
| 61 | ] as const; |
| 62 | |
| 63 | const 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 | |
| 68 | const 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 | |
| 76 | export 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 | |
| 83 | export interface BundleFile { |
| 84 | readonly path: string; |
| 85 | readonly content: string | Uint8Array; |
| 86 | readonly hash: string; |
| 87 | } |
| 88 | |
| 89 | export interface BundleOutput { |
| 90 | readonly files: [BundleFile, ...Array<BundleFile>]; |
| 91 | readonly hash: string; |
| 92 | } |
| 93 | |
| 94 | export 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 | |
| 103 | export interface SourceBuildOutput { |
| 104 | readonly bundle: BundleOutput | undefined; |
| 105 | readonly assets: AssetReadResult | undefined; |
| 106 | readonly hash: SourceHash; |
| 107 | } |
| 108 | |
| 109 | export 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 | |
| 125 | export 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 | |
| 138 | export interface ServerDevHandle { |
| 139 | readonly mode: "server"; |
| 140 | readonly url: URL; |
| 141 | } |
| 142 | |
| 143 | export class SourceProviderError extends Data.TaggedError( |
| 144 | "Cloudflare.Workers.SourceProviderError", |
| 145 | )<{ |
| 146 | readonly provider: string; |
| 147 | readonly message: string; |
| 148 | readonly cause?: unknown; |
| 149 | }> {} |
| 150 | |
| 151 | export type VinextSourceError = |
| 152 | | SourceProviderError |
| 153 | | PlatformError |
| 154 | | ModuleLoadError; |
| 155 | export type SourceRequirements = FileSystem.FileSystem | Path.Path; |
| 156 | |
| 157 | export 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 | |
| 179 | export interface VinextMemoOptions { |
| 180 | readonly include?: Array<string> | undefined; |
| 181 | readonly exclude?: Array<string> | undefined; |
| 182 | readonly lockfile?: boolean | undefined; |
| 183 | } |
| 184 | |
| 185 | export 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 | |
| 195 | export 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 | |
| 205 | const sha256 = (input: string | Uint8Array): string => |
| 206 | NodeCrypto.createHash("sha256").update(input).digest("hex"); |
| 207 | |
| 208 | const 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 | |
| 224 | const sha256Object = (value: unknown): string => |
| 225 | sha256(JSON.stringify(stableValue(value))); |
| 226 | |
| 227 | const 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 | |
| 234 | const LOCKFILE_NAMES = [ |
| 235 | "bun.lock", |
| 236 | "bun.lockb", |
| 237 | "package-lock.json", |
| 238 | "pnpm-lock.yaml", |
| 239 | "yarn.lock", |
| 240 | ]; |
| 241 | |
| 242 | const escapeRegExpChar = (char: string): string => |
| 243 | /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char; |
| 244 | |
| 245 | const 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 | |
| 274 | const 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 | |
| 288 | const compileIgnoreRules = (rules: ReadonlyArray<string>): Array<RegExp> => |
| 289 | rules |
| 290 | .map(compileIgnoreRule) |
| 291 | .filter((regex): regex is RegExp => regex !== undefined); |
| 292 | |
| 293 | const compileIncludeGlobs = (globs: ReadonlyArray<string>): Array<RegExp> => |
| 294 | globs.map((glob) => new RegExp(`^${globBodyToRegExpSource(glob)}$`)); |
| 295 | |
| 296 | const matchesAny = (regexes: ReadonlyArray<RegExp>, path: string): boolean => |
| 297 | regexes.some((regex) => regex.test(path)); |
| 298 | |
| 299 | const 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 | |
| 317 | const 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 | |
| 335 | interface ResolvedMemo { |
| 336 | readonly include: Array<RegExp> | undefined; |
| 337 | readonly exclude: Array<RegExp>; |
| 338 | readonly lockfile: boolean; |
| 339 | } |
| 340 | |
| 341 | const 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 | |
| 363 | const 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 | |
| 409 | const 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 | |
| 453 | const SPECIAL_ASSET_FILES = [".assetsignore", "_headers", "_redirects"]; |
| 454 | |
| 455 | const 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 | |
| 466 | const 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 | |
| 517 | const 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 | |
| 535 | type ViteModule = typeof import("vite"); |
| 536 | |
| 537 | const 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 | |
| 548 | const 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 | |
| 557 | const 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 | |
| 567 | interface 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 | |
| 576 | const 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 | |
| 668 | const 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 | |
| 685 | const 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 | |
| 710 | const 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 | */ |
| 729 | export 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 | |
| 794 | const 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 | */ |
| 812 | const 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 | |
| 835 | export 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 | |
| 991 | const 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 | |
| 1012 | export default sourceModule; |