alchemy 2.0.0-beta.2 → 2.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/bin/{alchemy-effect.js → alchemy.js} +171 -171
  2. package/bin/alchemy.js.map +1 -0
  3. package/bin/{alchemy-effect.sh → alchemy.sh} +2 -2
  4. package/lib/cli/index.d.ts.map +1 -1
  5. package/lib/cli/index.js +167 -167
  6. package/lib/cli/index.js.map +1 -1
  7. package/package.json +54 -45
  8. package/src/AWS/AGENTS.md +10 -10
  9. package/src/AWS/Assets.ts +1 -1
  10. package/src/AWS/AuthProvider.ts +392 -0
  11. package/src/AWS/Credentials.ts +0 -1
  12. package/src/AWS/DynamoDB/Table.ts +63 -10
  13. package/src/AWS/EC2/VpcEndpoint.ts +4 -4
  14. package/src/AWS/EC2/hosted.ts +1 -1
  15. package/src/AWS/ECS/Task.ts +1 -1
  16. package/src/AWS/Kinesis/Stream.ts +44 -8
  17. package/src/AWS/Lambda/Function.ts +218 -10
  18. package/src/AWS/S3/Bucket.ts +26 -19
  19. package/src/AWS/S3/BucketNotifications.ts +1 -1
  20. package/src/AWS/SNS/Topic.ts +47 -10
  21. package/src/AWS/SQS/Queue.ts +66 -0
  22. package/src/Auth/AuthProvider.ts +33 -0
  23. package/src/Auth/Credentials.ts +56 -0
  24. package/src/Auth/Env.ts +45 -0
  25. package/src/Auth/Profile.ts +72 -0
  26. package/src/Auth/index.ts +2 -0
  27. package/src/Cloudflare/Auth/AuthProvider.ts +670 -0
  28. package/src/Cloudflare/Auth/OAuthClient.ts +315 -0
  29. package/src/Cloudflare/Container/Container.ts +122 -0
  30. package/src/Cloudflare/Container/ContainerApplication.ts +6 -3
  31. package/src/Cloudflare/Container/ContainerBinding.ts +2 -4
  32. package/src/Cloudflare/Container/StartContainer.ts +1 -1
  33. package/src/Cloudflare/D1/D1Database.ts +23 -4
  34. package/src/Cloudflare/KV/KVNamespace.ts +25 -0
  35. package/src/Cloudflare/Providers.ts +1 -6
  36. package/src/Cloudflare/R2/R2Bucket.ts +45 -2
  37. package/src/Cloudflare/Website/StaticSite.ts +101 -15
  38. package/src/Cloudflare/Website/Vite.ts +86 -20
  39. package/src/Cloudflare/Workers/Assets.ts +170 -207
  40. package/src/Cloudflare/Workers/DurableObjectNamespace.ts +629 -0
  41. package/src/Cloudflare/Workers/DurableObjectState.ts +63 -0
  42. package/src/Cloudflare/Workers/DurableObjectStorage.ts +256 -0
  43. package/src/Cloudflare/Workers/DynamicWorkerLoader.ts +66 -7
  44. package/src/Cloudflare/Workers/InferEnv.ts +11 -7
  45. package/src/Cloudflare/Workers/Rpc.ts +13 -2
  46. package/src/Cloudflare/Workers/ScheduledEvents.ts +185 -0
  47. package/src/Cloudflare/Workers/WebSocket.ts +1 -1
  48. package/src/Cloudflare/Workers/Worker.ts +572 -67
  49. package/src/Cloudflare/Workers/Workflow.ts +53 -11
  50. package/src/Cloudflare/Workers/index.ts +4 -1
  51. package/src/Construct.ts +2 -2
  52. package/src/GitHub/Comment.ts +224 -0
  53. package/src/GitHub/Secret.ts +257 -0
  54. package/src/GitHub/Variable.ts +166 -0
  55. package/src/GitHub/index.ts +3 -0
  56. package/src/Kubernetes/client.ts +2 -2
  57. package/src/Output.ts +1 -1
  58. package/src/Platform.ts +10 -5
  59. package/src/Provider.ts +1 -1
  60. package/src/Resource.ts +4 -4
  61. package/src/Test/Vitest.ts +4 -3
  62. package/src/Util/Clank.ts +77 -0
  63. package/src/Util/PlatformServices.ts +21 -0
  64. package/src/Util/dedent.ts +59 -0
  65. package/src/Util/index.ts +1 -0
  66. package/bin/alchemy-effect.js.map +0 -1
  67. package/src/Cloudflare/Workers/DurableObject.ts +0 -527
  68. package/src/Daemon/Client.ts +0 -116
  69. package/src/Daemon/Config.ts +0 -28
  70. package/src/Daemon/Errors.ts +0 -48
  71. package/src/Daemon/Lock.ts +0 -162
  72. package/src/Daemon/ProcessRegistry.ts +0 -284
  73. package/src/Daemon/RpcSchema.ts +0 -43
  74. package/src/Daemon/RpcServer.ts +0 -231
  75. package/src/Daemon/index.ts +0 -27
  76. package/src/Spawn.ts +0 -23
  77. /package/bin/{alchemy-effect.ts → alchemy.ts} +0 -0
