1import * as workers from "@distilled.cloud/cloudflare/workers";
2import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
3import * as Retry from "@distilled.cloud/cloudflare/Retry";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as Schedule from "effect/Schedule";
10import * as Semaphore from "effect/Semaphore";
11import type { PlatformError } from "effect/PlatformError";
12import type { ScopedPlanStatusSession } from "../../Report.ts";
13import { sha256, sha256Object } from "../../Util/index.ts";
14import { initialCwd } from "../../Util/Node.ts";
15import createIgnore from "@alchemy.run/node-utils/ignore";
16
17const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB
18const MAX_ASSET_COUNT = 20_000;
19
20export interface Assets {
21 kind: "Cloudflare.Workers.Assets";
22}
23
24export const isAssets = (value: any): value is Assets =>
25 value?.kind === "Cloudflare.Workers.Assets";
26
27/**
28 * Routing configuration for a Worker's static assets — sent to Cloudflare
29 * as `metadata.assets.config` on script upload. Declared explicitly (not
30 * derived from the distilled API schema) so alchemy owns and documents its
31 * public surface.
32 */
33export interface AssetsConfig {
34 /**
35 * Determines the redirects and rewrites of requests for HTML content:
36 * whether `/page` serves `page.html`, and whether trailing slashes are
37 * added or dropped.
38 *
39 * @default "auto-trailing-slash"
40 */
41 htmlHandling?:
42 | "auto-trailing-slash"
43 | "force-trailing-slash"
44 | "drop-trailing-slash"
45 | "none";
46 /**
47 * Determines the response when a request does not match a static asset:
48 * `"404-page"` serves the nearest `404.html`, and
49 * `"single-page-application"` serves `index.html` for client-side
50 * routing. When the Worker has a script, an unmatched request falls
51 * through to the Worker instead.
52 *
53 * @default "none"
54 */
55 notFoundHandling?: "none" | "404-page" | "single-page-application";
56 /**
57 * Routes requests through the Worker *before* static-asset matching.
58 *
59 * Assets-first by default: a request matching a file is served directly
60 * and never invokes the Worker. `true` routes every request through the
61 * Worker ahead of the asset layer — serve files yourself via the
62 * `ASSETS` binding. A path-rule array routes only matching paths
63 * worker-first (e.g. `["/api/*"]`): glob (`*`) and negative (`!`) rules
64 * are supported, rules must start with `/` or `!/`, and negative rules
65 * take precedence. The same routing applies under `alchemy dev`.
66 *
67 * @default false
68 */
69 runWorkerFirst?: boolean | string[];
70 /**
71 * Legacy routing flag predating `runWorkerFirst`.
72 */
73 serveDirectly?: boolean;
74 /**
75 * Raw contents of a `_headers` file — header rules applied by the asset
76 * layer. Overrides a `_headers` file read from the assets directory.
77 */
78 headers?: string;
79 /**
80 * Raw contents of a `_redirects` file — redirect rules applied by the
81 * asset layer. Overrides a `_redirects` file read from the assets
82 * directory.
83 */
84 redirects?: string;
85}
86
87export interface AssetReadResult {
88 directory: string;
89 /**
90 * The normalized `base` this manifest was keyed with (`""` when the
91 * assets are served from the origin root). Manifest keys are request
92 * paths, so `uploadAssets` strips this back off to find each file on
93 * disk.
94 */
95 pathPrefix: string;
96 config: AssetsConfig | undefined;
97 manifest: Record<string, { hash: string; size: number }>;
98 _headers: string | undefined;
99 _redirects: string | undefined;
100 hash: string;
101}
102
103export interface AssetsProps extends AssetsConfig {
104 directory: string;
105 /**
106 * The path this site is served from, when it is not the origin root —
107 * e.g. `"/docs"` for a Worker on the route `example.com/docs*`. Matches
108 * Vite's `base`, and `Website.Vite` fills it in from the resolved Vite
109 * config automatically; set it by hand when you bring your own build.
110 *
111 * Cloudflare's asset router matches request paths against the manifest
112 * literally and never strips a prefix, so its model is that the assets
113 * directory mirrors the served path. This does that at manifest time:
114 * `dist/app.js` is uploaded as `/docs/app.js`, leaving the build output
115 * on disk untouched.
116 *
117 * Bases that name no path — `"/"`, `"./"`, `"https://cdn.example.com/"` —
118 * are ignored, as an absolute base means the assets are served by a CDN
119 * rather than by this Worker.
120 *
121 * `_headers` and `_redirects` are NOT rewritten: their rules match the
122 * incoming request path, so author them with the full served path
123 * (`/docs/old /docs/new 301`).
124 *
125 * @default undefined (assets are served from the origin root)
126 * @see https://developers.cloudflare.com/workers/static-assets/routing/advanced/serving-a-subdirectory/
127 */
128 base?: string;
129}
130
131/**
132 * `base` → manifest path prefix. Only a root-relative base names a path
133 * this Worker serves; `"/"`, `"./"`, protocol-relative and absolute URLs
134 * all mean "no prefix".
135 */
136export const getAssetsPathPrefix = (base: string | undefined) =>
137 base?.startsWith("/") && !base.startsWith("//")
138 ? base.replace(/\/+$/, "")
139 : "";
140
141export type ValidationError =
142 | AssetTooLargeError
143 | TooManyAssetsError
144 | AssetNotFoundError
145 | FailedToReadAssetError;
146
147export class AssetTooLargeError extends Data.TaggedError("AssetTooLargeError")<{
148 message: string;
149 name: string;
150 size: number;
151}> {}
152
153export class TooManyAssetsError extends Data.TaggedError("TooManyAssetsError")<{
154 message: string;
155 directory: string;
156 count: number;
157}> {}
158
159export class AssetNotFoundError extends Data.TaggedError("AssetNotFoundError")<{
160 message: string;
161 hash: string;
162}> {}
163
164export class FailedToReadAssetError extends Data.TaggedError(
165 "FailedToReadAssetError",
166)<{
167 message: string;
168 name: string;
169 cause: PlatformError;
170}> {}
171
172export class AssetUploadSessionError extends Data.TaggedError(
173 "AssetUploadSessionError",
174)<{
175 message: string;
176 workerName: string;
177}> {}
178
179const contentTypesByExtension: Record<string, string> = {
180 html: "text/html",
181 htm: "text/html",
182 txt: "text/plain",
183 md: "text/markdown",
184 sql: "text/sql",
185 json: "application/json",
186 // Source maps are JSON; serving them as such lets devtools consume them.
187 map: "application/json",
188 jsonld: "application/ld+json",
189 xml: "application/xml",
190 csv: "text/csv",
191 // Browsers only accept JavaScript module scripts when the MIME type is a
192 // "JavaScript MIME type" (e.g. text/javascript). application/javascript+module
193 // is not valid and causes strict module loading to fail.
194 js: "text/javascript; charset=utf-8",
195 mjs: "text/javascript; charset=utf-8",
196 css: "text/css",
197 wasm: "application/wasm",
198 pdf: "application/pdf",
199 // images
200 png: "image/png",
201 jpg: "image/jpeg",
202 jpeg: "image/jpeg",
203 gif: "image/gif",
204 webp: "image/webp",
205 avif: "image/avif",
206 svg: "image/svg+xml",
207 ico: "image/x-icon",
208 bmp: "image/bmp",
209 // fonts
210 woff: "font/woff",
211 woff2: "font/woff2",
212 ttf: "font/ttf",
213 otf: "font/otf",
214 eot: "application/vnd.ms-fontobject",
215 // media
216 mp3: "audio/mpeg",
217 wav: "audio/wav",
218 ogg: "audio/ogg",
219 mp4: "video/mp4",
220 webm: "video/webm",
221 // app manifests
222 webmanifest: "application/manifest+json",
223};
224
225const getContentType = (name: string) => {
226 const dot = name.lastIndexOf(".");
227 const ext = dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
228 return contentTypesByExtension[ext] ?? "application/octet-stream";
229};
230
231const maybeReadString = Effect.fn(function* (file: string) {
232 const fs = yield* FileSystem.FileSystem;
233 return yield* fs.readFileString(file).pipe(
234 Effect.catchIf(
235 (error) =>
236 error._tag === "PlatformError" && error.reason._tag === "NotFound",
237 () => Effect.succeed(undefined),
238 ),
239 );
240});
241
242const createIgnoreMatcher = (patterns: string[]) => {
243 const matcher = createIgnore().add(patterns);
244 return (file: string) => matcher.ignores(file);
245};
246
247/**
248 * Read the special `_headers` / `_redirects` files from an assets
249 * directory. They are excluded from the upload manifest, but their raw
250 * contents must be sent to Cloudflare in the script metadata's asset
251 * config (`config._headers` / `config._redirects`) for the rules to
252 * apply.
253 *
254 * The directory is optional: a config-only assets object (e.g. Vite's
255 * `assets: { runWorkerFirst: true }`, whose directory is supplied by the
256 * build) has no directory to read, and in `dev` there is no build output at
257 * all. That yields no rules rather than an error.
258 */
259export const readAssetsConfigFiles = Effect.fn(function* (
260 directory: string | undefined,
261) {
262 if (directory === undefined) {
263 return { _headers: undefined, _redirects: undefined };
264 }
265 const path = yield* Path.Path;
266 // Anchored: see `readAssets` — the directory may be initial-cwd-relative
267 // and live cwd reads race concurrent tools' transient chdir.
268 const resolvedDirectory = path.resolve(initialCwd, directory);
269 const [_headers, _redirects] = yield* Effect.all([
270 maybeReadString(path.join(resolvedDirectory, "_headers")),
271 maybeReadString(path.join(resolvedDirectory, "_redirects")),
272 ]);
273 return { _headers, _redirects };
274});
275
276/**
277 * Merge `_headers` / `_redirects` file contents into an asset config,
278 * producing the config to send in the script-upload metadata. Explicit
279 * `headers` / `redirects` props win over the files.
280 */
281export const mergeAssetsConfigFiles = (
282 config: AssetsConfig | undefined,
283 files: { _headers: string | undefined; _redirects: string | undefined },
284): AssetsConfig | undefined => {
285 const headers = config?.headers ?? files._headers;
286 const redirects = config?.redirects ?? files._redirects;
287 if (headers === undefined && redirects === undefined) {
288 return config;
289 }
290 return {
291 ...config,
292 ...(headers !== undefined ? { headers } : undefined),
293 ...(redirects !== undefined ? { redirects } : undefined),
294 };
295};
296
297export const readAssets = Effect.fn(function* ({
298 directory,
299 base,
300 ...config
301}: AssetsProps) {
302 const fs = yield* FileSystem.FileSystem;
303 const path = yield* Path.Path;
304 // `base` nests the *manifest* paths (what Cloudflare matches request
305 // pathnames against) under a prefix; files stay where they are on disk.
306 // It is deliberately excluded from the `config` sent to Cloudflare — it
307 // is not part of the API's asset config shape.
308 const pathPrefix = getAssetsPathPrefix(base);
309 // Anchored: `directory` may be a relative path persisted by
310 // `Command.Build` (relative to the initial cwd), and a live
311 // `process.cwd()` read can race a concurrent tool's transient chdir.
312 const resolvedDirectory = path.resolve(initialCwd, directory);
313 const [files, ignore, _headers, _redirects] = yield* Effect.all([
314 fs.readDirectory(resolvedDirectory, { recursive: true }),
315 maybeReadString(path.join(resolvedDirectory, ".assetsignore")),
316 maybeReadString(path.join(resolvedDirectory, "_headers")),
317 maybeReadString(path.join(resolvedDirectory, "_redirects")),
318 ]);
319 const ignores = createIgnoreMatcher([
320 ".assetsignore",
321 "_headers",
322 "_redirects",
323 ...(ignore
324 ?.split("\n")
325 .map((line) => line.trim())
326 .filter((line) => line.length > 0 && !line.startsWith("#")) ?? []),
327 ]);
328 const manifest = new Map<string, { hash: string; size: number }>();
329 let count = 0;
330 yield* Effect.forEach(
331 files,
332 Effect.fn(function* (name) {
333 if (ignores(name)) {
334 return;
335 }
336 const file = path.join(resolvedDirectory, name);
337 const stat = yield* fs.stat(file);
338 if (stat.type !== "File") {
339 return;
340 }
341 const size = Number(stat.size);
342 if (size > MAX_ASSET_SIZE) {
343 return yield* new AssetTooLargeError({
344 message: `Asset ${name} is too large (the maximum size is ${MAX_ASSET_SIZE / 1024 / 1024} MB; this asset is ${size / 1024 / 1024} MB)`,
345 name,
346 size,
347 });
348 }
349 // Hash content + extension (matching wrangler): the upload API stores
350 // one blob + content type per hash, so two identical bodies under
351 // different extensions must not collapse into a single entry — the
352 // second file would serve with the first file's content type.
353 const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
354 const hash = yield* fs.readFile(file).pipe(
355 Effect.flatMap((content) =>
356 Effect.sync(() => {
357 const extBytes = new TextEncoder().encode(extension);
358 const hashed = new Uint8Array(content.length + extBytes.length);
359 hashed.set(content);
360 hashed.set(extBytes, content.length);
361 return hashed;
362 }),
363 ),
364 Effect.flatMap(sha256),
365 Effect.map((hash) => hash.slice(0, 32)),
366 );
367 count++;
368 if (count > MAX_ASSET_COUNT) {
369 return yield* new TooManyAssetsError({
370 message: `Too many assets (the maximum count is ${MAX_ASSET_COUNT}; this directory has ${count} assets)`,
371 directory,
372 count,
373 });
374 }
375 manifest.set(
376 `${pathPrefix}${(name.startsWith("/") ? name : `/${name}`).replaceAll("\\", "/")}`,
377 {
378 hash,
379 size,
380 },
381 );
382 }),
383 );
384 // Cloudflare's SPA fallback is hard-coded to `/index.html` at the
385 // manifest root, so prefixing every key would 404 every client-side
386 // route under the base. Alias the shell back to the root — one extra
387 // manifest line, zero extra uploads, since entries are content-addressed.
388 const indexHtml = manifest.get(`${pathPrefix}/index.html`);
389 if (
390 indexHtml &&
391 config.notFoundHandling === "single-page-application" &&
392 !manifest.has("/index.html")
393 ) {
394 manifest.set("/index.html", indexHtml);
395 }
396 const sortedManifest = Object.fromEntries(
397 Array.from(manifest.entries()).sort((a, b) => a[0].localeCompare(b[0])),
398 );
399 // Hash only inputs that affect what gets uploaded — the file
400 // manifest, asset config, and the special `_headers` / `_redirects`
401 // files. `directory` is deliberately excluded: identical bytes at a
402 // different absolute path must produce the same hash, otherwise
403 // diffing across machines (CI runner → local laptop, monorepo
404 // root → workspace root, etc.) spuriously reports "changed" and
405 // causes both unnecessary re-uploads and `NotFound` failures when
406 // the previously-recorded path is gone.
407 const hash = yield* sha256Object({
408 config,
409 manifest: sortedManifest,
410 _headers,
411 _redirects,
412 });
413 return {
414 directory,
415 pathPrefix,
416 // Fold the `_headers` / `_redirects` file contents into the config
417 // that gets sent to Cloudflare (`metadata.assets.config`). Merged
418 // *after* hashing so the hash input shape stays stable for
419 // already-deployed workers. Deliberately NOT `base`-prefixed: their
420 // rules match the incoming request path, which already carries the
421 // base, so they are authored with the full served path.
422 config: mergeAssetsConfigFiles(config, { _headers, _redirects }),
423 manifest: sortedManifest,
424 _headers,
425 _redirects,
426 hash,
427 };
428});
429
430/**
431 * Bulk buckets (up to ~50 MiB of base64 each) go up this many at a time —
432 * the same number wrangler uses.
433 */
434const BULK_UPLOAD_CONCURRENCY = 3;
435/**
436 * Per-bucket retries after a gateway error (502/503/504), with concurrency
437 * already dropped to one. Mirrors wrangler's `MAX_UPLOAD_GATEWAY_ERRORS`.
438 */
439const MAX_UPLOAD_GATEWAY_RETRIES = 5;
440
441const isGatewayError = (error: unknown) =>
442 Predicate.isTagged(error, "BadGateway") ||
443 Predicate.isTagged(error, "ServiceUnavailable") ||
444 Predicate.isTagged(error, "GatewayTimeout");
445
446/**
447 * The SDK's default policy retries gateway errors internally (~20s of
448 * backoff) before the caller ever sees one, so the concurrency fallback
449 * below could never kick in. Let those surface to the bucket loop; every
450 * other transient error keeps the default handling.
451 */
452const surfaceGatewayErrors: Retry.Factory = (lastError) => {
453 const base = Retry.makeDefault(lastError);
454 return {
455 ...base,
456 while: (error) => !isGatewayError(error) && (base.while?.(error) ?? false),
457 };
458};
459
460export const uploadAssets = Effect.fn(function* (
461 accountId: string,
462 workerName: string,
463 assets: AssetReadResult,
464 { note }: ScopedPlanStatusSession,
465 dispatchNamespace?: string,
466) {
467 const fs = yield* FileSystem.FileSystem;
468 const path = yield* Path.Path;
469 const createAssetUpload = yield* workers.createAssetUpload;
470
471 // Manifest keys are the paths Cloudflare *serves*, so they carry the
472 // `base` prefix. The files themselves are on disk at the un-prefixed
473 // path relative to the assets directory, so drop the prefix to get
474 // back to something `readFile` can open.
475 const toDiskPath = (name: string) =>
476 assets.pathPrefix && name.startsWith(`${assets.pathPrefix}/`)
477 ? name.slice(assets.pathPrefix.length)
478 : name;
479
480 const assetsByHash = new Map<string, string>();
481 for (const [name, { hash }] of Object.entries(assets.manifest)) {
482 assetsByHash.set(hash, toDiskPath(name));
483 }
484 // Anchored: `assets.directory` may be relative to the initial cwd (a
485 // `Command.Build` outdir), and a live `process.cwd()` read can race a
486 // concurrent tool's transient chdir (framework source builds).
487 const directory = path.resolve(initialCwd, assets.directory);
488
489 // Bucket concurrency, wrangler-style: three in flight, and the first
490 // gateway error resizes it to one for the rest of the deploy. Hoisted out
491 // of the session so a JWT-expiry retry stays degraded instead of bursting
492 // back to three against a gateway that just told us to back off.
493 const semaphore = yield* Semaphore.make(BULK_UPLOAD_CONCURRENCY);
494
495 const uploadBucket = Effect.fn(function* (
496 bucket: readonly string[],
497 uploadJwt: string,
498 ) {
499 const body: Record<string, File> = {};
500 yield* Effect.forEach(
501 bucket,
502 Effect.fn(function* (hash) {
503 const name = assetsByHash.get(hash);
504 if (!name) {
505 return yield* new AssetNotFoundError({
506 message: `Asset ${hash} not found in manifest`,
507 hash,
508 });
509 }
510 const file = yield* fs.readFile(path.join(directory, name)).pipe(
511 Effect.mapError(
512 (error) =>
513 new FailedToReadAssetError({
514 message: `Failed to read asset ${name}: ${error.message}`,
515 name,
516 cause: error,
517 }),
518 ),
519 );
520 body[hash] = new File([Buffer.from(file).toString("base64")], hash, {
521 type: getContentType(name),
522 });
523 }),
524 );
525 return yield* createAssetUpload({
526 accountId,
527 base64: true,
528 body,
529 jwtToken: uploadJwt,
530 }).pipe(
531 Retry.policy(surfaceGatewayErrors),
532 Effect.tapError((error) =>
533 isGatewayError(error)
534 ? semaphore
535 .resize(1)
536 .pipe(
537 Effect.andThen(
538 note(
539 "Asset upload hit a gateway error, retrying one bucket at a time...",
540 { kind: "status" },
541 ),
542 ),
543 )
544 : Effect.void,
545 ),
546 Effect.retry({
547 while: isGatewayError,
548 schedule: Schedule.exponential("2 seconds"),
549 times: MAX_UPLOAD_GATEWAY_RETRIES,
550 }),
551 );
552 });
553
554 // One full upload session: ask Cloudflare which assets are missing,
555 // upload each bucket, and return the completion JWT that putWorker
556 // must redeem. The session JWT can expire mid-upload on very large
557 // asset sets (Unauthorized), and the final bucket response has been
558 // observed in the wild to omit the completion JWT — both cases are
559 // retried below with a fresh session. Already-uploaded assets are
560 // not re-bucketed, so a retry resumes where the last session
561 // stopped, and a fresh session with nothing left to upload returns
562 // the completion JWT directly.
563 const runSession = Effect.fn(function* () {
564 yield* note("Checking assets...", { kind: "status" });
565 const session = dispatchNamespace
566 ? yield* wfp.createDispatchNamespaceScriptAssetUpload({
567 accountId,
568 dispatchNamespace,
569 scriptName: workerName,
570 manifest: assets.manifest,
571 })
572 : yield* workers.createScriptAssetUpload({
573 accountId,
574 scriptName: workerName,
575 manifest: assets.manifest,
576 });
577 if (!session.buckets?.length) {
578 if (!session.jwt) {
579 return yield* new AssetUploadSessionError({
580 message: `Asset upload session for worker ${workerName} returned no completion token`,
581 workerName,
582 });
583 }
584 return session.jwt;
585 }
586 if (!session.jwt) {
587 return yield* new AssetUploadSessionError({
588 message: `Asset upload session for worker ${workerName} returned ${session.buckets.length} buckets to upload but no upload token`,
589 workerName,
590 });
591 }
592 const uploadJwt = session.jwt;
593 let uploaded = 0;
594 const total = session.buckets.flat().length;
595 yield* note(`Uploaded ${uploaded} of ${total} assets...`);
596 // Cloudflare only returns the completion JWT on the last bucket it
597 // receives, which under concurrency is not necessarily the last one
598 // in the list — so every response is checked.
599 let jwt: string | undefined | null;
600 yield* Effect.forEach(
601 session.buckets,
602 Effect.fn(function* (bucket) {
603 const result = yield* uploadBucket(bucket, uploadJwt).pipe(
604 semaphore.withPermits(1),
605 );
606 uploaded += bucket.length;
607 yield* note(`Uploaded ${uploaded} of ${total} assets...`);
608 if (result.jwt) {
609 jwt = result.jwt;
610 }
611 }),
612 { concurrency: BULK_UPLOAD_CONCURRENCY },
613 );
614 if (!jwt) {
615 return yield* new AssetUploadSessionError({
616 message: `Uploaded ${total} assets for worker ${workerName} but Cloudflare did not return a completion token`,
617 workerName,
618 });
619 }
620 return jwt;
621 });
622
623 const jwt = yield* runSession().pipe(
624 Effect.retry({
625 while: (error): boolean =>
626 error._tag === "Unauthorized" ||
627 error._tag === "AssetUploadSessionError",
628 schedule: Schedule.exponential("1 second"),
629 times: 3,
630 }),
631 );
632 return { jwt };
633});