1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as pathe from "pathe";
8import {
9 expectUrlContains,
10 expectUrlHeader,
11 expectUrlRedirect,
12} from "../Utils/Http.ts";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15const directory = pathe.resolve(import.meta.dirname, "fixtures/assets-config");
16
17// Regression for distilled#647: dispatch uploads must serialize asset rules
18// as assets.config._headers / _redirects, just like ordinary Worker uploads.
19test.provider.skipIf(process.env.CLOUDFLARE_TEST_WFP === "0")(
20 "dispatch asset headers and redirects survive keep-assets updates",
21 (stack) =>
22 Effect.gen(function* () {
23 yield* stack.destroy();
24
25 const deploy = (marker: string) =>
26 stack.deploy(
27 Effect.gen(function* () {
28 const namespace =
29 yield* Cloudflare.WorkersForPlatforms.DispatchNamespace(
30 "AssetsConfigNamespace",
31 {},
32 );
33 const worker = yield* Cloudflare.Worker("AssetsConfigUser", {
34 namespace: namespace.name,
35 script: `export default { fetch: () => new Response(${JSON.stringify(marker)}) };`,
36 assets: { directory, hash: "wfp-assets-config-v1" },
37 });
38 const dispatcher = yield* Cloudflare.Worker(
39 "AssetsConfigDispatcher",
40 {
41 workersDev: true,
42 env: { DISPATCH: namespace, USER_SCRIPT: worker.workerName },
43 script: `export default {
44 fetch(request, env) {
45 return env.DISPATCH.get(env.USER_SCRIPT).fetch(request);
46 }
47 };`,
48 },
49 );
50 return { namespace, worker, dispatcher };
51 }),
52 );
53
54 const initial = yield* deploy("wfp-assets-worker-v1");
55 const url = initial.dispatcher.url!;
56 yield* expectUrlContains(`${url}/`, "alchemy-wfp-assets-config", {
57 timeout: "15 seconds",
58 });
59 yield* expectUrlHeader(
60 `${url}/`,
61 "cache-control",
62 "public, max-age=3600",
63 {
64 timeout: "15 seconds",
65 },
66 );
67 yield* expectUrlRedirect(`${url}/old-path`, "/index.html", {
68 status: 301,
69 timeout: "15 seconds",
70 });
71
72 const live = yield* wfp
73 .getDispatchNamespaceScript({
74 accountId: initial.namespace.accountId,
75 dispatchNamespace: initial.namespace.name,
76 scriptName: initial.worker.workerName,
77 })
78 .pipe(
79 Effect.retry({
80 while: (error) =>
81 error._tag === "Forbidden" ||
82 error._tag === "DispatchNamespaceScriptNotFound",
83 schedule: Schedule.fixed("1 second"),
84 times: 8,
85 }),
86 );
87 expect(live.script?.hasAssets).toBe(true);
88
89 // The unchanged asset hash skips upload; changing the script still
90 // performs a PUT, which must resend the header and redirect rules.
91 yield* deploy("wfp-assets-worker-v2");
92 yield* expectUrlContains(`${url}/worker-route`, "wfp-assets-worker-v2", {
93 timeout: "15 seconds",
94 });
95 yield* expectUrlHeader(
96 `${url}/`,
97 "cache-control",
98 "public, max-age=3600",
99 {
100 timeout: "15 seconds",
101 },
102 );
103 yield* expectUrlRedirect(`${url}/old-path`, "/index.html", {
104 status: 301,
105 timeout: "15 seconds",
106 });
107
108 yield* stack.destroy();
109 yield* wfp
110 .getDispatchNamespace({
111 accountId: initial.namespace.accountId,
112 dispatchNamespace: initial.namespace.name,
113 })
114 .pipe(
115 Effect.flatMap(() =>
116 Effect.fail({ _tag: "NamespaceStillExists" } as const),
117 ),
118 Effect.catchTag("DispatchNamespaceNotFound", () => Effect.void),
119 Effect.retry({
120 while: (error) =>
121 error._tag === "NamespaceStillExists" ||
122 error._tag === "Forbidden",
123 schedule: Schedule.fixed("1 second"),
124 times: 8,
125 }),
126 );
127 }),
128 {
129 tags: [
130 "provider:cloudflare",
131 "provider:cloudflare:worker",
132 "provider:cloudflare:workersforplatforms",
133 "live",
134 ],
135 timeout: 120_000,
136 },
137);