alchemy 2.0.0-beta.35 → 2.0.0-beta.36

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 (58) hide show
  1. package/bin/exec.js +27 -0
  2. package/bin/exec.js.map +1 -1
  3. package/lib/Cli/main.d.ts.map +1 -1
  4. package/lib/Cli/main.js +2 -0
  5. package/lib/Cli/main.js.map +1 -1
  6. package/lib/Cloudflare/Images/Images.d.ts +103 -0
  7. package/lib/Cloudflare/Images/Images.d.ts.map +1 -0
  8. package/lib/Cloudflare/Images/Images.js +82 -0
  9. package/lib/Cloudflare/Images/Images.js.map +1 -0
  10. package/lib/Cloudflare/Images/ImagesBinding.d.ts +73 -0
  11. package/lib/Cloudflare/Images/ImagesBinding.d.ts.map +1 -0
  12. package/lib/Cloudflare/Images/ImagesBinding.js +83 -0
  13. package/lib/Cloudflare/Images/ImagesBinding.js.map +1 -0
  14. package/lib/Cloudflare/Images/index.d.ts +3 -0
  15. package/lib/Cloudflare/Images/index.d.ts.map +1 -0
  16. package/lib/Cloudflare/Images/index.js +3 -0
  17. package/lib/Cloudflare/Images/index.js.map +1 -0
  18. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  19. package/lib/Cloudflare/Providers.js +3 -1
  20. package/lib/Cloudflare/Providers.js.map +1 -1
  21. package/lib/Cloudflare/R2/R2Bucket.d.ts +135 -0
  22. package/lib/Cloudflare/R2/R2Bucket.d.ts.map +1 -1
  23. package/lib/Cloudflare/R2/R2Bucket.js +130 -7
  24. package/lib/Cloudflare/R2/R2Bucket.js.map +1 -1
  25. package/lib/Cloudflare/Workers/InferEnv.d.ts +1 -1
  26. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  27. package/lib/Cloudflare/Workers/Worker.d.ts +5 -5
  28. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  29. package/lib/Cloudflare/Workers/Worker.js +34 -23
  30. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  31. package/lib/Cloudflare/index.d.ts +1 -0
  32. package/lib/Cloudflare/index.d.ts.map +1 -1
  33. package/lib/Cloudflare/index.js +1 -0
  34. package/lib/Cloudflare/index.js.map +1 -1
  35. package/lib/Test/Vitest.d.ts.map +1 -1
  36. package/lib/Test/Vitest.js +2 -0
  37. package/lib/Test/Vitest.js.map +1 -1
  38. package/lib/Util/LocalhostDns.d.ts +19 -0
  39. package/lib/Util/LocalhostDns.d.ts.map +1 -0
  40. package/lib/Util/LocalhostDns.js +55 -0
  41. package/lib/Util/LocalhostDns.js.map +1 -0
  42. package/lib/Util/PlatformServices.d.ts.map +1 -1
  43. package/lib/Util/PlatformServices.js +2 -0
  44. package/lib/Util/PlatformServices.js.map +1 -1
  45. package/lib/tsconfig.test.tsbuildinfo +1 -1
  46. package/package.json +2 -1
  47. package/src/Cli/main.ts +3 -0
  48. package/src/Cloudflare/Images/Images.ts +121 -0
  49. package/src/Cloudflare/Images/ImagesBinding.ts +192 -0
  50. package/src/Cloudflare/Images/index.ts +2 -0
  51. package/src/Cloudflare/Providers.ts +3 -0
  52. package/src/Cloudflare/R2/R2Bucket.ts +240 -7
  53. package/src/Cloudflare/Workers/InferEnv.ts +7 -3
  54. package/src/Cloudflare/Workers/Worker.ts +47 -28
  55. package/src/Cloudflare/index.ts +1 -0
  56. package/src/Test/Vitest.ts +3 -0
  57. package/src/Util/LocalhostDns.ts +58 -0
  58. package/src/Util/PlatformServices.ts +2 -0
@@ -42,6 +42,60 @@ export type R2BucketCustomDomain = {
42
42
  minTLS?: "1.0" | "1.1" | "1.2" | "1.3";
43
43
  };
44
44
 
