1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import { glob } from "tinyglobby";
4import path from "pathe";
5import * as Artifacts from "../../../Artifacts.ts";
6import * as Bundle from "../../../Bundle/Bundle.ts";
7import { sha256 } from "../../../Util/sha256.ts";
8import type { SourceProvider } from "../Source.ts";
9import {
10 bundleSource,
11 resolveMainPath,
12 watchBundleDirectory,
13} from "./shared.ts";
14
15/**
16 * A rule selecting additional module files to upload alongside the entry
17 * of a prebuilt Worker (`bundle: false`). Globs are matched against
18 * POSIX-style paths relative to the directory containing the Worker's
19 * entry module, mirroring Wrangler's `rules` configuration.
20 */
21export interface ModuleRule {
22 readonly globs: readonly string[];
23}
24
25/**
26 * The default {@link ModuleRule | module rules} applied when
27 * `bundle: false` is set without explicit rules — the same set Wrangler
28 * applies to `no_bundle` Workers: ESModule (`**\/*.js`, `**\/*.mjs`),
29 * CompiledWasm (`**\/*.wasm`), Text (`**\/*.txt`, `**\/*.html`,
30 * `**\/*.sql`), and Data (`**\/*.bin`). Source maps (`.js.map`) are
31 * deliberately not uploaded.
32 */
33export const defaultModuleRules: ModuleRule[] = [
34 { globs: ["**/*.js", "**/*.mjs"] },
35 { globs: ["**/*.wasm"] },
36 { globs: ["**/*.txt", "**/*.html", "**/*.sql"] },
37 { globs: ["**/*.bin"] },
38];
39
40export interface PrebuiltWorkerBundleOptions {
41 /**
42 * Path (or `file://` URL) to the prebuilt, runtime-ready entry module.
43 */
44 main: string;
45 /**
46 * Module rules selecting additional files to upload alongside the
47 * entry. Defaults to {@link defaultModuleRules}.
48 */
49 rules?: readonly ModuleRule[] | undefined;
50}
51
52/**
53 * Read a prebuilt Worker bundle from disk without bundling.
54 *
55 * The entry's directory is walked recursively and every file matching the
56 * rule globs is uploaded byte-for-byte as an additional module, named by
57 * its POSIX path relative to that directory — the same contract as
58 * Wrangler's `find_additional_modules` and Alchemy v1's `noBundle`. The
59 * entry file is always first and never duplicated as an additional
60 * module.
61 */
62export const readPrebuiltWorkerBundle = Effect.fn(function* (
63 options: PrebuiltWorkerBundleOptions,
64) {
65 const fs = yield* FileSystem.FileSystem;
66
67 // Resolve without following symlinks (Alchemy v1 parity): the module
68 // walk happens in the directory the user pointed at, not the entry's
69 // canonical location.
70 const main = yield* resolveMainPath(options.main);
71 const root = path.dirname(main);
72 const entryName = path.basename(main);
73
74 const readModuleFile = Effect.fn(function* (name: string) {
75 const file = path.join(root, name);
76 const content = yield* fs.readFile(file).pipe(
77 Effect.mapError(
78 (cause) =>
79 new Bundle.BundleError({
80 message: `Failed to read prebuilt worker bundle module "${file}"`,
81 cause,
82 }),
83 ),
84 );
85 const hash = yield* sha256(content);
86 return { path: name, content, hash } satisfies Bundle.BundleFile;
87 });
88
89 return yield* readModuleFile(entryName).pipe(
90 Effect.zipWith(
91 Effect.tryPromise({
92 try: () =>
93 glob(
94 (options.rules ?? defaultModuleRules).flatMap((rule) => rule.globs),
95 {
96 cwd: root,
97 onlyFiles: true,
98 expandDirectories: false,
99 dot: true,
100 },
101 ),
102 catch: (error) =>
103 new Bundle.BundleError({
104 message: `Failed to read additional modules in directory "${root}"`,
105 cause: error,
106 }),
107 }).pipe(
108 Effect.map((names) =>
109 names
110 .map((name) => name.replaceAll("\\", "/"))
111 .filter((name) => name !== entryName)
112 .sort(),
113 ),
114 Effect.flatMap(
115 Effect.forEach(readModuleFile, { concurrency: "unbounded" }),
116 ),
117 ),
118 (entryModule, additionalModules) =>
119 [entryModule, ...additionalModules] as [
120 Bundle.BundleFile,
121 ...Bundle.BundleFile[],
122 ],
123 { concurrent: true },
124 ),
125 Effect.flatMap(Bundle.bundleOutputFromFiles),
126 );
127});
128
129/**
130 * Watch a prebuilt Worker (`bundle: false`) for changes: emits the
131 * initial byte-for-byte read, then re-reads whenever a file under the
132 * entry's directory changes (debounced). Mirrors the
133 * {@link Bundle.BundleWatchEvent} protocol of the rolldown watcher so
134 * local dev can consume either stream interchangeably — crucially
135 * WITHOUT re-bundling the prebuilt artifact, preserving the same
136 * byte-for-byte contract as the deploy path.
137 */
138export const watchPrebuiltWorkerBundle = (
139 options: PrebuiltWorkerBundleOptions,
140) =>
141 watchBundleDirectory({
142 main: options.main,
143 read: readPrebuiltWorkerBundle(options),
144 });
145
146/**
147 * Source provider for prebuilt workers (`bundle: false`): the entry and
148 * every file matching the module rules are uploaded byte-for-byte — no
149 * bundling, no minification. `hash()` is a cheap re-read (IO + sha256,
150 * no bundling), cached per run under the shared `"build"` artifact key.
151 *
152 * Local dev honors the same byte-for-byte contract: the entry directory
153 * is fs-watched and re-read on change — never re-bundled with rolldown,
154 * which would violate the prebuilt contract ("re-bundling such artifacts
155 * is unsafe").
156 */
157export const makePrebuiltSource = (options: {
158 main: string;
159 rules: ModuleRule[] | undefined;
160}): SourceProvider => {
161 const build = readPrebuiltWorkerBundle({
162 main: options.main,
163 rules: options.rules,
164 }).pipe(Artifacts.cached("build"));
165 return bundleSource({
166 build: () => build,
167 watch: () =>
168 Effect.succeed(
169 watchPrebuiltWorkerBundle({
170 main: options.main,
171 rules: options.rules,
172 }),
173 ),
174 });
175};