alchemy 2.0.0-beta.82 → 2.0.0-beta.83

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 (72) hide show
  1. package/lib/Cloudflare/Containers/Container.d.ts +128 -40
  2. package/lib/Cloudflare/Containers/Container.d.ts.map +1 -1
  3. package/lib/Cloudflare/Containers/Container.js +124 -24
  4. package/lib/Cloudflare/Containers/Container.js.map +1 -1
  5. package/lib/Cloudflare/Containers/ContainerApplication.d.ts +59 -50
  6. package/lib/Cloudflare/Containers/ContainerApplication.d.ts.map +1 -1
  7. package/lib/Cloudflare/Containers/ContainerApplication.js.map +1 -1
  8. package/lib/Cloudflare/Containers/ContainerClient.d.ts +20 -7
  9. package/lib/Cloudflare/Containers/ContainerClient.d.ts.map +1 -1
  10. package/lib/Cloudflare/Containers/ContainerClient.js +22 -5
  11. package/lib/Cloudflare/Containers/ContainerClient.js.map +1 -1
  12. package/lib/Cloudflare/Containers/ContainerConfiguration.d.ts +132 -13
  13. package/lib/Cloudflare/Containers/ContainerConfiguration.d.ts.map +1 -1
  14. package/lib/Cloudflare/Containers/ContainerConfiguration.js +219 -35
  15. package/lib/Cloudflare/Containers/ContainerConfiguration.js.map +1 -1
  16. package/lib/Cloudflare/Containers/ContainerPlatform.d.ts +1 -1
  17. package/lib/Cloudflare/Containers/ContainerPlatform.d.ts.map +1 -1
  18. package/lib/Cloudflare/Containers/ContainerPlatform.js +21 -2
  19. package/lib/Cloudflare/Containers/ContainerPlatform.js.map +1 -1
  20. package/lib/Cloudflare/Containers/ContainerProvider.d.ts.map +1 -1
  21. package/lib/Cloudflare/Containers/ContainerProvider.js +74 -177
  22. package/lib/Cloudflare/Containers/ContainerProvider.js.map +1 -1
  23. package/lib/Cloudflare/Containers/DurableObjectApplication.d.ts +84 -0
  24. package/lib/Cloudflare/Containers/DurableObjectApplication.d.ts.map +1 -0
  25. package/lib/Cloudflare/Containers/DurableObjectApplication.js +249 -0
  26. package/lib/Cloudflare/Containers/DurableObjectApplication.js.map +1 -0
  27. package/lib/Cloudflare/Containers/LocalContainerProvider.d.ts.map +1 -1
  28. package/lib/Cloudflare/Containers/LocalContainerProvider.js +20 -41
  29. package/lib/Cloudflare/Containers/LocalContainerProvider.js.map +1 -1
  30. package/lib/Cloudflare/Containers/index.d.ts +2 -1
  31. package/lib/Cloudflare/Containers/index.d.ts.map +1 -1
  32. package/lib/Cloudflare/Containers/index.js +2 -1
  33. package/lib/Cloudflare/Containers/index.js.map +1 -1
  34. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  35. package/lib/Cloudflare/Providers.js +2 -1
  36. package/lib/Cloudflare/Providers.js.map +1 -1
  37. package/lib/Cloudflare/StateStore/Api.d.ts +3 -0
  38. package/lib/Cloudflare/Workers/DurableObject.d.ts +75 -1
  39. package/lib/Cloudflare/Workers/DurableObject.d.ts.map +1 -1
  40. package/lib/Cloudflare/Workers/DurableObject.js +50 -0
  41. package/lib/Cloudflare/Workers/DurableObject.js.map +1 -1
  42. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
  43. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +29 -23
  44. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
  45. package/lib/Cloudflare/Workers/Worker.d.ts +2 -0
  46. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  47. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  48. package/lib/Cloudflare/Workers/WorkerAsyncBindings.d.ts.map +1 -1
  49. package/lib/Cloudflare/Workers/WorkerAsyncBindings.js +67 -4
  50. package/lib/Cloudflare/Workers/WorkerAsyncBindings.js.map +1 -1
  51. package/lib/Cloudflare/Workers/WorkerBinding.d.ts +6 -0
  52. package/lib/Cloudflare/Workers/WorkerBinding.d.ts.map +1 -1
  53. package/lib/Cloudflare/Workers/WorkerProvider.d.ts.map +1 -1
  54. package/lib/Cloudflare/Workers/WorkerProvider.js +54 -8
  55. package/lib/Cloudflare/Workers/WorkerProvider.js.map +1 -1
  56. package/lib/State/HttpStateApi.d.ts +13 -13
  57. package/package.json +6 -6
  58. package/src/Cloudflare/Containers/Container.ts +126 -56
  59. package/src/Cloudflare/Containers/ContainerApplication.ts +60 -53
  60. package/src/Cloudflare/Containers/ContainerClient.ts +47 -13
  61. package/src/Cloudflare/Containers/ContainerConfiguration.ts +316 -44
  62. package/src/Cloudflare/Containers/ContainerPlatform.ts +27 -3
  63. package/src/Cloudflare/Containers/ContainerProvider.ts +96 -256
  64. package/src/Cloudflare/Containers/DurableObjectApplication.ts +382 -0
  65. package/src/Cloudflare/Containers/LocalContainerProvider.ts +20 -48
  66. package/src/Cloudflare/Containers/index.ts +10 -0
  67. package/src/Cloudflare/Providers.ts +2 -0
  68. package/src/Cloudflare/Workers/DurableObject.ts +77 -1
  69. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +32 -21
  70. package/src/Cloudflare/Workers/Worker.ts +2 -0
  71. package/src/Cloudflare/Workers/WorkerAsyncBindings.ts +95 -4
  72. package/src/Cloudflare/Workers/WorkerProvider.ts +78 -13