45
+ export type R2BucketLifecycleCondition =
46
+ | {
47
+ type: "Age";
48
+ /**
49
+ * Maximum age of an object, in seconds, before the rule's action applies.
50
+ */
51
+ maxAge: number;
52
+ }
53
+ | {
54
+ type: "Date";
55
+ /**
56
+ * Absolute date (ISO 8601) at which the rule's action applies.
57
+ */
58
+ date: string;
59
+ };
60
+
61
+ export type R2BucketLifecycleRule = {
62
+ /**
63
+ * Unique identifier for the rule within the bucket.
64
+ */
65
+ id: string;
66
+ /**
67
+ * Whether the rule is enabled.
68
+ * @default true
69
+ */
70
+ enabled?: boolean;
71
+ /**
72
+ * Object key prefix the rule applies to. Use `""` (or omit) to match all
73
+ * objects in the bucket.
74
+ * @default ""
75
+ */
76
+ prefix?: string;
77
+ /**
78
+ * Abort incomplete multipart uploads after the configured age.
79
+ */
80
+ abortMultipartUploadsTransition?: {
81
+ condition?: { type: "Age"; maxAge: number };
82
+ };
83
+ /**
84
+ * Delete matching objects after the configured age or on a specific date.
85
+ */
86
+ deleteObjectsTransition?: {
87
+ condition?: R2BucketLifecycleCondition;
88
+ };
89
+ /**
90
+ * Transition matching objects to a different storage class. Cloudflare R2
91
+ * only supports transitioning to `InfrequentAccess` today.
92
+ */
93
+ storageClassTransitions?: {
94
+ condition: R2BucketLifecycleCondition;
95
+ storageClass: "InfrequentAccess";
96
+ }[];
97
+ };
98
+
45
99
  export type R2BucketProps = {
46
100
  /**
47
101
  * Name of the bucket. If omitted, a unique name will be generated.
@@ -67,6 +121,12 @@ export type R2BucketProps = {
67
121
  * to remove all custom domains.
68
122
  */
69
123
  domains?: R2BucketCustomDomain[];
124
+ /**
125
+ * Object lifecycle rules applied to the bucket. Pass an empty array (or
126
+ * omit) to clear all lifecycle rules. See the Cloudflare R2 docs for
127
+ * supported transitions.
128
+ */
129
+ lifecycleRules?: R2BucketLifecycleRule[];
70
130
  };
71
131
 
72
132
  export type R2Bucket = Resource<
@@ -79,6 +139,7 @@ export type R2Bucket = Resource<
79
139
  location: R2Bucket.Location | undefined;
80
140
  accountId: string;
81
141
  domains: R2Bucket.CustomDomain[];
142
+ lifecycleRules: R2Bucket.LifecycleRule[];
82
143
  },
83
144
  never,
84
145
  Cloudflare.Providers
@@ -175,6 +236,63 @@ export type R2Bucket = Resource<
175
236
  * ],
176
237
  * });
