| 1 | import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { isResourceOfType, Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.Workers.DispatchNamespace" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | export 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 | |
| 27 | export 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 | |
| 59 | export 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 | */ |
| 148 | export const DispatchNamespace = Resource<DispatchNamespace>(TypeId); |
| 149 | |
| 150 | /** |
| 151 | * Returns true if the given value is a DispatchNamespace resource. |
| 152 | */ |
| 153 | export const isDispatchNamespace = ( |
| 154 | value: unknown, |
| 155 | ): value is DispatchNamespace => isResourceOfType(value, TypeId); |
| 156 | |
| 157 | export 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 | */ |
| 243 | const 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 | |
| 252 | const 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 | }); |