@@ -0,0 +1,382 @@
1
+ import * as Containers from "@distilled.cloud/cloudflare/containers";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Schedule from "effect/Schedule";
4
+ import { isResolved } from "../../Diff.ts";
5
+ import * as ProviderLayer from "../../Local/ProviderLayer.ts";
6
+ import * as Namespace from "../../Namespace.ts";
7
+ import * as Provider from "../../Provider.ts";
8
+ import { Resource, type ResourceBinding } from "../../Resource.ts";
9
+ import { normalizeNulls } from "../../Util/stable.ts";
10
+ import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11
+ import { localAccountId } from "../LocalAccount.ts";
12
+ import { generateLocalId, isLiveId } from "../LocalRuntime.ts";
13
+ import type { Providers } from "../Providers.ts";
14
+ import type { DurableObjectApplicationSettings } from "./ContainerApplication.ts";
15
+ import { createContainerApplicationName } from "./ContainerBundle.ts";
16
+ import {
17
+ ContainerConfigurationError,
18
+ durableObjectSettingsPatch,
19
+ mergeApplicationSettings,
20
+ } from "./ContainerConfiguration.ts";
21
+
22
+ export const DurableObjectApplicationTypeId = "Cloudflare.Containers.DurableObjectApplication";
23
+
24
+ export interface DurableObjectApplicationProps {
25
+ /** The Durable Object class whose namespace the application serves. */
26
+ className: string;
27
+ }
28
+
29
+ /** What each bound Container contributes to its class's application. */
30
+ export interface DurableObjectApplicationBinding {
31
+ /** The Durable Object namespace the application is attached to. */
32
+ durableObjects?: { namespaceId: string };
33
+ /** The bound Container and the application-wide settings it declares. */
34
+ container?: { id: string; settings: DurableObjectApplicationSettings };
35
+ }
36
+
37
+ /**
38
+ * INTERNAL — the one container application of a Durable Object class.
39
+ *
40
+ * Cloudflare allows exactly one container application per Durable Object
41
+ * namespace. Every `schedulingPolicy: "durable_object"` Container bound to a
42
+ * class declares this resource under the same logical id, so the engine
43
+ * registers it once; each Container then binds its application-wide settings
44
+ * onto it. The images themselves are Worker upload metadata, merged by the
45
+ * Worker.
46
+ *
47
+ * @internal
48
+ */
49
+ export interface DurableObjectApplication extends Resource<
50
+ typeof DurableObjectApplicationTypeId,
51
+ DurableObjectApplicationProps,
52
+ {
53
+ /** The application id, which equals the Durable Object namespace id. Empty before creation. */
54
+ applicationId: string;
55
+ /** The generated application name. */
56
+ applicationName: string;
57
+ /** The Cloudflare account that owns the application. */
58
+ accountId: string;
59
+ /** The Durable Object class the application serves. */
60
+ className: string;
61
+ /** The Durable Object namespace attached to the application. */
62
+ durableObjects: { namespaceId: string } | undefined;
63
+ /** ISO-8601 creation time. Empty before creation. */
64
+ createdAt: string;
65
+ /** Application-wide container configuration (SSH and authorized keys). */
66
+ configuration: Containers.DurableObjectContainerConfiguration;
67
+ /** Application-wide logging. */
68
+ observability: Containers.ContainerApplicationObservability | undefined;
69
+ /** The merged settings last applied, used to reset settings a Container stops declaring. */
70
+ settings: DurableObjectApplicationSettings;
71
+ },
72
+ DurableObjectApplicationBinding,
73
+ Providers
74
+ > {}
75
+
76
+ export const DurableObjectApplication = Resource<DurableObjectApplication>(
77
+ DurableObjectApplicationTypeId,
78
+ );
79
+
80
+ /**
81
+ * Declare the container application of `className`, hosted by `worker`. Every
82
+ * Container bound to the class calls this; the engine dedupes the declaration
83
+ * by its fully qualified id, so all of them share one resource.
84
+ */
85
+ export const declareDurableObjectApplication = (worker: { LogicalId: string }, className: string) =>
86
+ Namespace.push(
87
+ worker.LogicalId,
88
+ DurableObjectApplication(`${className}-Containers`, { className }),
89
+ );
90
+
91
+ /**
92
+ * Right after a Worker upload creates a Durable Object namespace, the
93
+ * containers API can report the namespace missing for a few seconds before it
94
+ * sees it. Retry creates that attach to it, bounded at ~30s.
95
+ */
96
+ export const retryNamespacePropagation = <A, E extends { readonly _tag: string }, R>(
97
+ effect: Effect.Effect<A, E, R>,
98
+ ) =>
99
+ effect.pipe(
100
+ Effect.tapError((error) =>
101
+ error._tag === "DurableObjectNamespaceNotFound"
102
+ ? Effect.logDebug("Cloudflare Container create: namespace not visible yet, retrying")
103
+ : Effect.void,
104
+ ),
105
+ Effect.retry({
106
+ while: (error) => error._tag === "DurableObjectNamespaceNotFound",
107
+ schedule: Schedule.spaced("2 seconds"),
108
+ times: 15,
109
+ }),
110
+ );
111
+
112
+ /** Whether an application id names a created, live application (not a placeholder). */
113
+ const isLiveApplication = (id: string | undefined): id is string => !!id && isLiveId(id);
114
+
115
+ type Bindings = ReadonlyArray<ResourceBinding<DurableObjectApplicationBinding>>;
116
+
117
+ /** The one namespace every bound Container attaches the application to. */
118
+ const boundNamespace = (className: string, bindings: Bindings) => {
119
+ // A stale Worker namespace map can resolve a binding without an id; it
120
+ // does not request removing the live attachment.
121
+ const ids = [
122
+ ...new Set(
123
+ bindings.flatMap((b) =>
124
+ b.data.durableObjects?.namespaceId ? [b.data.durableObjects.namespaceId] : [],
125
+ ),
126
+ ),
127
+ ];
128
+ if (ids.length > 1) {
129
+ return Effect.fail(
130
+ new ContainerConfigurationError({
131
+ message: `The container application of Durable Object class '${className}' is bound to ${ids.length} namespaces (${ids.join(", ")}). A class has one namespace.`,
132
+ }),
133
+ );
134
+ }
135
+ return Effect.succeed(ids[0] === undefined ? undefined : { namespaceId: ids[0] });
136
+ };
137
+
138
+ const boundSettings = (className: string, bindings: Bindings) =>
139
+ mergeApplicationSettings(
140
+ className,
141
+ bindings.flatMap((b) => (b.data.container ? [b.data.container] : [])),
142
+ );
143
+
144
+ /**
145
+ * Check the bound settings for conflicts at plan time. The namespace id in
146
+ * the same bindings resolves only after the Worker uploads, so check the
147
+ * settings contributions on their own once they are resolved.
148
+ */
149
+ const planSettings = (className: string, newBindings: unknown) => {
150
+ const contributions = (Array.isArray(newBindings) ? newBindings : []).flatMap(
151
+ (binding: { data?: DurableObjectApplicationBinding }) =>
152
+ binding?.data?.container ? [binding.data.container] : [],
153
+ );
154
+ return isResolved(contributions)
155
+ ? Effect.asVoid(mergeApplicationSettings(className, contributions))
156
+ : Effect.void;
157
+ };
158
+
159
+ const placeholder = (
160
+ identity: Pick<
161
+ DurableObjectApplication["Attributes"],
162
+ "applicationId" | "applicationName" | "accountId" | "className" | "createdAt"
163
+ > &
164
+ Partial<DurableObjectApplication["Attributes"]>,
165
+ ): DurableObjectApplication["Attributes"] => ({
166
+ durableObjects: undefined,
167
+ configuration: {},
168
+ observability: undefined,
169
+ settings: {},
170
+ ...identity,
171
+ });
172
+
173
+ const toAttributes = (
174
+ application: Containers.GetContainerApplicationResponse,
175
+ className: string,
176
+ settings: DurableObjectApplicationSettings,
177
+ ): DurableObjectApplication["Attributes"] => ({
178
+ applicationId: application.id,
179
+ applicationName: application.name,
180
+ accountId: application.accountId,
181
+ className,
182
+ durableObjects: normalizeNulls(application.durableObjects) as { namespaceId: string } | undefined,
183
+ createdAt: application.createdAt,
184
+ configuration: normalizeNulls(
185
+ application.configuration,
186
+ ) as Containers.DurableObjectContainerConfiguration,
187
+ observability: normalizeNulls(application.observability ?? undefined),
188
+ settings,
189
+ });
190
+
191
+ export const LiveDurableObjectApplicationProvider = () =>
192
+ Provider.succeed(DurableObjectApplication, {
193
+ stables: ["accountId", "className"],
194
+ diff: Effect.fn(function* ({ news, output, newBindings }) {
195
+ if (!isResolved(news)) return undefined;
196
+ // Conflicting settings fail the plan, before anything is published.
197
+ yield* planSettings(news.className, newBindings);
198
+ if (!isResolved(newBindings)) return undefined;
199
+ const namespace = yield* boundNamespace(news.className, newBindings);
200
+ const deployed = output?.durableObjects?.namespaceId;
201
+ if (deployed !== undefined && namespace !== undefined && deployed !== namespace.namespaceId) {
202
+ return yield* new ContainerConfigurationError({
203
+ message: `The container application of Durable Object class '${news.className}' cannot move to another Durable Object namespace.`,
204
+ });
205
+ }
206
+ const { accountId } = yield* yield* CloudflareEnvironment;
207
+ if (
208
+ output !== undefined &&
209
+ isLiveApplication(output.applicationId) &&
210
+ output.accountId !== accountId
211
+ ) {
212
+ return yield* new ContainerConfigurationError({
213
+ message: `The container application of Durable Object class '${news.className}' cannot move to another account in place.`,
214
+ });
215
+ }
216
+ // Precreate only reserves a name; reconcile creates the application
217
+ // once the Worker has created its namespace.
218
+ if (!isLiveApplication(output?.applicationId)) {
219
+ return { action: "update", stables: ["accountId", "className"] } as const;
220
+ }
221
+ return undefined;
222
+ }),
223
+ precreate: Effect.fn(function* ({ id, news, bindings }) {
224
+ // A new application is never diffed; fail conflicting settings here,
225
+ // before the Worker is uploaded.
226
+ yield* planSettings(news.className, bindings);
227
+ const { accountId } = yield* yield* CloudflareEnvironment;
228
+ // The application needs the Worker's namespace id, which does not exist
229
+ // yet. Reserve the name so the Worker's upload can reference it.
230
+ return placeholder({
231
+ applicationId: "",
232
+ applicationName: yield* createContainerApplicationName(id, undefined),
233
+ accountId,
234
+ className: news.className,
235
+ createdAt: "",
236
+ });
237
+ }),
238
+ reconcile: Effect.fn(function* ({ id, news, bindings, output, session }) {
239
+ const { accountId } = yield* yield* CloudflareEnvironment;
240
+ const className = news.className;
241
+ // Prefer the deployed name: regenerating would target a different
242
+ // application if the generator's output for this id ever drifts.
243
+ const name =
244
+ output?.applicationName || (yield* createContainerApplicationName(id, undefined));
245
+ const durableObjects =
246
+ (yield* boundNamespace(className, bindings)) ??
247
+ (output?.durableObjects?.namespaceId ? output.durableObjects : undefined);
248
+ if (!durableObjects) {
249
+ return yield* new ContainerConfigurationError({
250
+ message: `The container application of Durable Object class '${className}' requires a resolved Durable Object namespace. Reconcile its Worker first.`,
251
+ });
252
+ }
253
+ const settings = yield* boundSettings(className, bindings);
254
+
255
+ // The application's id is the id of the namespace it serves.
256
+ const applicationId = durableObjects.namespaceId;
257
+ const getApplication = Containers.getContainerApplication({ accountId, applicationId });
258
+ const assertServesNamespace = (application: Containers.GetContainerApplicationResponse) =>
259
+ application.schedulingPolicy === "durable_object" &&
260
+ application.durableObjects?.namespaceId === applicationId
261
+ ? Effect.void
262
+ : Effect.fail(
263
+ new ContainerConfigurationError({
264
+ message: `The container application '${application.name}' of Durable Object namespace '${applicationId}' is not Durable Object-managed. Declare a new Durable Object class for Durable Object-managed Containers.`,
265
+ }),
266
+ );
267
+
268
+ // 1. Observe. The namespace belongs to this stack's Worker, so an
269
+ // application already attached to it is this class's application.
270
+ let application = yield* getApplication.pipe(
271
+ Effect.catchTag("ContainerApplicationNotFound", () => Effect.succeed(undefined)),
272
+ );
273
+
274
+ // 2. Ensure
275
+ if (!application) {
276
+ yield* session.note(`Creating container application ${name}...`);
277
+ application = yield* retryNamespacePropagation(
278
+ Containers.createDurableObjectContainerApplication({
279
+ accountId,
280
+ name,
281
+ schedulingPolicy: "durable_object",
282
+ durableObjects,
283
+ configuration: {
284
+ ...(settings.ssh !== undefined ? { wranglerSsh: settings.ssh } : {}),
285
+ ...(settings.authorizedKeys !== undefined
286
+ ? { authorizedKeys: settings.authorizedKeys }
287
+ : {}),
288
+ },
289
+ observability: settings.observability,
290
+ }),
291
+ ).pipe(Effect.catchTag("DurableObjectAlreadyHasApplication", () => getApplication));
292
+ }
293
+ yield* assertServesNamespace(application);
294
+
295
+ // 3. Sync declared settings and reset settings no Container declares anymore.
296
+ const patch = durableObjectSettingsPatch(settings, output?.settings, application);
297
+ if (patch.configuration !== undefined || patch.observability !== undefined) {
298
+ application = yield* Containers.updateContainerApplication({
299
+ accountId,
300
+ applicationId,
301
+ ...patch,
302
+ });
303
+ }
304
+ return toAttributes(application, className, settings);
305
+ }),
306
+ delete: Effect.fn(function* ({ output }) {
307
+ if (!isLiveApplication(output.applicationId)) return;
308
+ yield* Containers.deleteContainerApplication({
309
+ accountId: output.accountId,
310
+ applicationId: output.applicationId,
311
+ }).pipe(Effect.catchTag("ContainerApplicationNotFound", () => Effect.void));
312
+ }),
313
+ read: Effect.fn(function* ({ id, olds, output }) {
314
+ const { accountId } = yield* yield* CloudflareEnvironment;
315
+ const className = olds?.className ?? output?.className ?? "";
316
+ if (output !== undefined && isLiveApplication(output.applicationId)) {
317
+ return yield* Containers.getContainerApplication({
318
+ accountId: output.accountId,
319
+ applicationId: output.applicationId,
320
+ }).pipe(
321
+ Effect.map((application) => toAttributes(application, className, output.settings)),
322
+ Effect.catchTag("ContainerApplicationNotFound", () => Effect.succeed(undefined)),
323
+ );
324
+ }
325
+ // Generated names identify this instance, so a match is ours.
326
+ const name =
327
+ output?.applicationName || (yield* createContainerApplicationName(id, undefined));
328
+ const found = yield* Containers.listContainerApplications({ accountId }).pipe(
329
+ Effect.map((apps) =>
330
+ apps.find((app) => app.name === name && app.schedulingPolicy === "durable_object"),
331
+ ),
332
+ );
333
+ return found === undefined
334
+ ? undefined
335
+ : toAttributes(found, className, output?.settings ?? {});
336
+ }),
337
+ });
338
+
339
+ /**
340
+ * Local (dev) provider: `alchemy dev` runs every image through workerd and
341
+ * Docker, so the application is a placeholder carrying a `dev:` id.
342
+ */
343
+ export const LocalDurableObjectApplicationProvider = () =>
344
+ Provider.succeed(DurableObjectApplication, {
345
+ stables: ["accountId", "className"],
346
+ diff: Effect.fn(function* ({ news, newBindings }) {
347
+ if (!isResolved(news)) return undefined;
348
+ yield* planSettings(news.className, newBindings);
349
+ return undefined;
350
+ }),
351
+ precreate: Effect.fn(function* ({ id, news, bindings }) {
352
+ yield* planSettings(news.className, bindings);
353
+ return placeholder({
354
+ applicationId: generateLocalId(),
355
+ applicationName: yield* createContainerApplicationName(id, undefined),
356
+ accountId: yield* localAccountId,
357
+ className: news.className,
358
+ createdAt: new Date().toISOString(),
359
+ });
360
+ }),
361
+ reconcile: Effect.fn(function* ({ id, news, bindings, output }) {
362
+ return placeholder({
363
+ applicationId: output?.applicationId || generateLocalId(),
364
+ applicationName: yield* createContainerApplicationName(id, undefined),
365
+ accountId: yield* localAccountId,
366
+ className: news.className,
367
+ createdAt: output?.createdAt || new Date().toISOString(),
368
+ durableObjects: yield* boundNamespace(news.className, bindings),
369
+ settings: yield* boundSettings(news.className, bindings),
370
+ });
371
+ }),
372
+ delete: () => Effect.void,
373
+ read: Effect.fn(function* ({ output }) {
374
+ return output;
375
+ }),
376
+ });
377
+
378
+ export const DurableObjectApplicationProvider = () =>
379
+ ProviderLayer.dual(DurableObjectApplication, {
380
+ live: () => LiveDurableObjectApplicationProvider(),
381
+ local: () => LocalDurableObjectApplicationProvider(),
382
+ });
@@ -4,7 +4,7 @@ import * as Path from "effect/Path";
4
4
  import * as Redacted from "effect/Redacted";
