1/**
2 * `@alchemy.run/frontend-frameworks/octane/source` — alchemy Worker source provider for
3 * OctaneJS projects.
4 *
5 * The default export satisfies alchemy's `WorkerSourceModule` contract
6 * (`packages/alchemy/src/Cloudflare/Workers/Source.ts`) **structurally**:
7 * the `Source*` shapes below mirror alchemy's `SourceProvider` /
8 * `SourceContext` / `SourceBuildOutput` interfaces rather than importing
9 * them, so this package carries no dependency on alchemy. Version skew
10 * degrades to a load-time `SourceProviderError` on the alchemy side.
11 *
12 * Semantics mirror alchemy's built-in vite source
13 * (`Workers/Sources/Vite.ts`):
14 *
15 * - `build()` runs the Octane Framework service (the project's own
16 * Vite plugins compile the client and server; Alchemy generates the
17 * `dist/server/worker.js` entry without requiring a hosting adapter) and
18 * maps its `BuildOutput` onto the source contract: `serverModules`
19 * (entry first) → bundle files, `clientDirectory` (`dist/client`) →
20 * assets (manifest-hashed, honoring `.assetsignore` / `_headers` /
21 * `_redirects`), project-tree hash → the `input` hash slot.
22 * - `hash()` recomputes the `input` hash **without building** — a memo hash
23 * over the project tree (gitignore-aware by default, narrowable via
24 * `memo.include`/`memo.exclude`), the lockfile, this package's version,
25 * and the build-affecting options.
26 * - `dev()` starts Octane's own Vite dev server (the plugin's dev SSR
27 * middleware — rendering, server routes, and RPC run in-process with full
28 * HMR) and returns the server-mode dev handle. NOTE: Octane's dev
29 * middleware supplies no request-scoped platform bindings
30 * (`context.platform` is `undefined` in dev — an upstream limitation), so
31 * Worker bindings are not observable from dev-mode app code yet.
32 */
33import * as Data from "effect/Data";
34import * as Effect from "effect/Effect";
35import * as FileSystem from "effect/FileSystem";
36import type * as Path from "effect/Path";
37import type { PlatformError } from "effect/PlatformError";
38import type * as Scope from "effect/Scope";
39import { glob } from "tinyglobby";
40import * as NodeCrypto from "node:crypto";
41import * as NodePath from "node:path";
42import { fileURLToPath } from "node:url";
43import { runBuildChild } from "../core/BuildChild.ts";
44import { makeCloudflareTarget } from "./cloudflare.ts";
45import { make as makeOctane, type OctaneOptions } from "./Octane.ts";
46
47const PROVIDER = "@alchemy.run/frontend-frameworks/octane/source";
48
49// ─────────────────────────────────────────────────────────────────────
50// Structural mirror of alchemy's Worker source contract
51// (packages/alchemy/src/Cloudflare/Workers/Source.ts). Do not import
52// alchemy here — the contract is structural by design.
53// ─────────────────────────────────────────────────────────────────────
54
55/** Mirror of alchemy's `SourceHash`. */
56export interface SourceHash {
57 readonly bundle: string | undefined;
58 readonly assets: string | undefined;
59 readonly input: string | undefined;
60 readonly additionalWorkspaces: Array<string> | undefined;
61}
62
63/** Mirror of alchemy's `Bundle.BundleFile` / `Bundle.BundleOutput`. */
64export interface SourceBundleFile {
65 readonly path: string;
66 readonly content: string | Uint8Array;
67 readonly hash: string;
68}
69
70export interface SourceBundleOutput {
71 readonly files: [SourceBundleFile, ...Array<SourceBundleFile>];
72 readonly hash: string;
73}
74
75/** Mirror of alchemy's `AssetReadResult` (`Workers/Assets.ts`). */
76export interface SourceAssetReadResult {
77 directory: string;
78 config: Record<string, unknown> | undefined;
79 manifest: Record<string, { hash: string; size: number }>;
80 _headers: string | undefined;
81 _redirects: string | undefined;
82 hash: string;
83}
84
85/** Mirror of alchemy's `SourceBuildOutput`. */
86export interface SourceBuildOutput {
87 readonly bundle: SourceBundleOutput | undefined;
88 readonly assets: SourceAssetReadResult | undefined;
89 readonly hash: SourceHash;
90}
91
92/** Mirror of alchemy's `SourceContext` (the subset this source reads). */
93export interface SourceContext {
94 readonly id: string;
95 readonly workerName: string;
96 readonly compatibility: {
97 readonly date: string;
98 readonly flags: Array<string>;
99 };
100 readonly stack: { readonly name: string; readonly stage: string };
101 readonly env: Record<string, unknown> | undefined;
102 readonly assets: Record<string, unknown> | string | undefined;
103}
104
105/** Mirror of alchemy's `DevContext` (the subset this source reads). */
106export interface SourceDevContext extends SourceContext {
107 /**
108 * Runtime wiring derived by alchemy's LocalWorkerProvider. Octane's dev
109 * server cannot serve platform bindings yet (see the module doc), so the
110 * worker wiring is currently unused — carried for contract parity.
111 */
112 readonly worker: {
113 readonly name: string;
114 readonly bindings: ReadonlyArray<unknown>;
115 };
116 /** The host's pre-built `Context<RuntimeServices>` (opaque here; unused). */
117 readonly runtimeContext: unknown;
118}
119
120/** Mirror of alchemy's server-mode `SourceDevHandle`. */
121export interface SourceDevHandle {
122 readonly mode: "server";
123 readonly url: URL;
124 readonly serviceBinding?: "http";
125}
126
127/**
128 * Same tag as alchemy's `Cloudflare.Workers.SourceProviderError`, so the
129 * errors this module raises are members of alchemy's closed `SourceError`
130 * union structurally.
131 */
132export class SourceProviderError extends Data.TaggedError(
133 "Cloudflare.Workers.SourceProviderError",
134)<{
135 readonly provider: string;
136 readonly message: string;
137 readonly cause?: unknown;
138}> {}
139
140export type SourceError = SourceProviderError | PlatformError;
141
142type SourceServices = FileSystem.FileSystem | Path.Path;
143
144/** Mirror of alchemy's `SourceProvider` (structural). */
145export interface SourceProvider {
146 readonly ownsAssets: boolean;
147 readonly build: (
148 ctx: SourceContext,
149 ) => Effect.Effect<SourceBuildOutput, SourceError, SourceServices>;
150 readonly hash: (
151 ctx: SourceContext,
152 previous: SourceHash | undefined,
153 ) => Effect.Effect<Partial<SourceHash>, SourceError, SourceServices>;
154 readonly dev: (
155 ctx: SourceDevContext,
156 ) => Effect.Effect<
157 SourceDevHandle,
158 SourceError,
159 SourceServices | Scope.Scope
160 >;
161}
162
163// ─────────────────────────────────────────────────────────────────────
164// Descriptor options (JSON-serializable — they persist in alchemy state
165// and participate in the Worker's metadata hash)
166// ─────────────────────────────────────────────────────────────────────
167
168export interface OctaneMemoOptions {
169 /** Glob patterns of files to hash, relative to `rootDir`. @default all non-gitignored files */
170 readonly include?: Array<string> | undefined;
171 /** Glob patterns to exclude. @default gitignore rules from `rootDir` up to the repo root */
172 readonly exclude?: Array<string> | undefined;
173 /**
174 * Include the nearest package-manager lockfile in the hash.
175 * @default true when both `include` and `exclude` are unset
176 */
177 readonly lockfile?: boolean | undefined;
178}
179
180export interface OctaneSourceOptions {
181 /**
182 * Octane project root (the directory containing `vite.config.ts` and
183 * `octane.config.ts`). Relative paths resolve from the process working
184 * directory. @default process.cwd()
185 */
186 readonly rootDir?: string | undefined;
187 /** Narrows the rebuild-detection input hash. */
188 readonly memo?: OctaneMemoOptions | undefined;
189}
190
191// ─────────────────────────────────────────────────────────────────────
192// Hashing (mirrors alchemy's Command/Memo.ts + Util/sha256.ts semantics:
193// machine-independent — only relative paths and file bytes are hashed)
194// ─────────────────────────────────────────────────────────────────────
195
196const sha256Hex = (input: string | Uint8Array): Effect.Effect<string> =>
197 Effect.sync(() =>
198 NodeCrypto.createHash("sha256").update(input).digest("hex"),
199 );
200
201/** Recursively sort object keys so JSON.stringify is order-stable. */
202const stableValue = (value: unknown): unknown => {
203 if (Array.isArray(value)) {
204 return value.map(stableValue);
205 }
206 if (value !== null && typeof value === "object") {
207 return Object.fromEntries(
208 Object.entries(value as Record<string, unknown>)
209 .filter(([, v]) => v !== undefined)
210 .sort(([a], [b]) => a.localeCompare(b))
211 .map(([k, v]) => [k, stableValue(v)]),
212 );
213 }
214 return value;
215};
216
217const sha256Stable = (input: unknown): Effect.Effect<string> =>
218 sha256Hex(JSON.stringify(stableValue(input) ?? null));
219
220/**
221 * Convert gitignore-style rules into glob `ignore` patterns — a copy of
222 * alchemy's `Util/gitignore-rules-to-globs.ts` (common cases only).
223 */
224const gitignoreRulesToGlobs = (rules: ReadonlyArray<string>): Array<string> => {
225 const out: Array<string> = [];
226 for (const raw of rules) {
227 const line = raw.trim();
228 if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) {
229 continue;
230 }
231 let rest = line;
232 const dirOnly = rest.endsWith("/");
233 if (dirOnly) {
234 rest = rest.slice(0, -1);
235 }
236 rest = rest.replace(/\\/g, "/");
237 if (rest.length === 0) continue;
238 const anchored = rest.startsWith("/");
239 if (anchored) {
240 rest = rest.slice(1);
241 }
242 if (rest.length === 0) continue;
243 const hasSlash = rest.includes("/");
244 if (dirOnly) {
245 out.push(anchored || hasSlash ? `${rest}/**` : `**/${rest}/**`);
246 } else if (anchored || hasSlash) {
247 out.push(rest, `${rest}/**`);
248 } else {
249 out.push(`**/${rest}`, `**/${rest}/**`);
250 }
251 }
252 return out;
253};
254
255const findUp = Effect.fnUntraced(function* (
256 fs: FileSystem.FileSystem,
257 cwd: string,
258 filenames: Array<string>,
259) {
260 let dir = cwd;
261 for (;;) {
262 for (const filename of filenames) {
263 const file = NodePath.join(dir, filename);
264 if (yield* fs.exists(file)) {
265 return file;
266 }
267 }
268 const parent = NodePath.dirname(dir);
269 if (parent === dir) {
270 return undefined;
271 }
272 dir = parent;
273 }
274});
275
276/** Collect `.gitignore` rules from `cwd` up to the repo root (parents first). */
277const readGitIgnoreRules = (
278 fs: FileSystem.FileSystem,
279 cwd: string,
280): Effect.Effect<Array<string>, PlatformError> =>
281 Effect.gen(function* () {
282 const rules = yield* fs
283 .readFileString(NodePath.join(cwd, ".gitignore"))
284 .pipe(
285 Effect.map((file) => file.split("\n")),
286 Effect.catchTag("PlatformError", () =>
287 Effect.succeed([] as Array<string>),
288 ),
289 );
290 const parent = NodePath.dirname(cwd);
291 if (parent === cwd || (yield* fs.exists(NodePath.join(cwd, ".git")))) {
292 return rules;
293 }
294 const parentRules = yield* readGitIgnoreRules(fs, parent);
295 return [...parentRules, ...rules];
296 });
297
298/**
299 * Deterministic content hash of the files in `cwd` matched by `memo` —
300 * mirror of alchemy's `hashDirectory` (`Command/Memo.ts`).
301 */
302const hashDirectory = Effect.fnUntraced(function* (
303 cwd: string,
304 memo: OctaneMemoOptions | undefined,
305) {
306 const fs = yield* FileSystem.FileSystem;
307 const include = memo?.include ?? ["**/*"];
308 const exclude = memo?.exclude ?? [
309 "**/.git/**",
310 ...gitignoreRulesToGlobs(yield* readGitIgnoreRules(fs, cwd)),
311 ];
312 const lockfile = memo?.lockfile ?? !(memo?.include || memo?.exclude);
313 const [files, lockfilePath] = yield* Effect.all(
314 [
315 Effect.promise(() =>
316 glob(include, {
317 cwd,
318 ignore: exclude,
319 onlyFiles: true,
320 expandDirectories: false,
321 dot: true,
322 }),
323 ),
324 lockfile
325 ? Effect.map(
326 findUp(fs, cwd, [
327 "bun.lock",
328 "bun.lockb",
329 "package-lock.json",
330 "pnpm-lock.yaml",
331 "yarn.lock",
332 ]),
333 (file) => (file ? NodePath.relative(cwd, file) : undefined),
334 )
335 : Effect.succeed(undefined),
336 ],
337 { concurrency: "unbounded" },
338 );
339 if (lockfilePath && !files.includes(lockfilePath)) {
340 files.push(lockfilePath);
341 }
342 const hashes = yield* Effect.forEach(
343 files.sort(),
344 (file) =>
345 fs.readFile(NodePath.join(cwd, file)).pipe(
346 Effect.flatMap(sha256Hex),
347 Effect.map((hash) => `${file}:${hash}`),
348 ),
349 { concurrency: 16 },
350 );
351 return yield* sha256Stable(hashes);
352});
353
354/** This package's version — part of the `input` hash so an integration upgrade busts the memo. */
355const packageVersion = Effect.gen(function* () {
356 const fs = yield* FileSystem.FileSystem;
357 const dir = NodePath.dirname(fileURLToPath(import.meta.url));
358 const content = yield* fs.readFileString(
359 NodePath.join(dir, "../../package.json"),
360 );
361 return (JSON.parse(content) as { version: string }).version;
362});
363
364/**
365 * The rebuild-free `input` hash: project tree + external workspaces +
366 * package version + build-affecting options. Mirrors alchemy's
367 * `hashViteInput` semantics (`Workers/Sources/Vite.ts`) — never hashes
368 * absolute paths, so relocating an identical tree is a no-op.
369 */
370const hashOctaneInput = Effect.fnUntraced(function* (
371 rootDir: string,
372 options: OctaneSourceOptions,
373 workspaces: Iterable<string>,
374) {
375 const version = yield* packageVersion;
376 const hashWorkspace = (cwd: string, memo?: OctaneMemoOptions) =>
377 hashDirectory(NodePath.resolve(rootDir, cwd), memo).pipe(
378 Effect.map(
379 (hash) =>
380 `${NodePath.relative(rootDir, NodePath.resolve(rootDir, cwd))}:${hash}`,
381 ),
382 );
383 const [root, ...workspaceHashes] = yield* Effect.all(
384 [
385 hashWorkspace(rootDir, options.memo),
386 ...Array.from(workspaces, (cwd) => hashWorkspace(cwd)),
387 ],
388 { concurrency: "unbounded" },
389 );
390 const hash = yield* sha256Stable({
391 version,
392 options: {
393 memo: options.memo,
394 },
395 tree: [root, ...workspaceHashes.sort()],
396 });
397 return {
398 hash,
399 workspaces: Array.from(workspaces, (cwd) =>
400 NodePath.relative(rootDir, NodePath.resolve(rootDir, cwd)),
401 ),
402 };
403});
404
405// ─────────────────────────────────────────────────────────────────────
406// Assets (mirror of alchemy's `readAssets` in `Workers/Assets.ts`:
407// manifest of 32-hex content hashes, honoring `.assetsignore`,
408// `_headers`, `_redirects`; the overall hash excludes the directory
409// path so identical bytes hash identically across machines)
410// ─────────────────────────────────────────────────────────────────────
411
412const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB (Cloudflare limit)
413const MAX_ASSET_COUNT = 20_000;
414
415const maybeReadString = (fs: FileSystem.FileSystem, file: string) =>
416 fs
417 .readFileString(file)
418 .pipe(
419 Effect.catchTag("PlatformError", (error) =>
420 error.reason._tag === "NotFound"
421 ? Effect.succeed(undefined)
422 : Effect.fail(error),
423 ),
424 );
425
426const readAssetsDirectory = Effect.fnUntraced(function* (
427 directory: string,
428 config: Record<string, unknown> | undefined,
429) {
430 const fs = yield* FileSystem.FileSystem;
431 const [ignore, _headers, _redirects] = yield* Effect.all([
432 maybeReadString(fs, NodePath.join(directory, ".assetsignore")),
433 maybeReadString(fs, NodePath.join(directory, "_headers")),
434 maybeReadString(fs, NodePath.join(directory, "_redirects")),
435 ]);
436 const files = yield* Effect.promise(() =>
437 glob(["**/*"], {
438 cwd: directory,
439 ignore: [
440 ".assetsignore",
441 "_headers",
442 "_redirects",
443 ...gitignoreRulesToGlobs(ignore?.split("\n") ?? []),
444 ],
445 onlyFiles: true,
446 expandDirectories: false,
447 dot: true,
448 }),
449 );
450 if (files.length > MAX_ASSET_COUNT) {
451 return yield* Effect.fail(
452 new SourceProviderError({
453 provider: PROVIDER,
454 message: `Too many assets in ${directory} (maximum is ${MAX_ASSET_COUNT}, found ${files.length})`,
455 }),
456 );
457 }
458 const entries = yield* Effect.forEach(
459 files.sort(),
460 Effect.fnUntraced(function* (name) {
461 const file = NodePath.join(directory, name);
462 const content = yield* fs.readFile(file);
463 if (content.byteLength > MAX_ASSET_SIZE) {
464 return yield* Effect.fail(
465 new SourceProviderError({
466 provider: PROVIDER,
467 message: `Asset ${name} is too large (the maximum size is 25 MB)`,
468 }),
469 );
470 }
471 const hash = (yield* sha256Hex(content)).slice(0, 32);
472 return [
473 `/${name.replaceAll("\\", "/")}`,
474 { hash, size: content.byteLength },
475 ] as const;
476 }),
477 { concurrency: 16 },
478 );
479 const manifest = Object.fromEntries(
480 [...entries].sort((a, b) => a[0].localeCompare(b[0])),
481 );
482 const hash = yield* sha256Stable({ config, manifest, _headers, _redirects });
483 return {
484 directory,
485 config,
486 manifest,
487 _headers,
488 _redirects,
489 hash,
490 } satisfies SourceAssetReadResult;
491});
492
493// ─────────────────────────────────────────────────────────────────────
494// The provider
495// ─────────────────────────────────────────────────────────────────────
496
497const wrapFrameworkError = (error: {
498 readonly message: string;
499 readonly cause?: unknown;
500}) =>
501 new SourceProviderError({
502 provider: PROVIDER,
503 message: error.message,
504 cause: error.cause ?? error,
505 });
506
507const assetsConfig = (
508 assets: SourceContext["assets"],
509): Record<string, unknown> | undefined => {
510 if (assets === undefined || typeof assets === "string") {
511 return undefined;
512 }
513 const { directory: _directory, hash: _hash, ...config } = assets;
514 return Object.keys(config).length > 0 ? config : undefined;
515};
516
517/**
518 * The production build runs in a disposable child process with
519 * `cwd = project root` (see `core/BuildChild.ts`): the natively-loaded
520 * `vite.config.*` executes user plugins that may read the cwd, mutate
521 * `process.env`, or `process.chdir` — none of which may touch the engine
522 * process. The shared `core/BuildChildRunner`
523 * entry imports this module in the child and calls the exported
524 * {@link buildInChild}.
525 */
526export interface OctaneBuildChildConfig {
527 readonly rootDir: string;
528 readonly compatibilityDate: string;
529 readonly compatibilityFlags: Array<string>;
530}
531
532export const buildInChild = (config: OctaneBuildChildConfig) =>
533 Effect.gen(function* () {
534 const framework = yield* makeOctane({
535 root: config.rootDir,
536 target: makeCloudflareTarget,
537 compatibilityDate: config.compatibilityDate,
538 compatibilityFlags: config.compatibilityFlags,
539 });
540 return yield* framework.build({ root: config.rootDir });
541 });
542
543export const makeOctaneSource = (
544 options: OctaneSourceOptions,
545): SourceProvider => {
546 const rootDir = NodePath.resolve(options.rootDir ?? process.cwd());
547 const frameworkOptions = (ctx: SourceContext): OctaneOptions => ({
548 root: rootDir,
549 // This module is Cloudflare-specific by contract (it implements alchemy's
550 // Cloudflare Worker source), so it passes the target factory directly
551 // instead of relying on specifier resolution from the project's
552 // node_modules.
553 target: makeCloudflareTarget,
554 compatibilityDate: ctx.compatibility.date,
555 compatibilityFlags: ctx.compatibility.flags,
556 });
557 return {
558 ownsAssets: true,
559 build: Effect.fnUntraced(function* (ctx) {
560 const output = yield* runBuildChild({
561 module: import.meta.url,
562 rootDir,
563 framework: "octane",
564 config: {
565 rootDir,
566 compatibilityDate: ctx.compatibility.date,
567 compatibilityFlags: ctx.compatibility.flags,
568 } satisfies OctaneBuildChildConfig,
569 }).pipe(Effect.mapError(wrapFrameworkError));
570 if (
571 output.serverModules === undefined ||
572 output.serverModules.length === 0
573 ) {
574 return yield* Effect.fail(
575 new SourceProviderError({
576 provider: PROVIDER,
577 message: "The Octane build produced no server modules",
578 }),
579 );
580 }
581 if (output.clientDirectory === undefined) {
582 return yield* Effect.fail(
583 new SourceProviderError({
584 provider: PROVIDER,
585 message: "The Octane build produced no client directory",
586 }),
587 );
588 }
589 const files = output.serverModules.map((module): SourceBundleFile => ({
590 path: module.name,
591 content: module.content,
592 hash: module.hash,
593 })) as [SourceBundleFile, ...Array<SourceBundleFile>];
594 const [bundleHash, assets, input] = yield* Effect.all(
595 [
596 sha256Stable(files.map((file) => `${file.path}:${file.hash}`)),
597 readAssetsDirectory(
598 NodePath.resolve(rootDir, output.clientDirectory),
599 assetsConfig(ctx.assets),
600 ),
601 hashOctaneInput(rootDir, options, output.externalWorkspaces),
602 ],
603 { concurrency: "unbounded" },
604 );
605 return {
606 bundle: { files, hash: bundleHash },
607 assets,
608 hash: {
609 bundle: bundleHash,
610 assets: assets.hash,
611 input: input.hash,
612 additionalWorkspaces: input.workspaces,
613 },
614 } satisfies SourceBuildOutput;
615 }),
616 hash: Effect.fnUntraced(function* (_ctx, previous) {
617 const { hash, workspaces } = yield* hashOctaneInput(
618 rootDir,
619 options,
620 previous?.additionalWorkspaces ?? [],
621 );
622 return { input: hash, additionalWorkspaces: workspaces };
623 }),
624 dev: Effect.fnUntraced(function* (ctx: SourceDevContext) {
625 // Octane's dev server serves SSR/HMR in-process but supplies no
626 // request-scoped platform (see the module doc) — the worker's binding
627 // hooks (`ctx.worker.bindings`) cannot be served into dev app code yet.
628 const framework = yield* makeOctane(frameworkOptions(ctx));
629 const server = yield* framework
630 .dev({ root: rootDir, port: 0 })
631 .pipe(Effect.mapError(wrapFrameworkError));
632 return {
633 mode: "server",
634 url: new URL(server.url),
635 serviceBinding: "http",
636 } satisfies SourceDevHandle;
637 }),
638 };
639};
640
641/**
642 * The `WorkerSourceModule` contract: alchemy dynamically imports this module
643 * and calls `make(descriptor.options)`.
644 */
645const sourceModule = {
646 make: (
647 options: unknown,
648 ): Effect.Effect<SourceProvider, SourceProviderError> =>
649 Effect.succeed(makeOctaneSource((options ?? {}) as OctaneSourceOptions)),
650};
651
652export default sourceModule;