177
238
  * ```
239
+ *
240
+ * @section Object Lifecycle Rules
241
+ *
242
+ * Configure lifecycle rules to automatically delete objects, abort
243
+ * incomplete multipart uploads, or transition objects to InfrequentAccess
244
+ * storage. Pass an empty array (or omit) to clear all rules. See the
245
+ * [Cloudflare R2 docs](https://developers.cloudflare.com/r2/buckets/object-lifecycles/)
246
+ * for details and limits (max 1000 rules per bucket).
247
+ *
248
+ * @example Delete objects 30 days after upload
249
+ * ```typescript
250
+ * const bucket = yield* Cloudflare.R2Bucket("MyBucket", {
251
+ * lifecycleRules: [
252
+ * {
253
+ * id: "expire-old-objects",
254
+ * deleteObjectsTransition: {
255
+ * condition: { type: "Age", maxAge: 60 * 60 * 24 * 30 },
256
+ * },
257
+ * },
258
+ * ],
259
+ * });
260
+ * ```
261
+ *
262
+ * @example Transition to InfrequentAccess after 60 days, delete after 365
263
+ * ```typescript
264
+ * const bucket = yield* Cloudflare.R2Bucket("MyBucket", {
265
+ * lifecycleRules: [
266
+ * {
267
+ * id: "archive-then-delete",
268
+ * prefix: "logs/",
269
+ * storageClassTransitions: [
270
+ * {
271
+ * condition: { type: "Age", maxAge: 60 * 60 * 24 * 60 },
272
+ * storageClass: "InfrequentAccess",
273
+ * },
274
+ * ],
275
+ * deleteObjectsTransition: {
276
+ * condition: { type: "Age", maxAge: 60 * 60 * 24 * 365 },
277
+ * },
278
+ * },
279
+ * ],
280
+ * });
281
+ * ```
282
+ *
283
+ * @example Abort incomplete multipart uploads after 7 days
284
+ * ```typescript
285
+ * const bucket = yield* Cloudflare.R2Bucket("MyBucket", {
286
+ * lifecycleRules: [
287
+ * {
288
+ * id: "abort-stale-uploads",
289
+ * abortMultipartUploadsTransition: {
290
+ * condition: { type: "Age", maxAge: 60 * 60 * 24 * 7 },
291
+ * },
292
+ * },
293
+ * ],
294
+ * });
295
+ * ```
178
296
  */
179
297
  export const R2Bucket = Resource<R2Bucket>("Cloudflare.R2Bucket")({
180
298
  bind: R2BucketBinding.bind,
@@ -184,6 +302,23 @@ export declare namespace R2Bucket {
184
302
  export type StorageClass = "Standard" | "InfrequentAccess";
185
303
  export type Jurisdiction = "default" | "eu" | "fedramp";
186
304
  export type Location = "apac" | "eeur" | "enam" | "weur" | "wnam" | "oc";
305
+ export type LifecycleRule = {
306
+ id: string;
307
+ enabled: boolean;
308
+ prefix: string;
309
+ abortMultipartUploadsTransition:
310
+ | { condition: { type: "Age"; maxAge: number } | undefined }
311
+ | undefined;
312
+ deleteObjectsTransition:
313
+ | { condition: R2BucketLifecycleCondition | undefined }
314
+ | undefined;
315
+ storageClassTransitions:
316
+ | {
317
+ condition: R2BucketLifecycleCondition;
318
+ storageClass: "InfrequentAccess";
319
+ }[]
320
+ | undefined;
321
+ };
187
322
  export type CustomDomain = {
188
323
  domain: string;
189
324
  zoneId: string | undefined;
@@ -224,6 +359,8 @@ export const R2BucketProvider = () =>
224
359
  const createBucketDomainCustom = yield* r2.createBucketDomainCustom;
225
360
  const updateBucketDomainCustom = yield* r2.updateBucketDomainCustom;
226
361
  const deleteBucketDomainCustom = yield* r2.deleteBucketDomainCustom;
362
+ const getBucketLifecycle = yield* r2.getBucketLifecycle;
363
+ const putBucketLifecycle = yield* r2.putBucketLifecycle;
227
364
 
228
365
  const createBucketName = (id: string, name: string | undefined) =>
229
366
  Effect.gen(function* () {
@@ -252,7 +389,7 @@ export const R2BucketProvider = () =>
252
389
  }).pipe(
253
390
  Effect.retry({
254
391
  while: isNoSuchBucket,
255
- schedule: r2CustomDomainConsistencySchedule,
392
+ schedule: r2BucketEndpointConsistencySchedule,
256
393
  }),
257
394
  Effect.map((response) =>
258
395
  response.domains.map(toCustomDomainAttributes),
@@ -351,7 +488,7 @@ export const R2BucketProvider = () =>
351
488
  }).pipe(
352
489
  Effect.retry({
353
490
  while: isNoSuchBucket,
354
- schedule: r2CustomDomainConsistencySchedule,
491
+ schedule: r2BucketEndpointConsistencySchedule,
355
492
  }),
356
493
  );
357
494
  return toCustomDomainAttributes({ ...created, zoneId });
@@ -368,7 +505,7 @@ export const R2BucketProvider = () =>
368
505
  }).pipe(
369
506
  Effect.retry({
370
507
  while: isNoSuchBucket,
371
- schedule: r2CustomDomainConsistencySchedule,
508
+ schedule: r2BucketEndpointConsistencySchedule,
372
509
  }),
373
510
  );
374
511
  return toCustomDomainAttributes({
@@ -383,6 +520,45 @@ export const R2BucketProvider = () =>
383
520
  return applied.sort((a, b) => a.domain.localeCompare(b.domain));
384
521
  });
385
522
 
523
+ const reconcileLifecycleRules = (
524
+ bucketName: string,
525
+ jurisdiction: R2Bucket.Jurisdiction,
526
+ desired: R2BucketLifecycleRule[],
527
+ ) =>
528
+ Effect.gen(function* () {
529
+ const observed = yield* getBucketLifecycle({
530
+ accountId,
531
+ bucketName,
532
+ jurisdiction,
533
+ }).pipe(
534
+ Effect.retry({
535
+ while: isNoSuchBucket,
536
+ schedule: r2BucketEndpointConsistencySchedule,
537
+ }),
538
+ );
539
+
540
+ const observedRules = (observed.rules ?? []).map(toLifecycleRule);
541
+ const desiredRules = desired.map(normalizeLifecycleRule);
542
+
543
+ if (deepEqual(observedRules, desiredRules)) {
544
+ return desiredRules;
545
+ }
546
+
547
+ yield* putBucketLifecycle({
548
+ accountId,
549
+ bucketName,
550
+ jurisdiction,
551
+ rules: desired.map(toLifecyclePutPayload),
552
+ }).pipe(
553
+ Effect.retry({
554
+ while: isNoSuchBucket,
555
+ schedule: r2BucketEndpointConsistencySchedule,
556
+ }),
557
+ );
558
+
559
+ return desiredRules;
560
+ });
561
+
386
562
  return {
387
563
  stables: ["bucketName", "accountId"],
388
564
  diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
@@ -412,6 +588,9 @@ export const R2BucketProvider = () =>
412
588
  if (!deepEqual(olds.domains, news.domains)) {
413
589
  return { action: "update" } as const;
414
590
  }
591
+ if (!deepEqual(olds.lifecycleRules, news.lifecycleRules)) {
592
+ return { action: "update" } as const;
593
+ }
415
594
  }),
416
595
  reconcile: Effect.fn(function* ({ id, news = {}, output }) {
417
596
  const name = yield* createBucketName(id, news.name);
@@ -481,9 +660,16 @@ export const R2BucketProvider = () =>
481
660
  output?.domains ?? [],
482
661
  );
483
662
 
663
+ const lifecycleRules = yield* reconcileLifecycleRules(
664
+ attrs.bucketName,
665
+ attrs.jurisdiction,
666
+ news.lifecycleRules ?? [],
667
+ );
668
+
484
669
  return {
485
670
  ...attrs,
486
671
  domains,
672
+ lifecycleRules,
487
673
  };
488
674
  }),
489
675
  delete: Effect.fn(function* ({ output }) {
@@ -519,6 +705,7 @@ export const R2BucketProvider = () =>
519
705
  location: normalizeLocation(bucket.location),
520
706
  accountId: acct,
521
707
  domains: output?.domains ?? [],
708
+ lifecycleRules: output?.lifecycleRules ?? [],
522
709
  })),
523
710
  Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
524
711
  );
@@ -527,10 +714,11 @@ export const R2BucketProvider = () =>
527
714
  }),
528
715
  );
529
716
 
530
- // R2 can make a newly-created bucket visible to `getBucket` before the custom
531
- // domain endpoints accept it. Retry only that narrow `NoSuchBucket` lag here;
532
- // not-found domains are still treated as terminal for idempotent deletes.
533
- const r2CustomDomainConsistencySchedule = Schedule.exponential(100).pipe(
717
+ // R2 can make a newly-created bucket visible to `getBucket` before its
718
+ // sub-resource endpoints (custom domains, lifecycle) accept it. Retry only
719
+ // that narrow `NoSuchBucket` lag here; not-found sub-resources are still
720
+ // treated as terminal for idempotent deletes.
721
+ const r2BucketEndpointConsistencySchedule = Schedule.exponential(100).pipe(
534
722
  Schedule.both(Schedule.recurs(5)),
535
723
  );
536
724
 
@@ -573,6 +761,51 @@ const isMissingCustomDomainOrBucket = (error: unknown): boolean =>
573
761
  ((error as { _tag: unknown })._tag === "DomainNotFound" ||
574
762
  (error as { _tag: unknown })._tag === "NoSuchBucket")));
575
763
 
764
+ type LifecycleRuleResponse = NonNullable<
765
+ r2.GetBucketLifecycleResponse["rules"]
766
+ >[number];
767
+
768
+ const toLifecycleRule = (
769
+ rule: LifecycleRuleResponse,
770
+ ): R2Bucket.LifecycleRule => ({
771
+ id: rule.id,
772
+ enabled: rule.enabled,
773
+ prefix: rule.conditions.prefix ?? "",
774
+ abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition
775
+ ? { condition: rule.abortMultipartUploadsTransition.condition ?? undefined }
776
+ : undefined,
777
+ deleteObjectsTransition: rule.deleteObjectsTransition
778
+ ? { condition: rule.deleteObjectsTransition.condition ?? undefined }
779
+ : undefined,
780
+ storageClassTransitions: rule.storageClassTransitions ?? undefined,
781
+ });
782
+
783
+ const normalizeLifecycleRule = (
784
+ rule: R2BucketLifecycleRule,
785
+ ): R2Bucket.LifecycleRule => ({
786
+ id: rule.id,
787
+ enabled: rule.enabled ?? true,
788
+ prefix: rule.prefix ?? "",
789
+ abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition
790
+ ? { condition: rule.abortMultipartUploadsTransition.condition }
791
+ : undefined,
792
+ deleteObjectsTransition: rule.deleteObjectsTransition
793
+ ? { condition: rule.deleteObjectsTransition.condition }
794
+ : undefined,
795
+ storageClassTransitions: rule.storageClassTransitions,
796
+ });
797
+
798
+ const toLifecyclePutPayload = (
799
+ rule: R2BucketLifecycleRule,
800
+ ): NonNullable<r2.PutBucketLifecycleRequest["rules"]>[number] => ({
801
+ id: rule.id,
802
+ enabled: rule.enabled ?? true,
803
+ conditions: { prefix: rule.prefix ?? "" },
804
+ abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition,
805
+ deleteObjectsTransition: rule.deleteObjectsTransition,
806
+ storageClassTransitions: rule.storageClassTransitions,
807
+ });
808
+
576
809
  const isNoSuchBucket = (error: unknown): boolean =>
577
810
  typeof error === "object" &&
578
811
  error !== null &&
@@ -27,6 +27,10 @@ type GetBindingType<T> = T extends Cloudflare.Assets
27
27
  ? Ai
28
28
  : T extends Cloudflare.Artifacts
29
29
  ? Artifacts
30
- : T extends Cloudflare.DurableObjectNamespaceLike
31
- ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
32
- : never;
30
+ : T extends Cloudflare.Images
31
+ ? ImagesBinding
32
+ : T extends Cloudflare.Hyperdrive
33
+ ? Hyperdrive
34
+ : T extends Cloudflare.DurableObjectNamespaceLike
35
+ ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
36
+ : never;
@@ -6,7 +6,6 @@ import * as Context from "effect/Context";
6
6
  import * as Data from "effect/Data";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Exit from "effect/Exit";
9
- import * as FileSystem from "effect/FileSystem";
10
9
  import * as Layer from "effect/Layer";
11
10
  import * as Option from "effect/Option";
12
11
  import * as Path from "effect/Path";
@@ -46,7 +45,14 @@ import {
46
45
  import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
47
46
  import { D1Database } from "../D1/D1Database.ts";
48
47
  import { fromCloudflareFetcher } from "../Fetcher.ts";
49
- import type { HyperdriveDevOrigin } from "../Hyperdrive/Hyperdrive.ts";
48
+ import type {
49
+ Hyperdrive,
50
+ HyperdriveDevOrigin,
51
+ } from "../Hyperdrive/Hyperdrive.ts";
52
+ import {
53
+ isImages as isImagesBinding,
54
+ type Images as ImagesBinding,
55
+ } from "../Images/Images.ts";
50
56
  import type { KVNamespace } from "../KV/KVNamespace.ts";
51
57
  import { SidecarLive } from "../Local/Sidecar.ts";
52
58
  import { CloudflareLogs } from "../Logs.ts";
@@ -182,6 +188,8 @@ export type WorkerBindingResource =
182
188
  | CloudflareQueue
183
189
  | AiGateway
184
190
  | ArtifactsBinding
191
+ | ImagesBinding
192
+ | Hyperdrive
185
193
  | DurableObjectNamespaceLike<any>;
186
194
 
187
195
  export type WorkerBindings = {
@@ -734,48 +742,61 @@ export const Worker: Platform<
734
742
  name: bindingName,
735
743
  namespace: binding.namespace,
736
744
  } as any)
737
- : isDurableObjectNamespaceLike(binding)
745
+ : isImagesBinding(binding)
738
746
  ? {
739
- type: "durable_object_namespace",
747
+ type: "images",
740
748
  name: bindingName,
741
- className: binding.className ?? binding.name,
742
749
  }
743
- : binding.Type === "Cloudflare.D1Database"
750
+ : isDurableObjectNamespaceLike(binding)
744
751
  ? {
745
- type: "d1",
746
- id: binding.databaseId,
752
+ type: "durable_object_namespace",
747
753
  name: bindingName,
754
+ className: binding.className ?? binding.name,
748
755
  }
749
- : binding.Type === "Cloudflare.R2Bucket"
756
+ : binding.Type === "Cloudflare.D1Database"
750
757
  ? {
751
- type: "r2_bucket",
758
+ type: "d1",
759
+ id: binding.databaseId,
752
760
  name: bindingName,
753
- bucketName: binding.bucketName,
754
- jurisdiction: binding.jurisdiction.pipe(
755
- Output.map((jurisdiction) =>
756
- jurisdiction === "default" ? undefined : jurisdiction,
757
- ),
758
- ),
759
761
  }
760
- : binding.Type === "Cloudflare.KVNamespace"
762
+ : binding.Type === "Cloudflare.R2Bucket"
761
763
  ? {
762
- type: "kv_namespace",
764
+ type: "r2_bucket",
763
765
  name: bindingName,
764
- namespaceId: binding.namespaceId,
766
+ bucketName: binding.bucketName,
767
+ jurisdiction: binding.jurisdiction.pipe(
768
+ Output.map((jurisdiction) =>
769
+ jurisdiction === "default"
770
+ ? undefined
771
+ : jurisdiction,
772
+ ),
773
+ ),
765
774
  }
766
- : binding.Type === "Cloudflare.Queue"
775
+ : binding.Type === "Cloudflare.KVNamespace"
767
776
  ? {
768
- type: "queue",
777
+ type: "kv_namespace",
769
778
  name: bindingName,
770
- queueName: binding.queueName,
779
+ namespaceId: binding.namespaceId,
771
780
  }
772
- : binding.Type === "Cloudflare.AiGateway"
781
+ : binding.Type === "Cloudflare.Queue"
773
782
  ? {
774
- type: "ai",
783
+ type: "queue",
775
784
  name: bindingName,
785
+ queueName: binding.queueName,
776
786
  }
777
- : // TODO(sam): handle others
778
- undefined;
787
+ : binding.Type === "Cloudflare.AiGateway"
788
+ ? {
789
+ type: "ai",
790
+ name: bindingName,
791
+ }
792
+ : binding.Type === "Cloudflare.Hyperdrive"
793
+ ? {
794
+ type: "hyperdrive",
795
+ name: bindingName,
796
+ id: binding.hyperdriveId,
797
+ }
798
+ : // TODO(sam): handle others
799
+ undefined;
779
800
 
780
801
  if (bindingMeta) {
781
802
  yield* resource.bind`${bindingName}`({
@@ -1059,11 +1080,9 @@ export const LiveWorkerProvider = () =>
1059
1080
  Provider.effect(
1060
1081
  Worker,
1061
1082
  Effect.gen(function* () {
1062
- const fs = yield* FileSystem.FileSystem;
1063
1083
  const path = yield* Path.Path;
1064
1084
 
1065
1085
  const { accountId } = yield* CloudflareEnvironment;
1066
- const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
1067
1086
  const bundler = yield* WorkerBundle;
1068
1087
  const stack = yield* Stack;
1069
1088
 
@@ -6,6 +6,7 @@ export * from "./Container/index.ts";
6
6
  export * from "./D1/index.ts";
7
7
  export * from "./EdgeSession.ts";
8
8
  export * from "./Hyperdrive/index.ts";
9
+ export * from "./Images/index.ts";
9
10
  export * from "./KV/index.ts";
10
11
  export * from "./Providers.ts";
11
12
  export * from "./Queue/index.ts";
@@ -10,8 +10,11 @@ import {
10
10
  import type { AlchemyContext } from "../AlchemyContext.ts";
11
11
  import type { CompiledStack } from "../Stack.ts";
12
12
  import type { Stage } from "../Stage.ts";
13
+ import { installLocalhostDns } from "../Util/LocalhostDns.ts";
13
14
  import * as Core from "./Core.ts";
14
15
 
16
+ installLocalhostDns();
17
+
15
18
  export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
16
19
  export type ScratchStack = Core.ScratchStack;
17
20
  export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Process-level DNS shim that makes `*.localhost` resolve to 127.0.0.1.
3
+ *
4
+ * Background: macOS's `getaddrinfo` does not honor RFC 6761 for arbitrary
5
+ * `*.localhost` names; only the bare `localhost` is mapped to loopback.
6
+ * workerd and browsers handle it themselves, but Node's undici-based fetch
7
+ * (and Bun's libcurl-based fetch on some platforms) fails with `ENOTFOUND`
8
+ * when calling URLs returned by the local Cloudflare worker proxy
9
+ * (e.g. `http://my-worker.localhost:1337`).
10
+ *
11
+ * The proxy in `@distilled.cloud/cloudflare-runtime` dispatches by the
12
+ * URL hostname, so we cannot rewrite the URL client-side to `127.0.0.1`
13
+ * (and `Host` is a forbidden fetch header). The fix is to make the host
14
+ * resolvable at the network layer.
15
+ *
16
+ * Calling `installLocalhostDns()` is idempotent.
17
+ */
18
+
19
+ import { createRequire } from "node:module";
20
+
21
+ let installed = false;
22
+
23
+ const isLocalhost = (hostname: string): boolean =>
24
+ hostname === "localhost" ||
25
+ hostname === "[::1]" ||
26
+ hostname.endsWith(".localhost");
27
+
28
+ export const installLocalhostDns = (): void => {
29
+ if (installed) return;
30
+ installed = true;
31
+ // Bun resolves `*.localhost` natively via its libcurl-based fetch on
32
+ // Darwin and Linux, so the shim is a no-op there. If a future Bun
33
+ // release changes this, install via Bun's undici-compatible global
34
+ // dispatcher here.
35
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return;
36
+ try {
37
+ const require = createRequire(import.meta.url);
38
+ const undici: typeof import("undici") = require("undici");
39
+ const dns: typeof import("node:dns") = require("node:dns");
40
+ undici.setGlobalDispatcher(
41
+ new undici.Agent({
42
+ connect: {
43
+ lookup: (hostname, options, cb: any) => {
44
+ if (isLocalhost(hostname)) {
45
+ if (options && (options as { all?: boolean }).all) {
46
+ return cb(null, [{ address: "127.0.0.1", family: 4 }]);
47
+ }
48
+ return cb(null, "127.0.0.1", 4);
49
+ }
50
+ return dns.lookup(hostname, options, cb);
51
+ },
52
+ },
53
+ }),
54
+ );
55
+ } catch {
56
+ // undici not available; nothing to do.
57
+ }
58
+ };
@@ -9,6 +9,7 @@ import type { HttpServer } from "effect/unstable/http/HttpServer";
9
9
  import type { ServeError } from "effect/unstable/http/HttpServerError";
10
10
  import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
11
11
  import type { WebSocketConstructor } from "effect/unstable/socket/Socket";
12
+ import { installLocalhostDns } from "./LocalhostDns.ts";
12
13
 
13
14
  const isBun = typeof Bun !== "undefined";
14
15
 
@@ -52,6 +53,7 @@ export const runMain = <E, A>(
52
53
  readonly teardown?: Teardown | undefined;
53
54
  },
54
55
  ): void => {
56
+ installLocalhostDns();
55
57
  if (isBun) {
56
58
  void import("@effect/platform-bun/BunRuntime").then((BunRuntime) =>
57
59
  BunRuntime.runMain(effect, options),