5
5
  import * as Artifacts from "../../Artifacts.ts";
6
6
  import { hashDirectory } from "../../Command/Memo.ts";
7
- import { deepEqual, isResolved } from "../../Diff.ts";
7
+ import { isResolved } from "../../Diff.ts";
8
8
  import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
9
9
  import * as RpcProvider from "../../Local/RpcProvider.ts";
10
10
  import type { ResourceBinding } from "../../Resource.ts";
@@ -16,7 +16,6 @@ import type {
16
16
  AnyContainerApplicationProps,
17
17
  ContainerApplication,
18
18
  DevContainerImage,
19
- DurableObjectContainerProps,
20
19
  } from "./ContainerApplication.ts";
21
20
  import {
22
21
  createContainerApplicationName,
@@ -26,9 +25,9 @@ import {
26
25
  validateContainerImageProps,
27
26
  } from "./ContainerBundle.ts";
28
27
  import {
29
- isDurableObjectContainer,
30
28
  durableObjectPlaceholder,
31
- namedImageHash,
29
+ hasImageSource,
30
+ isDurableObjectContainer,
32
31
  validateContainerConfiguration,
33
32
  } from "./ContainerConfiguration.ts";
34
33
  import { ContainerPlatform } from "./ContainerPlatform.ts";
@@ -151,7 +150,6 @@ export const LocalContainerProvider = () =>
151
150
  const news = input as AnyContainerApplicationProps;
152
151
  const picked = {
153
152
  schedulingPolicy: news.schedulingPolicy,
154
- images: news.images,
155
153
  main: news.main,
156
154
  image: news.image,
157
155
  dockerfile: news.dockerfile,
@@ -176,27 +174,6 @@ export const LocalContainerProvider = () =>
176
174
  return picked as AnyContainerApplicationProps;
177
175
  };
178
176
 
179
- /**
180
- * Prepare every named image of a Durable Object-managed container.
181
- * Each image is cached under its own `${id}-${name}` artifact key.
182
- */
183
- const prepareNamedImages = Effect.fn(function* (
184
- id: string,
185
- props: DurableObjectContainerProps,
186
- ) {
187
- const devImages: Record<string, DevContainerImage> = {};
188
- const hashes: Record<string, string> = {};
189
- for (const [name, source] of Object.entries(props.images ?? {})) {
190
- const image = yield* prepareImage(`${id}-${name}`, source);
191
- devImages[name] = image.dev;
192
- hashes[name] = image.hash;
193
- }
194
- return {
195
- devImages,
196
- hash: yield* namedImageHash(hashes),
197
- };
198
- });
199
-
200
177
  const placeholderConfiguration = (
201
178
  props: AnyContainerApplicationProps,
202
179
  env: Record<string, string | Redacted.Redacted<string>>,
@@ -238,19 +215,22 @@ export const LocalContainerProvider = () =>
238
215
  yield* validateContainerConfiguration(news, output?.schedulingPolicy);
239
216
  const accountId = yield* localAccountId;
240
217
  if (isDurableObjectContainer(news)) {
241
- // workerd resolves each image by its declared name, so locally the
242
- // runtime image reference is just the name.
243
- const imageNames = Object.keys(news.images ?? {});
218
+ const attributes = durableObjectPlaceholder({
219
+ applicationId: output?.applicationId ?? generateLocalId(),
220
+ applicationName: yield* createContainerApplicationName(id, undefined),
221
+ accountId,
222
+ createdAt: output?.createdAt ?? new Date().toISOString(),
223
+ observability: news.observability,
224
+ });
225
+ if (!hasImageSource(news)) return attributes;
226
+ const { dev, hash } = yield* prepareImage(id, news);
244
227
  return {
245
- ...durableObjectPlaceholder({
246
- applicationId: output?.applicationId ?? generateLocalId(),
247
- applicationName: yield* createContainerApplicationName(id, news.name),
248
- accountId,
249
- createdAt: output?.createdAt ?? new Date().toISOString(),
250
- observability: news.observability,
251
- }),
252
- images: Object.fromEntries(imageNames.map((name) => [name, name])),
253
- ...(yield* prepareNamedImages(id, news)),
228
+ ...attributes,
229
+ // workerd resolves each image by its key in the class's image
230
+ // map, so locally the image reference is the Container's id.
231
+ image: id,
232
+ dev,
233
+ hash: { image: hash },
254
234
  } satisfies ContainerApplication["Attributes"];
255
235
  }
256
236
  const env = makeContainerEnv(news, accountId, bindings);
@@ -290,16 +270,8 @@ export const LocalContainerProvider = () =>
290
270
  // disabled this check for every effectful container.
291
271
  const imageInputs = resolvedImageInputs(news);
292
272
  if (imageInputs !== undefined) {
293
- if (isDurableObjectContainer(imageInputs)) {
294
- const artifacts = yield* Artifacts.Artifacts;
295
- for (const name of Object.keys(imageInputs.images ?? {})) {
296
- yield* artifacts.delete(`container-image:${id}-${name}`);
297
- }
298
- const prepared = yield* prepareNamedImages(id, imageInputs);
299
- const changed =
300
- output.devImages === undefined ||
301
- !deepEqual(prepared.hash.images, output.hash?.images);
302
- return changed ? { action: "update" } : undefined;
273
+ if (isDurableObjectContainer(imageInputs) && !hasImageSource(imageInputs)) {
274
+ return output.image !== undefined ? { action: "update" } : undefined;
303
275
  }
304
276
  // Recompute fresh on every plan. `prepareImage` is memoized so
305
277
  // a plan's diff→precreate→reconcile chain bundles once — but
@@ -15,9 +15,19 @@ export type {
15
15
  ReservedContainerKey,
16
16
  } from "./ContainerClient.ts";
17
17
  export {
18
+ ContainerApplicationSettingsConflict,
18
19
  ContainerConfigurationError,
19
20
  ContainerImagePreparationError,
21
+ DurableObjectContainerBindingError,
22
+ DurableObjectContainerConflict,
23
+ DurableObjectContainersOnEffectNativeClass,
20
24
  } from "./ContainerConfiguration.ts";
21
25
  export { ContainerPlatform } from "./ContainerPlatform.ts";
22
26
  export * from "./ContainerProvider.ts";
27
+ export {
28
+ DurableObjectApplication,
29
+ DurableObjectApplicationProvider,
30
+ type DurableObjectApplicationBinding,
31
+ type DurableObjectApplicationProps,
32
+ } from "./DurableObjectApplication.ts";
23
33
  export * from "./LocalContainerProvider.ts";
@@ -196,6 +196,7 @@ export const providers = () =>
196
196
  Configuration.Configuration,
197
197
  Connectivity.DirectoryService,
198
198
  Containers.ContainerPlatform,
199
+ Containers.DurableObjectApplication,
199
200
  ContentScanning.ContentScanning,
200
201
  ContentScanning.Expression,
201
202
  CustomCertificates.CustomCertificate,
@@ -450,6 +451,7 @@ export const providers = () =>
450
451
  Configuration.ConfigurationProvider(),
451
452
  Connectivity.DirectoryServiceProvider(),
452
453
  Containers.ContainerProvider(),
454
+ Containers.DurableObjectApplicationProvider(),
453
455
  ContentScanning.ContentScanningProvider(),
454
456
  ContentScanning.ExpressionProvider(),
455
457
  CustomCertificates.CustomCertificateProvider(),
@@ -61,6 +61,8 @@ export interface DurableObjectLike<Shape = any> {
61
61
  scriptName?: Input<string>;
62
62
  /** @internal phantom */
63
63
  transferredFrom?: DurableObjectTransferSource | DurableObjectTransferSource[];
64
+ /** @internal Durable Object-managed Containers attached to the class. */
65
+ containers?: ReadonlyArray<Container.Decl.Any>;
64
66
  /** @internal phantom */
65
67
  Shape?: Shape;
66
68
  }
@@ -264,6 +266,30 @@ export interface DurableObjectProps {
264
266
  // namespaceId?: string | undefined;
265
267
  }
266
268
 
269
+ /**
270
+ * Props of an async Worker's Durable Object binding,
271
+ * `Cloudflare.DurableObject(className, props)` in `env`.
272
+ */
273
+ export interface DurableObjectBindingProps extends DurableObjectProps {
274
+ /**
275
+ * Durable Object-managed Containers (`schedulingPolicy: "durable_object"`)
276
+ * this class starts. Each adds its image to the class's container
277
+ * application under the Container's id, read at runtime from
278
+ * `ctx.container.images`. A Container may be listed by several classes.
279
+ *
280
+ * Only for a plain (async) Durable Object class the Worker hosts.
281
+ * Effect-native Durable Objects `yield*` their Containers instead; listing
282
+ * Containers for one fails with `DurableObjectContainersOnEffectNativeClass`.
283
+ *
284
+ * ```typescript
285
+ * env: {
286
+ * AGENT: Cloudflare.DurableObject("Agent", { containers: [Node, Python] }),
287
+ * }
288
+ * ```
289
+ */
290
+ containers?: ReadonlyArray<Container.Decl.Any> | undefined;
291
+ }
292
+
267
293
  export interface DurableObjectClass extends Effect.Effect<DurableObject, never, DurableObject> {
268
294
  <Self, Shape>(): {
269
295
  <Name extends string>(
@@ -310,7 +336,7 @@ export interface DurableObjectClass extends Effect.Effect<DurableObject, never,
310
336
  new (_: never): Shape;
311
337
  };
312
338
  };
313
- <Shape>(name: string, props?: DurableObjectProps): DurableObjectLike<Shape>;
339
+ <Shape>(name: string, props?: DurableObjectBindingProps): DurableObjectLike<Shape>;
314
340
  <Shape, InitReq = never>(
315
341
  name: string,
316
342
  impl: Effect.Effect<Shape, never, DurableObjectServices | InitReq>,
@@ -1076,6 +1102,55 @@ export class DurableObjectScope extends Context.Service<DurableObjectScope, Dura
1076
1102
  * });
1077
1103
  * ```
1078
1104
  *
1105
+ * ### Native Containers in an Async Worker
1106
+ * A plain Durable Object class can start Durable Object-managed Containers
1107
+ * (`schedulingPolicy: "durable_object"`). List them in its binding's
1108
+ * `containers`: each adds its image to the class's container application,
1109
+ * keyed by the Container's id in `ctx.container.images`. A Container may be
1110
+ * shared by several classes.
1111
+ *
1112
+ * **Example:** Two classes, one shared image
1113
+ * ```typescript
1114
+ * const Node = Cloudflare.Container("Node", {
1115
+ * schedulingPolicy: "durable_object",
1116
+ * image: "node:24-slim",
1117
+ * });
1118
+ * const Python = Cloudflare.Container("Python", {
1119
+ * schedulingPolicy: "durable_object",
1120
+ * image: "python:3.13-slim",
1121
+ * });
1122
+ * const Rust = Cloudflare.Container("Rust", {
1123
+ * schedulingPolicy: "durable_object",
1124
+ * image: "rust:1.90-slim",
1125
+ * });
1126
+ *
1127
+ * export const Worker = Cloudflare.Worker("App", {
1128
+ * main: "./src/worker.ts",
1129
+ * env: {
1130
+ * AGENT: Cloudflare.DurableObject("Agent", { containers: [Node, Python] }),
1131
+ * REVIEWER: Cloudflare.DurableObject("Reviewer", { containers: [Node, Rust] }),
1132
+ * },
1133
+ * });
1134
+ * ```
1135
+ *
1136
+ * **Example:** Starting a Container's image from the class
1137
+ * ```typescript
1138
+ * // src/worker.ts
1139
+ * export class Agent extends DurableObject {
1140
+ * async run(cmd: string[]) {
1141
+ * const container = this.ctx.container!;
1142
+ * if (!container.running) {
1143
+ * container.start({
1144
+ * image: container.images.Node,
1145
+ * entrypoint: ["sleep", "infinity"],
1146
+ * enableInternet: false,
1147
+ * });
1148
+ * }
1149
+ * return (await container.exec(cmd)).output();
1150
+ * }
1151
+ * }
1152
+ * ```
1153
+ *
1079
1154
  * ### Moving a Class Between Workers
1080
1155
  * A Durable Object class can move from one Worker to another with its
1081
1156
  * data intact. The move is always **declared** — a class that disappears
@@ -1357,6 +1432,7 @@ export const DurableObject: DurableObjectClass = taggedFunction(
1357
1432
  className: (args[1] as DurableObjectProps)?.className || namespace,
1358
1433
  scriptName: (args[1] as DurableObjectProps)?.scriptName,
1359
1434
  transferredFrom: (args[1] as DurableObjectProps)?.transferredFrom,
1435
+ containers: (args[1] as DurableObjectBindingProps)?.containers,
1360
1436
  };
1361
1437
  } else if (Effect.isEffect(propsOrImpl)) {
1362
1438
  // inline Effect DO