@@ -2,6 +2,7 @@ import type * as cf from "@cloudflare/workers-types";
2
2
  import cloudflareRolldown from "@distilled.cloud/cloudflare-rolldown-plugin";
3
3
  import cloudflareVite from "@distilled.cloud/cloudflare-vite-plugin";
4
4
  import * as workers from "@distilled.cloud/cloudflare/workers";
5
+ import * as zones from "@distilled.cloud/cloudflare/zones";
5
6
  import type * as Cause from "effect/Cause";
6
7
  import * as Context from "effect/Context";
7
8
  import * as Data from "effect/Data";
@@ -26,7 +27,7 @@ import { findCwdForBundle } from "../../Bundle/TempRoot.ts";
26
27
  import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts";
27
28
  import { isResolved } from "../../Diff.ts";
28
29
  import type { HttpEffect } from "../../Http.ts";
29
- import type { Input, InputProps } from "../../Input.ts";
30
+ import type { InputProps } from "../../Input.ts";
30
31
  import * as Output from "../../Output.ts";
31
32
  import { createPhysicalName } from "../../PhysicalName.ts";
32
33
  import {
@@ -47,10 +48,20 @@ import { fromCloudflareFetcher } from "../Fetcher.ts";
47
48
  import { CloudflareLogs } from "../Logs.ts";
48
49
  import type { Providers } from "../Providers.ts";
49
50
  import type { R2Bucket } from "../R2/R2Bucket.ts";
50
- import type { AssetsConfig, AssetsProps } from "./Assets.ts";
51
- import * as Assets from "./Assets.ts";
51
+ import {
52
+ isAssets,
53
+ readAssets,
54
+ uploadAssets,
55
+ type Assets,
56
+ type AssetsConfig,
57
+ type AssetsProps,
58
+ } from "./Assets.ts";
52
59
  import cloudflare_workers from "./cloudflare_workers.ts";
53
- import { isDurableObjectExport } from "./DurableObject.ts";
60
+ import {
61
+ isDurableObjectExport,
62
+ isDurableObjectNamespaceLike,
63
+ type DurableObjectNamespaceLike,
64
+ } from "./DurableObjectNamespace.ts";
54
65
  import { workersHttpHandler } from "./HttpServer.ts";
55
66
  import { Request } from "./Request.ts";
56
67
  import { makeRpcStub } from "./Rpc.ts";
@@ -105,12 +116,12 @@ export interface AssetsWithHash {
105
116
  /**
106
117
  * Path to the assets directory.
107
118
  */
108
- path: Input<string>;
119
+ path: string;
109
120
  /**
110
121
  * Pre-computed hash of the assets. When provided, this hash is used for diffing
111
122
  * to determine if the worker needs to be redeployed.
112
123
  */
113
- hash: Input<string>;
124
+ hash: string;
114
125
  /**
115
126
  * Optional assets configuration.
116
127
  */
@@ -161,7 +172,11 @@ export type WorkerServices = Worker | WorkerEnvironment | Request;
161
172
 
162
173
  export type WorkerShape = Main<WorkerServices>;
163
174
 
164
- export type WorkerBindingResource = R2Bucket | D1Database;
175
+ export type WorkerBindingResource =
176
+ | Assets
177
+ | R2Bucket
178
+ | D1Database
179
+ | DurableObjectNamespaceLike<any>;
165
180
 
166
181
  export type WorkerBindings = {
167
182
  [bindingName in string]: WorkerBindingResource;
@@ -173,8 +188,26 @@ export type WorkerBindingProps = {
173
188
  | Effect.Effect<WorkerBindingResource, any, any>;
174
189
  };
175
190
 
191
+ type NormalizedBindings<
192
+ Bindings extends WorkerBindingProps = {},
193
+ AssetsConfig extends WorkerAssetsConfig | undefined = undefined,
194
+ > = {
195
+ [B in keyof Bindings]: Bindings[B] extends Effect.Effect<
196
+ infer T extends WorkerBindingResource,
197
+ any,
198
+ any
199
+ >
200
+ ? T
201
+ : Extract<Bindings[B], WorkerBindingResource>;
202
+ } & (undefined extends AssetsConfig ? {} : { ASSETS: Assets });
203
+
204
+ export type WorkerAssetsConfig = string | AssetsProps | AssetsWithHash;
205
+
176
206
  export interface WorkerProps<
177
207
  Bindings extends WorkerBindingProps = any,
208
+ Assets extends WorkerAssetsConfig | undefined =
209
+ | WorkerAssetsConfig
210
+ | undefined,
178
211
  > extends PlatformProps {
179
212
  /**
180
213
  * Worker name override. If omitted, Alchemy derives a deterministic physical
@@ -192,11 +225,7 @@ export interface WorkerProps<
192
225
  * - An AssetsProps object with directory and config
193
226
  * - An object with path and hash (e.g., from a Build resource)
194
227
  */
195
- assets?:
196
- | string
197
- | AssetsProps
198
- | AssetsWithHash
199
- | (AssetsWithHash & { [K: string]: any });
228
+ assets?: Assets;
200
229
  subdomain?: {
201
230
  enabled?: boolean;
202
231
  previewsEnabled?: boolean;
@@ -219,6 +248,12 @@ export interface WorkerProps<
219
248
  env?: Record<string, string | Redacted.Redacted<string>>;
220
249
  exports?: string[];
221
250
  bindings?: Bindings;
251
+ /**
252
+ * One or more custom hostnames (e.g. `"app.example.com"`) to bind to this
253
+ * Worker. The Cloudflare Zone is inferred from the hostname — the zone must
254
+ * already exist in the account.
255
+ */
256
+ domain?: string | string[];
222
257
  build?: {
223
258
  /**
224
259
  * Whether to generate a metafile for the worker bundle.
@@ -239,6 +274,7 @@ export type Worker<Bindings extends WorkerBindings = any> = Resource<
239
274
  tags: string[] | undefined;
240
275
  durableObjectNamespaces: Record<string, string>;
241
276
  accountId: string;
277
+ domains: { hostname: string; id: string; zoneId: string }[];
242
278
  hash?: {
243
279
  assets: string | undefined;
244
280
  bundle: string | undefined;
@@ -256,17 +292,326 @@ export type Worker<Bindings extends WorkerBindings = any> = Resource<
256
292
  * A Cloudflare Worker host with deploy-time binding support and runtime export
257
293
  * collection.
258
294
  *
259
- * `Worker` behaves like a resource during deploy, but it also carries a runtime
260
- * execution context so KV, R2, Durable Objects, assets, and service bindings
261
- * can be inferred from the worker program itself.
295
+ * A Worker follows a two-phase pattern. The outer `Effect.gen` runs at
296
+ * deploy time to bind resources (KV, R2, Durable Objects, etc.). It returns
297
+ * an object whose properties are the Worker's runtime handlers — `fetch` for
298
+ * HTTP requests and any additional RPC methods.
262
299
  *
263
- * @section Creating Workers
264
- * @example Basic Worker
265
300
  * ```typescript
266
- * const worker = yield* Worker("ApiWorker", {
301
+ * Effect.gen(function* () {
302
+ * // Phase 1: bind resources (runs at deploy time)
303
+ * const kv = yield* Cloudflare.KVNamespace.bind(MyKV);
304
+ *
305
+ * return {
306
+ * // Phase 2: runtime handlers (runs on each request)
307
+ * fetch: Effect.gen(function* () {
308
+ * const value = yield* kv.get("key");
309
+ * return HttpServerResponse.text(value ?? "not found");
310
+ * }),
311
+ * };
312
+ * })
313
+ * ```
314
+ *
315
+ * There are three ways to define a Worker, from simplest to most
316
+ * flexible. See the {@link https://alchemy.run/concepts/platform | Platform concept}
317
+ * page for the full explanation.
318
+ *
319
+ * - **Async** — plain `async fetch` handler, no Effect runtime in the bundle.
320
+ * - **Effect** — Effect implementation passed directly, single file.
321
+ * - **Layer** — class and `.make()` in a single file; Rolldown tree-shakes `.make()` from consumers.
322
+ *
323
+ * @section Async Workers
324
+ * You don't have to use Effect for your runtime code. If you create
325
+ * a Worker resource with `main` pointing at a file but provide no
326
+ * `Effect.gen` implementation, Alchemy bundles and deploys that file
327
+ * as-is. Your handler is a plain `async fetch` — no Effect runtime
328
+ * is included in the bundle.
329
+ *
330
+ * Use the `bindings` prop to declare which resources are available
331
+ * at runtime, and `Cloudflare.InferEnv` to extract a fully typed
332
+ * `env` object from those bindings.
333
+ *
334
+ * See the {@link https://alchemy.run/guides/async-worker | Async Workers Guide}
335
+ * for a comprehensive walkthrough of all binding types (R2, D1,
336
+ * Durable Objects, Assets, and more).
337
+ *
338
+ * @example Defining an async Worker in your stack
339
+ * ```typescript
340
+ * // alchemy.run.ts
341
+ * const db = yield* Cloudflare.D1Database("DB");
342
+ * const bucket = yield* Cloudflare.R2Bucket("Bucket");
343
+ *
344
+ * export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
345
+ *
346
+ * export const Worker = Cloudflare.Worker("Worker", {
267
347
  * main: "./src/worker.ts",
348
+ * bindings: { db, bucket },
349
+ * });
350
+ * ```
351
+ *
352
+ * @example Writing the async handler
353
+ * ```typescript
354
+ * // src/worker.ts
355
+ * import type { WorkerEnv } from "../alchemy.run.ts";
356
+ *
357
+ * export default {
358
+ * async fetch(request: Request, env: WorkerEnv) {
359
+ * if (request.method === "GET") {
360
+ * const object = await env.bucket.get("key");
361
+ * return new Response(object?.body ?? null);
362
+ * }
363
+ * return new Response("Not Found", { status: 404 });
364
+ * },
365
+ * };
366
+ * ```
367
+ *
368
+ * @section Effect Workers
369
+ * Pass the Effect implementation as the third argument. This is the
370
+ * simplest Effect-based approach — everything lives in one file.
371
+ * Convenient for standalone Workers that don't need to be referenced
372
+ * by other Workers.
373
+ *
374
+ * @example Worker Effect
375
+ * ```typescript
376
+ * export default class MyWorker extends Cloudflare.Worker<MyWorker>()(
377
+ * "MyWorker",
378
+ * { main: import.meta.path },
379
+ * Effect.gen(function* () {
380
+ * // init: bind resources
381
+ * const kv = yield* Cloudflare.KVNamespace.bind(MyKV);
382
+ *
383
+ * return {
384
+ * // runtime: use them
385
+ * fetch: Effect.gen(function* () {
386
+ * const value = yield* kv.get("key");
387
+ * return HttpServerResponse.text(value ?? "not found");
388
+ * }),
389
+ * };
390
+ * }),
391
+ * ) {}
392
+ * ```
393
+ *
394
+ * @section Worker Layer
395
+ * When two Workers need to reference each other (e.g. WorkerA calls
396
+ * WorkerB and vice versa), or you simply want optimal tree-shaking,
397
+ * define the Worker class separately from its `.make()` call. The
398
+ * class is a lightweight identifier; `.make()` provides the runtime
399
+ * implementation as an `export default`. Rolldown treats `.make()`
400
+ * as pure, so any Worker that imports the class to bind it will not
401
+ * pull in the `.make()` dependencies — the bundler tree-shakes
402
+ * them away entirely.
403
+ *
404
+ * The class and `.make()` can live in the same file. This is the
405
+ * same pattern used by `Container` and `DurableObjectNamespace`,
406
+ * and is recommended for any cross-Worker or cross-DO bindings.
407
+ *
408
+ * @example Worker Layer (class + .make() in one file)
409
+ * ```typescript
410
+ * // src/WorkerB.ts
411
+ * export default class WorkerB extends Cloudflare.Worker<WorkerB>()(
412
+ * "WorkerB",
413
+ * { main: import.meta.path },
414
+ * ) {}
415
+ *
416
+ * export default WorkerB.make(
417
+ * Effect.gen(function* () {
418
+ * // init: bind resources
419
+ * const kv = yield* Cloudflare.KVNamespace.bind(MyKV);
420
+ *
421
+ * return {
422
+ * // runtime: use them
423
+ * greet: (name: string) =>
424
+ * Effect.gen(function* () {
425
+ * yield* kv.put("last-greeted", name);
426
+ * return `Hello ${name}`;
427
+ * }),
428
+ * };
429
+ * }),
430
+ * );
431
+ * ```
432
+ *
433
+ * @example Binding a Worker Layer from another Worker
434
+ * ```typescript
435
+ * // src/WorkerA.ts — imports WorkerB; bundler tree-shakes .make()
436
+ * import WorkerB from "./WorkerB.ts";
437
+ *
438
+ * export default class WorkerA extends Cloudflare.Worker<WorkerA>()(
439
+ * "WorkerA",
440
+ * { main: import.meta.path },
441
+ * Effect.gen(function* () {
442
+ * const b = yield* Cloudflare.Worker.bind(WorkerB);
443
+ * return {
444
+ * fetch: Effect.gen(function* () {
445
+ * return yield* b.greet("world");
446
+ * }),
447
+ * };
448
+ * }),
449
+ * ) {}
450
+ * ```
451
+ *
452
+ * @section Configuration
453
+ * The props object controls compatibility flags, static assets, and
454
+ * build options. These are evaluated at deploy time.
455
+ *
456
+ * @example Enabling Node.js compatibility
457
+ * ```typescript
458
+ * {
459
+ * main: import.meta.path,
460
+ * compatibility: {
461
+ * flags: ["nodejs_compat"],
462
+ * date: "2026-03-17",
463
+ * },
464
+ * }
465
+ * ```
466
+ *
467
+ * @example Serving static assets
468
+ * ```typescript
469
+ * {
470
+ * main: import.meta.path,
471
+ * assets: "./public",
472
+ * }
473
+ * ```
474
+ *
475
+ * @section R2 Bucket
476
+ * Bind an R2 bucket in the init phase with `Cloudflare.R2Bucket.bind`.
477
+ * The returned handle exposes `get`, `put`, `delete`, and `list`
478
+ * methods you can call in your runtime handlers.
479
+ *
480
+ * @example Binding and using R2
481
+ * ```typescript
482
+ * // init
483
+ * const bucket = yield* Cloudflare.R2Bucket.bind(MyBucket);
484
+ *
485
+ * return {
486
+ * fetch: Effect.gen(function* () {
487
+ * const request = yield* HttpServerRequest;
488
+ * const key = request.url.split("/").pop()!;
489
+ *
490
+ * if (request.method === "GET") {
491
+ * const object = yield* bucket.get(key);
492
+ * return object
493
+ * ? HttpServerResponse.text(yield* object.text())
494
+ * : HttpServerResponse.empty({ status: 404 });
495
+ * }
496
+ *
497
+ * yield* bucket.put(key, request.stream);
498
+ * return HttpServerResponse.empty({ status: 201 });
499
+ * }),
500
+ * };
501
+ * ```
502
+ *
503
+ * @section KV Namespace
504
+ * Bind a KV namespace with `Cloudflare.KVNamespace.bind`. KV provides
505
+ * eventually-consistent, low-latency key-value reads replicated
506
+ * globally across Cloudflare's edge.
507
+ *
508
+ * @example Binding and using KV
509
+ * ```typescript
510
+ * // init
511
+ * const kv = yield* Cloudflare.KVNamespace.bind(MyKV);
512
+ *
513
+ * return {
514
+ * fetch: Effect.gen(function* () {
515
+ * const value = yield* kv.get("my-key");
516
+ * return HttpServerResponse.text(value ?? "not found");
517
+ * }),
518
+ * };
519
+ * ```
520
+ *
521
+ * @section D1 Database
522
+ * Bind a D1 database with `Cloudflare.D1Connection.bind`. D1 is a
523
+ * serverless SQLite database — use `prepare` to build parameterized
524
+ * queries and `all`, `first`, or `run` to execute them.
525
+ *
526
+ * @example Binding and querying D1
527
+ * ```typescript
528
+ * // init
529
+ * const db = yield* Cloudflare.D1Connection.bind(MyDB);
530
+ *
531
+ * return {
532
+ * fetch: Effect.gen(function* () {
533
+ * const results = yield* db
534
+ * .prepare("SELECT * FROM users WHERE id = ?")
535
+ * .bind(userId)
536
+ * .all();
537
+ * return yield* HttpServerResponse.json(results);
538
+ * }),
539
+ * };
540
+ * ```
541
+ *
542
+ * @section Durable Objects
543
+ * Yield a `DurableObjectNamespace` class in the init phase to get a
544
+ * namespace handle. Call `getByName` or `getById` to get a typed RPC
545
+ * stub, then call its methods from your runtime handlers.
546
+ *
547
+ * @example Using a Durable Object
548
+ * ```typescript
549
+ * // init
550
+ * const counters = yield* Counter;
551
+ *
552
+ * return {
553
+ * fetch: Effect.gen(function* () {
554
+ * const counter = counters.getByName("user-123");
555
+ * const value = yield* counter.increment();
556
+ * return HttpServerResponse.text(String(value));
557
+ * }),
558
+ * };
559
+ * ```
560
+ *
561
+ * @section Containers
562
+ * Containers run long-lived processes alongside Durable Objects. Bind
563
+ * one with `Cloudflare.Container.bind` and start it with
564
+ * `Cloudflare.start`. You can call typed methods on the running
565
+ * container or make HTTP requests to its exposed ports.
566
+ *
567
+ * @example Binding and starting a Container
568
+ * ```typescript
569
+ * // init (inside a DurableObjectNamespace)
570
+ * const sandbox = yield* Cloudflare.Container.bind(Sandbox);
571
+ *
572
+ * return Effect.gen(function* () {
573
+ * const container = yield* Cloudflare.start(sandbox);
574
+ *
575
+ * return {
576
+ * exec: (cmd: string) => container.exec(cmd),
577
+ * fetch: Effect.gen(function* () {
578
+ * const { fetch } = yield* container.getTcpPort(3000);
579
+ * const res = yield* fetch(HttpClientRequest.get("http://container/"));
580
+ * return HttpServerResponse.fromClientResponse(res);
581
+ * }),
582
+ * };
268
583
  * });
269
584
  * ```
585
+ *
586
+ * @section Dynamic Workers
587
+ * `DynamicWorkerLoader` lets you spin up ephemeral Workers at runtime
588
+ * from inline JavaScript modules. This is useful for sandboxing
589
+ * user-provided code or running untrusted scripts in isolation.
590
+ *
591
+ * @example Loading a dynamic Worker
592
+ * ```typescript
593
+ * // init
594
+ * const loader = yield* Cloudflare.DynamicWorkerLoader("Loader");
595
+ *
596
+ * return {
597
+ * fetch: Effect.gen(function* () {
598
+ * const worker = loader.load({
599
+ * compatibilityDate: "2026-01-28",
600
+ * mainModule: "worker.js",
601
+ * modules: {
602
+ * "worker.js": `export default {
603
+ * async fetch(req) { return new Response("sandboxed"); }
604
+ * }`,
605
+ * },
606
+ * });
607
+ *
608
+ * const res = yield* worker.fetch(
609
+ * HttpClientRequest.get("https://worker/"),
610
+ * );
611
+ * return HttpServerResponse.fromClientResponse(res);
612
+ * }),
613
+ * };
614
+ * ```
270
615
  */
271
616
  export const Worker: Platform<
272
617
  Worker,
@@ -274,19 +619,24 @@ export const Worker: Platform<
274
619
  WorkerShape,
275
620
  WorkerExecutionContext
276
621
  > & {
277
- <const Bindings extends WorkerBindingProps>(
622
+ <
623
+ const Bindings extends WorkerBindingProps,
624
+ const Assets extends WorkerAssetsConfig | undefined = undefined,
625
+ Req = never,
626
+ >(
278
627
  id: string,
279
- props: InputProps<WorkerProps<Bindings>>,
628
+ props:
629
+ | InputProps<WorkerProps<Bindings, Assets>>
630
+ | Effect.Effect<InputProps<WorkerProps<Bindings, Assets>>, never, Req>,
280
631
  ): Effect.Effect<
281
632
  Worker<{
282
- [B in keyof Bindings]: Bindings[B] extends Effect.Effect<
283
- infer T extends WorkerBindingResource,
284
- any,
285
- any
286
- >
287
- ? T
288
- : Extract<Bindings[B], WorkerBindingResource>;
289
- }>
633
+ [binding in keyof NormalizedBindings<
634
+ Bindings,
635
+ Assets
636
+ >]: NormalizedBindings<Bindings, Assets>[binding];
637
+ }>,
638
+ never,
639
+ Req | Providers
290
640
  >;
291
641
  } = Platform(WorkerTypeId, {
292
642
  onCreate: Effect.fnUntraced(function* (
@@ -303,31 +653,45 @@ export const Worker: Platform<
303
653
  ? yield* bindingEff
304
654
  : bindingEff;
305
655
 
306
- const bindingMeta: InputProps<WorkerBinding> | undefined =
307
- binding.Type === "Cloudflare.D1Database"
656
+ const bindingMeta: InputProps<WorkerBinding> | undefined = isAssets(
657
+ binding,
658
+ )
659
+ ? {
660
+ type: "assets",
661
+ name: bindingName,
662
+ }
663
+ : isDurableObjectNamespaceLike(binding)
308
664
  ? {
309
- type: "d1",
310
- id: binding.databaseId,
665
+ type: "durable_object_namespace",
311
666
  name: bindingName,
667
+ className: binding.className ?? binding.name,
312
668
  }
313
- : binding.Type === "Cloudflare.R2Bucket"
669
+ : binding.Type === "Cloudflare.D1Database"
314
670
  ? {
315
- type: "r2_bucket",
671
+ type: "d1",
672
+ id: binding.databaseId,
316
673
  name: bindingName,
317
- bucketName: binding.bucketName,
318
- jurisdiction: binding.jurisdiction.pipe(
319
- Output.map((jurisdiction) =>
320
- jurisdiction === "default" ? undefined : jurisdiction,
321
- ),
322
- ),
323
674
  }
324
- : // TODO(sam): handle others
325
- undefined;
675
+ : binding.Type === "Cloudflare.R2Bucket"
676
+ ? {
677
+ type: "r2_bucket",
678
+ name: bindingName,
679
+ bucketName: binding.bucketName,
680
+ jurisdiction: binding.jurisdiction.pipe(
681
+ Output.map((jurisdiction) =>
682
+ jurisdiction === "default" ? undefined : jurisdiction,
683
+ ),
684
+ ),
685
+ }
686
+ : // TODO(sam): handle others
687
+ undefined;
326
688
 
327
689
  if (bindingMeta) {
328
690
  yield* resource.bind`${bindingName}`({
329
691
  bindings: [bindingMeta],
330
692
  });
693
+ } else {
694
+ return yield* Effect.die(`Unknown binding type: ${bindingName}`);
331
695
  }
332
696
  }
333
697
  }
@@ -552,7 +916,6 @@ export const WorkerProvider = () =>
552
916
  const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
553
917
  const stack = yield* Stack;
554
918
 
555
- const { read, upload } = yield* Assets.Assets;
556
919
  const createScriptSubdomain = yield* workers.createScriptSubdomain;
557
920
  const createScriptTail = yield* workers.createScriptTail;
558
921
  const deleteScript = yield* workers.deleteScript;
@@ -562,6 +925,10 @@ export const WorkerProvider = () =>
562
925
  const getSubdomain = yield* workers.getSubdomain;
563
926
  const listScripts = yield* workers.listScripts;
564
927
  const putScript = yield* workers.putScript;
928
+ const putDomain = yield* workers.putDomain;
929
+ const listDomains = yield* workers.listDomains;
930
+ const deleteDomain = yield* workers.deleteDomain;
931
+ const listZones = yield* zones.listZones;
565
932
  const telemetry = yield* CloudflareLogs;
566
933
  const defaultCompatibilityDate = yield* Effect.promise(() =>
567
934
  // @ts-expect-error no types for workerd
@@ -588,6 +955,95 @@ export const WorkerProvider = () =>
588
955
  maxLength: 54,
589
956
  }).pipe(Effect.map((name) => name.toLowerCase()));
590
957
 
958
+ const normalizeDomains = (
959
+ domain: string | string[] | undefined,
960
+ ): string[] =>
961
+ domain === undefined
962
+ ? []
963
+ : Array.isArray(domain)
964
+ ? Array.from(new Set(domain))
965
+ : [domain];
966
+
967
+ /**
968
+ * Infer the Cloudflare Zone ID for a given hostname by listing the
969
+ * account's zones and matching the hostname against each zone's name —
970
+ * walking up the DNS label hierarchy until a match is found.
971
+ */
972
+ const inferZoneIdForHostname = (
973
+ hostname: string,
974
+ zoneCache: Map<string, string>,
975
+ ) =>
976
+ Effect.gen(function* () {
977
+ const cached = zoneCache.get(hostname);
978
+ if (cached) return cached;
979
+
980
+ const zoneList = yield* listZones({}).pipe(
981
+ Effect.map((response) => response.result ?? []),
982
+ );
983
+ for (const zone of zoneList) {
984
+ zoneCache.set(zone.name, zone.id);
985
+ }
986
+
987
+ const parts = hostname.split(".");
988
+ for (let i = 0; i < parts.length - 1; i++) {
989
+ const candidate = parts.slice(i).join(".");
990
+ const match = zoneList.find((z) => z.name === candidate);
991
+ if (match) {
992
+ zoneCache.set(hostname, match.id);
993
+ return match.id;
994
+ }
995
+ }
996
+ return yield* Effect.die(
997
+ `Could not infer Cloudflare Zone for hostname "${hostname}". ` +
998
+ "Ensure the parent zone exists in this account.",
999
+ );
1000
+ });
1001
+
1002
+ const reconcileDomains = (
1003
+ scriptName: string,
1004
+ desired: string[],
1005
+ previous: Worker["Attributes"]["domains"],
1006
+ ) =>
1007
+ Effect.gen(function* () {
1008
+ const desiredSet = new Set(desired);
1009
+ const toRemove = previous.filter((p) => !desiredSet.has(p.hostname));
1010
+ yield* Effect.all(
1011
+ toRemove.map((d) =>
1012
+ deleteDomain({ accountId, domainId: d.id }).pipe(
1013
+ Effect.catchTag("DomainNotFound", () => Effect.void),
1014
+ ),
1015
+ ),
1016
+ { concurrency: "unbounded" },
1017
+ );
1018
+
1019
+ if (desired.length === 0) return [];
1020
+
1021
+ const zoneCache = new Map<string, string>();
1022
+ const applied = yield* Effect.all(
1023
+ desired.map((hostname) =>
1024
+ Effect.gen(function* () {
1025
+ const zoneId = yield* inferZoneIdForHostname(
1026
+ hostname,
1027
+ zoneCache,
1028
+ );
1029
+ const res = yield* putDomain({
1030
+ accountId,
1031
+ hostname,
1032
+ service: scriptName,
1033
+ zoneId,
1034
+ });
1035
+ return {
1036
+ hostname,
1037
+ id: res.id ?? "",
1038
+ zoneId: res.zoneId ?? zoneId,
1039
+ };
1040
+ }),
1041
+ ),
1042
+ { concurrency: "unbounded" },
1043
+ );
1044
+ return applied;
1045
+ });
1046
+
591
1047
  const createAlchemyWorkerTags = (id: string) => [
592
1048
  `alchemy:stack:${stack.name}`,
593
1049
  `alchemy:stage:${stack.stage}`,
@@ -669,7 +1125,9 @@ export const WorkerProvider = () =>
669
1125
  const prepareAssets = Effect.fnUntraced(function* (
670
1126
  assets: WorkerProps["assets"],
671
1127
  ) {
672
- if (!assets) return undefined;
1128
+ if (!assets) {
1129
+ return undefined;
1130
+ }
673
1131
 
674
1132
  // Handle AssetsWithHash (from Build resource)
675
1133
  // Props are resolved by Plan, so Input<string> values are already strings at runtime
@@ -678,7 +1136,7 @@ export const WorkerProvider = () =>
678
1136
  "path" in assets &&
679
1137
  "hash" in assets
680
1138
  ) {
681
- const result = yield* read({
1139
+ const result = yield* readAssets({
682
1140
  directory: assets.path as string,
683
1141
  config: assets.config,
684
1142
  });
@@ -689,7 +1147,7 @@ export const WorkerProvider = () =>
689
1147
  }
690
1148
 
691
1149
  // Handle string path or AssetsProps
692
- return yield* read(
1150
+ return yield* readAssets(
693
1151
  typeof assets === "string" ? { directory: assets } : assets,
694
1152
  );
695
1153
  });
@@ -760,8 +1218,8 @@ import * as Stream from "effect/Stream";
760
1218
  import { env, DurableObject${hasWfClasses ? ", WorkflowEntrypoint" : ""} } from "cloudflare:workers";
761
1219
  import { MinimumLogLevel } from "effect/References";
762
1220
  import { NodeServices } from "@effect/platform-node";
763
- import { Stack } from "alchemy-effect/Stack";
764
- import { WorkerEnvironment, makeDurableObjectBridge${hasWfClasses ? ", makeWorkflowBridge" : ""}, ExportedHandlerMethods } from "alchemy-effect/Cloudflare";
1221
+ import { Stack } from "alchemy/Stack";
1222
+ import { WorkerEnvironment, makeDurableObjectBridge${hasWfClasses ? ", makeWorkflowBridge" : ""}, ExportedHandlerMethods } from "alchemy/Cloudflare";
765
1223
 
766
1224
  import entry from "${importPath}";
767
1225
 
@@ -927,7 +1385,7 @@ ${[
927
1385
  const [assets, bundle] = yield* Effect.all(
928
1386
  [
929
1387
  assetsDirectory
930
- ? read({
1388
+ ? readAssets({
931
1389
  directory: assetsDirectory,
932
1390
  config:
933
1391
  typeof props.assets === "object" && "config" in props.assets
@@ -1007,7 +1465,12 @@ ${[
1007
1465
  yield* Effect.logInfo(
1008
1466
  `Cloudflare Worker ${olds ? "update" : "create"}: uploading assets for ${name}`,
1009
1467
  );
1010
- const { jwt } = yield* upload(accountId, name, assets, session);
1468
+ const { jwt } = yield* uploadAssets(
1469
+ accountId,
1470
+ name,
1471
+ assets,
1472
+ session,
1473
+ );
1011
1474
  metadataAssets = {
1012
1475
  jwt,
1013
1476
  config: assets.config,
@@ -1281,6 +1744,18 @@ ${[
1281
1744
  );
1282
1745
  yield* setWorkerSubdomain(name, enable);
1283
1746
  }
1747
+ const desiredDomains = normalizeDomains(news.domain);
1748
+ const previousDomains = output?.domains ?? [];
1749
+ if (desiredDomains.length > 0 || previousDomains.length > 0) {
1750
+ yield* session.note(
1751
+ `Reconciling custom domains (${desiredDomains.length}) ...`,
1752
+ );
1753
+ }
1754
+ const domains = yield* reconcileDomains(
1755
+ name,
1756
+ desiredDomains,
1757
+ previousDomains,
1758
+ );
1284
1759
  return {
1285
1760
  workerId: worker.id ?? name,
1286
1761
  workerName: name,
@@ -1292,6 +1767,7 @@ ${[
1292
1767
  tags: settings.tags ?? metadata.tags,
1293
1768
  durableObjectNamespaces,
1294
1769
  accountId,
1770
+ domains,
1295
1771
  hash,
1296
1772
  } satisfies Worker["Attributes"];
1297
1773
  });
@@ -1337,7 +1813,14 @@ ${[
1337
1813
  if (!output) {
1338
1814
  return;
1339
1815
  }
1340
- if (yield* hasChanged(id, news, output)) {
1816
+ const newDomains = normalizeDomains(news.domain).sort();
1817
+ const oldDomains = (output?.domains ?? [])
1818
+ .map((d) => d.hostname)
1819
+ .sort();
1820
+ const domainsChanged =
1821
+ newDomains.length !== oldDomains.length ||
1822
+ newDomains.some((d, i) => d !== oldDomains[i]);
1823
+ if (domainsChanged || (yield* hasChanged(id, news, output))) {
1341
1824
  return {
1342
1825
  action: "update",
1343
1826
  stables:
@@ -1466,6 +1949,7 @@ ${[
1466
1949
  tags: existingSettings?.tags ?? tags,
1467
1950
  durableObjectNamespaces,
1468
1951
  accountId,
1952
+ domains: [],
1469
1953
  } satisfies Worker["Attributes"];
1470
1954
  }),
1471
1955
  read: Effect.fnUntraced(
@@ -1475,23 +1959,28 @@ ${[
1475
1959
  yield* Effect.logInfo(
1476
1960
  `Cloudflare Worker read: checking ${workerName}`,
1477
1961
  );
1478
- const [worker, subdomain, settings] = yield* Effect.all([
1479
- listScripts({
1480
- accountId,
1481
- }).pipe(
1482
- Effect.map((workers) =>
1483
- workers.result.find((worker) => worker.id === workerName),
1962
+ const [worker, subdomain, settings, domainsList] =
1963
+ yield* Effect.all([
1964
+ listScripts({
1965
+ accountId,
1966
+ }).pipe(
1967
+ Effect.map((workers) =>
1968
+ workers.result.find((worker) => worker.id === workerName),
1969
+ ),
1484
1970
  ),
1485
- ),
1486
- getScriptSubdomain({
1487
- accountId,
1488
- scriptName: workerName,
1489
- }),
1490
- getScriptSettings({
1491
- accountId,
1492
- scriptName: workerName,
1493
- }),
1494
- ]);
1971
+ getScriptSubdomain({
1972
+ accountId,
1973
+ scriptName: workerName,
1974
+ }),
1975
+ getScriptSettings({
1976
+ accountId,
1977
+ scriptName: workerName,
1978
+ }),
1979
+ listDomains({
1980
+ accountId,
1981
+ service: workerName,
1982
+ }).pipe(Effect.map((r) => r.result ?? [])),
1983
+ ]);
1495
1984
  if (!worker) {
1496
1985
  yield* Effect.logInfo(
1497
1986
  `Cloudflare Worker read: ${workerName} not found in script list`,
@@ -1513,6 +2002,11 @@ ${[
1513
2002
  durableObjectNamespaces: getDurableObjectNamespaces(
1514
2003
  settings.bindings,
1515
2004
  ),
2005
+ domains: domainsList.flatMap((d) =>
2006
+ d.id && d.hostname && d.zoneId
2007
+ ? [{ id: d.id, hostname: d.hostname, zoneId: d.zoneId }]
2008
+ : [],
2009
+ ),
1516
2010
  } satisfies Worker["Attributes"];
1517
2011
  },
1518
2012
  (effect) =>
@@ -1614,6 +2108,17 @@ ${[
1614
2108
  yield* Effect.logInfo(
1615
2109
  `Cloudflare Worker delete: deleting ${output.workerName}`,
1616
2110
  );
2111
+ if (output.domains?.length) {
2112
+ yield* Effect.all(
2113
+ output.domains.map((d) =>
2114
+ deleteDomain({
2115
+ accountId: output.accountId,
2116
+ domainId: d.id,
2117
+ }).pipe(Effect.catchTag("DomainNotFound", () => Effect.void)),
2118
+ ),
2119
+ { concurrency: "unbounded" },
2120
+ );
2121
+ }
1617
2122
  yield* deleteScript({
1618
2123
  accountId: output.accountId,
1619
2124
  scriptName: output.workerName,