1import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { isResourceOfType, Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Workers.DispatchNamespace" as const;
13type TypeId = typeof TypeId;
14
15export interface DispatchNamespaceProps {
16 /**
17 * Name of the dispatch namespace. Must be lowercase, alphanumeric, and
18 * contain no spaces or special characters except dashes. The name is the
19 * namespace's identity — there is no rename API, so changing it triggers
20 * a replacement. If omitted, a unique name is generated from the app,
21 * stage, and logical ID.
22 * @default ${app}-${id}-${stage}-${suffix}
23 */
24 name?: string;
25}
26
27export interface DispatchNamespaceAttributes {
28 /**
29 * API Resource UUID tag assigned by Cloudflare.
30 */
31 namespaceId: string;
32 /**
33 * Name of the dispatch namespace.
34 */
35 name: string;
36 /**
37 * The Cloudflare account the namespace belongs to.
38 */
39 accountId: string;
40 /**
41 * The current number of scripts in this dispatch namespace.
42 */
43 scriptCount: number;
44 /**
45 * Whether the Workers in the namespace are executed in a "trusted"
46 * manner (access to shared zone caches and `request.cf`).
47 */
48 trustedWorkers: boolean;
49 /**
50 * When the namespace was created.
51 */
52 createdOn: string | undefined;
53 /**
54 * When the namespace was last modified.
55 */
56 modifiedOn: string | undefined;
57}
58
59export type DispatchNamespace = Resource<
60 TypeId,
61 DispatchNamespaceProps,
62 DispatchNamespaceAttributes,
63 never,
64 Providers
65>;
66
67/**
68 * A Workers for Platforms dispatch namespace — a container for customer
69 * ("user") Workers that a platform Worker dispatches to at runtime via a
70 * dynamic-dispatch binding.
71 *
72 * The namespace has no mutable properties: its `name` is its identity, so
73 * changing the name triggers a replacement. Deleting a namespace also
74 * deletes every script uploaded into it.
75 *
76 * Note: Workers for Platforms is a paid add-on. On accounts without the
77 * subscription, namespace creation fails with an entitlement error.
78 * ### Creating a Dispatch Namespace
79 * **Example:** Namespace with a generated name
80 * ```typescript
81 * const namespace = yield* Cloudflare.WorkersForPlatforms.DispatchNamespace("Customers", {});
82 * ```
83 *
84 * **Example:** Namespace with an explicit name
85 * ```typescript
86 * const namespace = yield* Cloudflare.WorkersForPlatforms.DispatchNamespace("Customers", {
87 * name: "my-platform-customers",
88 * });
89 * ```
90 *
91 * ### Uploading user Workers
92 * **Example:** Upload a customer Worker into the namespace
93 * A {@link Cloudflare.Worker} deploys into the namespace as a "user worker"
94 * (rather than as a routable account-level script) when its `namespace` prop is
95 * set. Reference the namespace by its `name` output so it deploys first.
96 * ```typescript
97 * const namespace = yield* Cloudflare.WorkersForPlatforms.DispatchNamespace("Customers", {});
98 *
99 * const customerA = yield* Cloudflare.Worker("CustomerA", {
100 * namespace: namespace.name,
101 * script: `export default { fetch() { return new Response("hi"); } }`,
102 * });
103 * ```
104 *
105 * ### Dispatching from a platform Worker
106 * **Example:** Effect-native binding via `Get`
107 * `Cloudflare.WorkersForPlatforms.Get(namespace)` binds the namespace and
108 * returns an Effect-native client; `get(name)` resolves a user Worker by script
109 * name. Provide {@link GetBinding} on the Worker's runtime layer.
110 * ```typescript
111 * const dispatch = yield* Cloudflare.WorkersForPlatforms.Get(namespace);
112 *
113 * return {
114 * fetch: Effect.gen(function* () {
115 * const request = yield* HttpServerRequest;
116 * const userWorker = yield* dispatch.get("CustomerA");
117 * return yield* Effect.promise(() => userWorker.fetch(request));
118 * }),
119 * };
120 * ```
121 *
122 * **Example:** Async binding via `env` + `InferEnv`
123 * Passing the namespace on a Worker's `env` binds it as a native
124 * `dispatch_namespace` binding; `Cloudflare.InferEnv` types `env.DISPATCH` as
125 * the runtime `DispatchNamespace`, so the async handler calls `.get(name)`
126 * directly.
127 * ```typescript
128 * const platform = Cloudflare.Worker("Platform", {
129 * main: "./handler.ts",
130 * env: { DISPATCH: namespace },
131 * });
132 * type Env = Cloudflare.InferEnv<typeof platform>;
133 *
134 * // handler.ts
135 * export default {
136 * async fetch(request: Request, env: Env) {
137 * return env.DISPATCH.get("CustomerA").fetch(request);
138 * },
139 * };
140 * ```
141 *
142 * @see https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/
143 *
144 * @resource
145 * @product Workers for Platforms
146 * @category Workers & Compute
147 */
148export const DispatchNamespace = Resource<DispatchNamespace>(TypeId);
149
150/**
151 * Returns true if the given value is a DispatchNamespace resource.
152 */
153export const isDispatchNamespace = (
154 value: unknown,
155): value is DispatchNamespace => isResourceOfType(value, TypeId);
156
157export const DispatchNamespaceProvider = () =>
158 Provider.succeed(DispatchNamespace, {
159 stables: ["namespaceId", "name", "accountId", "createdOn"],
160 diff: Effect.fn(function* ({ olds, news, output }) {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 if (!isResolved(news)) return undefined;
163 if ((output?.accountId ?? accountId) !== accountId) {
164 return { action: "replace" } as const;
165 }
166 // The name is the namespace's identity — no rename API. When `name`
167 // is omitted on both sides, the generated physical name is identical
168 // (same logical ID + instance ID), so an omitted name never diffs.
169 const oldName = output?.name ?? olds?.name;
170 const newName = news.name ?? olds?.name ?? oldName;
171 if (oldName !== undefined && oldName !== newName) {
172 return { action: "replace" } as const;
173 }
174 return undefined;
175 }),
176 list: Effect.fn(function* () {
177 const { accountId } = yield* yield* CloudflareEnvironment;
178 return yield* wfp.listDispatchNamespaces.pages({ accountId }).pipe(
179 Stream.runCollect,
180 Effect.map((chunk) =>
181 Array.from(chunk).flatMap((page) =>
182 (page.result ?? []).map((ns) =>
183 toAttributes(ns, accountId, ns.namespaceName ?? ""),
184 ),
185 ),
186 ),
187 );
188 }),
189 read: Effect.fn(function* ({ id, output, olds }) {
190 const { accountId } = yield* yield* CloudflareEnvironment;
191 const acct = output?.accountId ?? accountId;
192 // The name is the identity — a cold read (lost state) lands on the
193 // same deterministic name as reconcile would.
194 const name =
195 output?.name ??
196 olds?.name ??
197 (yield* createPhysicalName({ id, lowercase: true }));
198 const observed = yield* getNamespace(acct, name);
199 return observed ? toAttributes(observed, acct, name) : undefined;
200 }),
201 reconcile: Effect.fn(function* ({ id, news, output }) {
202 const { accountId } = yield* yield* CloudflareEnvironment;
203 const name =
204 news.name ?? (yield* createPhysicalName({ id, lowercase: true }));
205
206 // Observe — namespaces are looked up by name; `output` is only a
207 // cache of the same identity. A missing namespace falls through to
208 // the ensure step.
209 const observed = yield* getNamespace(
210 output?.accountId ?? accountId,
211 name,
212 );
213 if (observed) {
214 // Existence-only resource — nothing mutable to sync.
215 return toAttributes(observed, output?.accountId ?? accountId, name);
216 }
217
218 // Ensure — create, tolerating the already-exists race by re-reading.
219 const created = yield* wfp
220 .createDispatchNamespace({ accountId, name })
221 .pipe(
222 Effect.catchTag("DispatchNamespaceAlreadyExists", () =>
223 wfp.getDispatchNamespace({ accountId, dispatchNamespace: name }),
224 ),
225 );
226 return toAttributes(created, accountId, name);
227 }),
228 delete: Effect.fn(function* ({ output }) {
229 // Deleting a namespace also deletes the scripts inside it.
230 yield* wfp
231 .deleteDispatchNamespace({
232 accountId: output.accountId,
233 dispatchNamespace: output.name,
234 })
235 .pipe(Effect.catchTag("DispatchNamespaceNotFound", () => Effect.void));
236 }),
237 });
238
239/**
240 * Read a namespace by name, mapping "gone" (`DispatchNamespaceNotFound`,
241 * Cloudflare error code 100119) to `undefined`.
242 */
243const getNamespace = (accountId: string, name: string) =>
244 wfp
245 .getDispatchNamespace({ accountId, dispatchNamespace: name })
246 .pipe(
247 Effect.catchTag("DispatchNamespaceNotFound", () =>
248 Effect.succeed(undefined),
249 ),
250 );
251
252const toAttributes = (
253 ns: wfp.GetDispatchNamespaceResponse | wfp.CreateDispatchNamespaceResponse,
254 accountId: string,
255 name: string,
256): DispatchNamespaceAttributes => ({
257 namespaceId: ns.namespaceId ?? "",
258 name: ns.namespaceName ?? name,
259 accountId,
260 scriptCount: ns.scriptCount ?? 0,
261 trustedWorkers: ns.trustedWorkers ?? false,
262 createdOn: ns.createdOn ?? undefined,
263 modifiedOn: ns.modifiedOn ?? undefined,
264});