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
@@ -38,10 +38,6 @@ export type ContainerInfo = cf.ContainerInfo;
38
38
  export type ContainerSnapshot = cf.ContainerSnapshot;
39
39
  export type ContainerSnapshotOptions = cf.ContainerSnapshotOptions;
40
40
 
41
- type PreparedImages<ImageName extends string> = string extends ImageName
42
- ? Readonly<Record<string, string | undefined>>
43
- : Readonly<Record<ImageName, string>>;
44
-
45
41
  /** Errors a container `start` can fail with. */
46
42
  export type ContainerStartError =
47
43
  | ContainerError
@@ -101,16 +97,29 @@ export interface ContainerProcess {
101
97
  * returns. Binding never starts the container: call {@link start} before
102
98
  * using it. `start` is idempotent and coordinated across every handle in the
103
99
  * Durable Object, so calling it at the top of each operation is cheap.
100
+ *
101
+ * A Durable Object has one container instance. When several Durable
102
+ * Object-managed Containers are bound to it, each handle starts its own image,
103
+ * and every handle controls that same instance: start one image at a time.
104
104
  */
105
- export interface ContainerClient<ImageName extends string = string> {
106
- /** Prepared image references. Required names retain their declaration's keys. */
107
- readonly images: Effect.Effect<PreparedImages<ImageName>, ContainerError, RuntimeContext>;
105
+ export interface ContainerClient {
106
+ /**
107
+ * This Container's prepared image reference, the key `start()` selects by
108
+ * default. Fails with {@link ContainerError} when the Container publishes no
109
+ * image to this Durable Object (fleet Containers and Containers without an
110
+ * image source).
111
+ */
112
+ readonly image: Effect.Effect<string, ContainerError, RuntimeContext>;
108
113
  /** Whether the container process is running; this does not imply port readiness. */
109
114
  readonly running: Effect.Effect<boolean, ContainerError, RuntimeContext>;
110
115
  /**
111
116
  * Start the container unless it is already running. Concurrent calls start
112
117
  * it once. Returns before ports are ready; {@link getTcpPort} waits for them.
113
118
  * Options apply only when this call actually starts the container.
119
+ *
120
+ * A Durable Object-managed Container starts its own {@link image} unless
121
+ * `options` names an `image` or a `containerSnapshot`. A no-op while any
122
+ * image is running: `destroy()` first to switch images.
114
123
  */
115
124
  start(
116
125
  options?: ContainerStartupOptions,
@@ -300,9 +309,11 @@ const fromProcess = (process: cf.ExecProcess): ContainerProcess => {
300
309
  * looked up on every call, never at construction: the client is also built
301
310
  * while planning a Worker, where no Durable Object state exists.
302
311
  */
303
- export const fromContainer = <ImageName extends string = string>(
312
+ export const fromContainer = (
304
313
  getContainer: () => cf.Container | undefined,
305
- ): ContainerClient<ImageName> => {
314
+ /** The key of this Container's image in `ctx.container.images`. */
315
+ imageKey?: string,
316
+ ): ContainerClient => {
306
317
  const attached = Effect.suspend(() => {
307
318
  const container = getContainer();
308
319
  return container
@@ -324,6 +335,30 @@ export const fromContainer = <ImageName extends string = string>(
324
335
 
325
336
  const running = call((container) => container.running ?? false);
326
337
 
338
+ const ownImage = (container: cf.Container) =>
339
+ imageKey === undefined ? undefined : container.images?.[imageKey];
340
+
341
+ const image = Effect.flatMap(attached, (container) => {
342
+ const ref = ownImage(container);
343
+ return ref === undefined
344
+ ? Effect.fail(
345
+ new ContainerError({
346
+ message: `Container '${imageKey}' publishes no image to this Durable Object.`,
347
+ }),
348
+ )
349
+ : Effect.succeed(ref);
350
+ });
351
+
352
+ /** Default `image` to this Container's own image. */
353
+ const withOwnImage = (
354
+ container: cf.Container,
355
+ options: ContainerStartupOptions | undefined,
356
+ ): ContainerStartupOptions | undefined => {
357
+ if (options?.image !== undefined || options?.containerSnapshot !== undefined) return options;
358
+ const ref = ownImage(container);
359
+ return ref === undefined ? options : ({ ...options, image: ref } as ContainerStartupOptions);
360
+ };
361
+
327
362
  const start = (options?: ContainerStartupOptions) =>
328
363
  Effect.gen(function* () {
329
364
  const container = yield* attached;
@@ -340,7 +375,7 @@ export const fromContainer = <ImageName extends string = string>(
340
375
  // Nothing listens on a fresh start; re-probe every port.
341
376
  readyPorts.clear();
342
377
  yield* Effect.try({
343
- try: () => container.start(options),
378
+ try: () => container.start(withOwnImage(container, options)),
344
379
  catch: classifyStartError,
345
380
  }).pipe(
346
381
  Effect.retry({
@@ -452,8 +487,7 @@ export const fromContainer = <ImageName extends string = string>(
452
487
  ).pipe(Effect.map(fromProcess));
453
488
 
454
489
  return {
455
- // The binding publishes every declared image under its declared name.
456
- images: call((container) => container.images as PreparedImages<ImageName>),
490
+ image,
457
491
  running,
458
492
  start,
459
493
  destroy: (error) => callAsync((container) => container.destroy(error)),
@@ -495,7 +529,7 @@ export const makeContainerHandle = Effect.fnUntraced(function* (
495
529
  ) {
496
530
  yield* binding;
497
531
  const state = yield* DurableObjectState;
498
- const client = fromContainer(() => state.container);
532
+ const client = fromContainer(() => state.container, id);
499
533
  const rpcPort = client.getTcpPort(3000);
500
534
  return makeFetchRpcStub<ContainerClient>({
501
535
  // RPC methods only run inside the Durable Object, where RuntimeContext is
@@ -4,13 +4,15 @@ import * as Duration from "effect/Duration";
4
4
  import * as Effect from "effect/Effect";
5
5
  import { deepEqual } from "../../Diff.ts";
6
6
  import * as Output from "../../Output.ts";
7
- import { sha256Object } from "../../Util/sha256.ts";
8
7
  import { normalizeNulls } from "../../Util/stable.ts";
9
8
  import type {
10
9
  AnyContainerApplicationProps,
11
10
  ContainerApplication,
11
+ DevContainerImage,
12
+ DurableObjectApplicationSettings,
12
13
  DurableObjectContainerProps,
13
14
  } from "./ContainerApplication.ts";
15
+ import type { DurableObjectApplication } from "./DurableObjectApplication.ts";
14
16
 
15
17
  export class ContainerConfigurationError extends Data.TaggedError("ContainerConfigurationError")<{
16
18
  readonly message: string;
@@ -24,7 +26,80 @@ export class ContainerImagePreparationError extends Data.TaggedError(
24
26
  readonly message: string;
25
27
  }> {}
26
28
 
27
- /** Cloudflare's limits for the `images` map of a Durable Object container. */
29
+ /**
30
+ * Two Containers bound to the same Durable Object class declare different
31
+ * values for one application-wide setting. The class has one container
32
+ * application, so the declarations must agree (or only one may declare it).
33
+ */
34
+ export class ContainerApplicationSettingsConflict extends Data.TaggedError(
35
+ "ContainerApplicationSettingsConflict",
36
+ )<{
37
+ readonly className: string;
38
+ readonly setting: keyof DurableObjectApplicationSettings;
39
+ readonly containers: ReadonlyArray<string>;
40
+ readonly message: string;
41
+ }> {}
42
+
43
+ /**
44
+ * The Containers bound to one Durable Object class cannot be merged into the
45
+ * class's single container entry: two images share a key, the class mixes a
46
+ * fleet Container with Durable Object-managed ones, or it exceeds Cloudflare's
47
+ * image limit.
48
+ */
49
+ export class DurableObjectContainerConflict extends Data.TaggedError(
50
+ "DurableObjectContainerConflict",
51
+ )<{
52
+ readonly className: string;
53
+ readonly message: string;
54
+ }> {}
55
+
56
+ /**
57
+ * A Durable Object-managed Container was bound on an async Worker outside a
58
+ * Durable Object binding, or a Durable Object binding lists a Container it
59
+ * cannot attach. Durable Object-managed Containers attach to a class through
60
+ * `Cloudflare.DurableObject(className, { containers })`.
61
+ */
62
+ export class DurableObjectContainerBindingError extends Data.TaggedError(
63
+ "DurableObjectContainerBindingError",
64
+ )<{
65
+ readonly bindingName: string;
66
+ readonly message: string;
67
+ }> {}
68
+
69
+ /**
70
+ * An async `Cloudflare.DurableObject(className, { containers })` binding
71
+ * attaches Containers to a class the same Worker implements as an
72
+ * Effect-native Durable Object. Effect-native classes `yield*` their
73
+ * Containers instead.
74
+ */
75
+ export class DurableObjectContainersOnEffectNativeClass extends Data.TaggedError(
76
+ "DurableObjectContainersOnEffectNativeClass",
77
+ )<{
78
+ readonly className: string;
79
+ readonly message: string;
80
+ }> {}
81
+
82
+ /**
83
+ * Fail when an async binding's `containers` target a class the Worker
84
+ * implements as an Effect-native Durable Object.
85
+ */
86
+ export const assertBindingContainersOnAsyncClasses = (
87
+ rows: ReadonlyArray<{ readonly className: string; readonly attachedBy?: "binding" }>,
88
+ effectNativeClasses: Iterable<string>,
89
+ ) => {
90
+ const effectNative = new Set(effectNativeClasses);
91
+ const row = rows.find((row) => row.attachedBy === "binding" && effectNative.has(row.className));
92
+ return row === undefined
93
+ ? Effect.void
94
+ : Effect.fail(
95
+ new DurableObjectContainersOnEffectNativeClass({
96
+ className: row.className,
97
+ message: `Durable Object class '${row.className}' is an Effect-native Durable Object, so it cannot take containers from Cloudflare.DurableObject("${row.className}", { containers }). Bind each Container inside the Durable Object with \`yield*\` instead, and remove the containers prop.`,
98
+ }),
99
+ );
100
+ };
101
+
102
+ /** Cloudflare's limits for the `images` map of a Durable Object class. */
28
103
  const MAX_NAMED_IMAGES = 100;
29
104
  const MAX_IMAGE_NAME_LENGTH = 128;
30
105
 
@@ -33,10 +108,9 @@ const MAX_IMAGE_NAME_LENGTH = 128;
33
108
  * the image and instance size at `start()` time.
34
109
  */
35
110
  const FLEET_ONLY_PROPS = [
111
+ "name",
112
+ "className",
36
113
  "main",
37
- "image",
38
- "context",
39
- "dockerfile",
40
114
  "instanceType",
41
115
  "instances",
42
116
  "maxInstances",
@@ -58,7 +132,6 @@ const FLEET_ONLY_PROPS = [
58
132
  "affinities",
59
133
  "rollout",
60
134
  "registryId",
61
- "publish",
62
135
  "sshPublicKeyIds",
63
136
  ] as const satisfies ReadonlyArray<keyof AnyContainerApplicationProps>;
64
137
 
@@ -100,26 +173,23 @@ export const validateContainerConfiguration = Effect.fn(function* (
100
173
  );
101
174
  }
102
175
 
103
- if (!isDurableObjectContainer(props)) {
104
- if (props.images !== undefined) {
105
- return yield* invalid("Named images require schedulingPolicy: 'durable_object'.");
106
- }
107
- return;
108
- }
109
-
110
- yield* imagePreparationTimeoutMillis(props.imagePreparationTimeout);
111
- const imageNames = Object.keys(props.images ?? {});
112
- if (imageNames.length > MAX_NAMED_IMAGES) {
176
+ // `images` was removed: one Container is one image. Untyped or
177
+ // Effect-produced props can still carry it.
178
+ if ((props as { images?: unknown }).images !== undefined) {
113
179
  return yield* invalid(
114
- `A Durable Object-managed container supports at most ${MAX_NAMED_IMAGES} named images.`,
180
+ "Container property 'images' was removed. Declare one Cloudflare.Container per image with schedulingPolicy: 'durable_object' and bind each one to the Durable Object; the image key is the Container's id.",
115
181
  );
116
182
  }
117
- const badName = imageNames.find(
118
- (name) => name.length === 0 || name.length > MAX_IMAGE_NAME_LENGTH,
119
- );
120
- if (badName !== undefined) {
183
+
184
+ if (!isDurableObjectContainer(props)) return;
185
+
186
+ yield* imagePreparationTimeoutMillis(props.imagePreparationTimeout);
187
+ if (
188
+ props.image !== undefined &&
189
+ (props.context !== undefined || props.dockerfile !== undefined)
190
+ ) {
121
191
  return yield* invalid(
122
- `Container image name '${badName}' must contain between 1 and ${MAX_IMAGE_NAME_LENGTH} characters.`,
192
+ "A Durable Object-managed container declares one image source: `image`, or `context`/`dockerfile`.",
123
193
  );
124
194
  }
125
195
 
@@ -129,52 +199,257 @@ export const validateContainerConfiguration = Effect.fn(function* (
129
199
  const unsupported = FLEET_ONLY_PROPS.find((key) => loose[key] !== undefined);
130
200
  if (unsupported !== undefined) {
131
201
  return yield* invalid(
132
- `Container property '${unsupported}' is not supported with schedulingPolicy: 'durable_object'. Declare image sources under 'images' and pass per-instance settings to start().`,
202
+ `Container property '${unsupported}' is not supported with schedulingPolicy: 'durable_object'. The application is named and owned by the Durable Object class: attach the Container with Cloudflare.DurableObject(className, { containers }) on an async Worker, or yield it inside an Effect-native Durable Object. Pass per-instance settings to start().`,
133
203
  );
134
204
  }
135
205
  });
136
206
 
207
+ /** Whether a Durable Object-managed Container declares an image to publish. */
208
+ export const hasImageSource = (props: DurableObjectContainerProps) =>
209
+ props.image !== undefined || props.context !== undefined || props.dockerfile !== undefined;
210
+
211
+ /** Read the scheduling policy off a declared Container resource's props. */
212
+ export const isDurableObjectContainerResource = (container: ContainerApplication) => {
213
+ const props = container.Props as { schedulingPolicy?: unknown } | undefined;
214
+ return props?.schedulingPolicy === "durable_object";
215
+ };
216
+
217
+ /** The application-wide settings a Container contributes to its Durable Object class. */
218
+ export const containerApplicationSettings = (container: ContainerApplication) => {
219
+ const props = container.Props as DurableObjectApplicationSettings | undefined;
220
+ return {
221
+ ssh: props?.ssh,
222
+ authorizedKeys: props?.authorizedKeys,
223
+ observability: props?.observability,
224
+ };
225
+ };
226
+
227
+ const SETTING_KEYS = ["ssh", "authorizedKeys", "observability"] as const;
228
+
137
229
  /**
138
- * The `containers` entry a Durable Object class contributes to its Worker.
139
- * Durable Object-managed applications also send their application name and
140
- * prepared images so the Worker upload can expose `ctx.container.images`.
230
+ * Merge the application-wide settings every bound Container declares. A
231
+ * setting declared by several Containers must have the same value.
232
+ */
233
+ export const mergeApplicationSettings = (
234
+ className: string,
235
+ contributions: ReadonlyArray<{
236
+ readonly id: string;
237
+ readonly settings: DurableObjectApplicationSettings | undefined;
238
+ }>,
239
+ ) =>
240
+ Effect.gen(function* () {
241
+ const merged: DurableObjectApplicationSettings = {};
242
+ for (const setting of SETTING_KEYS) {
243
+ const declared = contributions.flatMap(({ id, settings }) => {
244
+ const value = normalizeNulls(settings?.[setting]);
245
+ return value === undefined ? [] : [{ id, value }];
246
+ });
247
+ const [first] = declared;
248
+ if (first === undefined) continue;
249
+ const conflicting = declared.find(({ value }) => !deepEqual(value, first.value));
250
+ if (conflicting !== undefined) {
251
+ return yield* new ContainerApplicationSettingsConflict({
252
+ className,
253
+ setting,
254
+ containers: declared.map(({ id }) => id),
255
+ message: `Containers '${first.id}' and '${conflicting.id}' declare different '${setting}' for Durable Object class '${className}'. A class has one container application, so its Containers must agree on application-wide settings; declare '${setting}' on one Container or give them the same value.`,
256
+ });
257
+ }
258
+ Object.assign(merged, { [setting]: first.value });
259
+ }
260
+ return merged;
261
+ });
262
+
263
+ /**
264
+ * One row of a Worker's `containers` binding data: the container a Durable
265
+ * Object class is backed by.
266
+ */
267
+ export interface WorkerContainerRow {
268
+ readonly className: string;
269
+ /** Set when an async `Cloudflare.DurableObject(name, { containers })` binding contributed the row. */
270
+ readonly attachedBy?: "binding";
271
+ /** Application name, set for a Durable Object-managed class. */
272
+ readonly name?: string;
273
+ /** Prepared images keyed by Container id (Durable Object-managed). */
274
+ readonly images?: Record<string, string>;
275
+ /** Local image sources keyed by Container id (Durable Object-managed). */
276
+ readonly devImages?: Record<string, DevContainerImage>;
277
+ /** The fleet container's local image. */
278
+ readonly dev: DevContainerImage | undefined;
279
+ readonly hash: string | undefined;
280
+ }
281
+
282
+ const isDurableObjectRow = (row: Pick<WorkerContainerRow, "name" | "images" | "devImages">) =>
283
+ row.name !== undefined || row.images !== undefined || row.devImages !== undefined;
284
+
285
+ const mergeImageMaps = <V>(
286
+ className: string,
287
+ label: string,
288
+ maps: ReadonlyArray<Record<string, V> | undefined>,
289
+ ) =>
290
+ Effect.gen(function* () {
291
+ if (maps.every((map) => map === undefined)) return undefined;
292
+ const merged: Record<string, V> = {};
293
+ for (const map of maps) {
294
+ for (const [key, value] of Object.entries(map ?? {})) {
295
+ if (key in merged && !deepEqual(merged[key], value)) {
296
+ return yield* new DurableObjectContainerConflict({
297
+ className,
298
+ message: `Two Containers bound to Durable Object class '${className}' publish different ${label} under the key '${key}'. The image key is the Container's id, so give each Container a distinct id.`,
299
+ });
300
+ }
301
+ merged[key] = value;
302
+ }
303
+ }
304
+ return merged;
305
+ });
306
+
307
+ /**
308
+ * Merge a Worker's `containers` rows into one entry per Durable Object class.
309
+ * A Durable Object-managed class unions the images of every bound Container;
310
+ * a fleet class is backed by exactly one Container.
311
+ */
312
+ export const mergeWorkerContainers = <
313
+ Row extends Partial<WorkerContainerRow> & { className: string },
314
+ >(
315
+ rows: ReadonlyArray<Row>,
316
+ ) =>
317
+ Effect.gen(function* () {
318
+ const byClass = new Map<string, Row[]>();
319
+ for (const row of rows) {
320
+ const existing = byClass.get(row.className);
321
+ if (existing) existing.push(row);
322
+ else byClass.set(row.className, [row]);
323
+ }
324
+ const merged: Row[] = [];
325
+ for (const [className, classRows] of byClass) {
326
+ const [first, ...rest] = classRows as [Row, ...Row[]];
327
+ const managed = classRows.filter(isDurableObjectRow);
328
+ if (managed.length === 0) {
329
+ // Fleet: rows for the same Container collapse; distinct ones conflict.
330
+ if (rest.some((row) => !deepEqual(row, first))) {
331
+ return yield* new DurableObjectContainerConflict({
332
+ className,
333
+ message: `Durable Object class '${className}' is bound to more than one fleet Container. Only Durable Object-managed Containers (schedulingPolicy: 'durable_object') can share a class.`,
334
+ });
335
+ }
336
+ merged.push(first);
337
+ continue;
338
+ }
339
+ if (managed.length !== classRows.length) {
340
+ return yield* new DurableObjectContainerConflict({
341
+ className,
342
+ message: `Durable Object class '${className}' mixes a fleet Container with Durable Object-managed Containers. Every Container bound to one class must use schedulingPolicy: 'durable_object'.`,
343
+ });
344
+ }
345
+ const names = [
346
+ ...new Set(managed.flatMap((row) => (row.name === undefined ? [] : [row.name]))),
347
+ ];
348
+ if (names.length > 1) {
349
+ return yield* new DurableObjectContainerConflict({
350
+ className,
351
+ message: `Durable Object class '${className}' resolves to more than one container application (${names.join(", ")}).`,
352
+ });
353
+ }
354
+ const images = yield* mergeImageMaps(
355
+ className,
356
+ "images",
357
+ managed.map((row) => row.images),
358
+ );
359
+ const devImages = yield* mergeImageMaps(
360
+ className,
361
+ "local images",
362
+ managed.map((row) => row.devImages),
363
+ );
364
+ const keys = Object.keys(images ?? devImages ?? {});
365
+ if (keys.length > MAX_NAMED_IMAGES) {
366
+ return yield* new DurableObjectContainerConflict({
367
+ className,
368
+ message: `Durable Object class '${className}' is bound to ${keys.length} container images; Cloudflare allows at most ${MAX_NAMED_IMAGES}.`,
369
+ });
370
+ }
371
+ const badKey = keys.find((key) => key.length === 0 || key.length > MAX_IMAGE_NAME_LENGTH);
372
+ if (badKey !== undefined) {
373
+ return yield* new DurableObjectContainerConflict({
374
+ className,
375
+ message: `Container id '${badKey}' is used as an image key and must contain between 1 and ${MAX_IMAGE_NAME_LENGTH} characters.`,
376
+ });
377
+ }
378
+ const hashes = managed.flatMap((row) => (row.hash === undefined ? [] : [row.hash])).sort();
379
+ merged.push({
380
+ ...first,
381
+ name: names[0],
382
+ images,
383
+ devImages,
384
+ dev: undefined,
385
+ hash: hashes.length === 0 ? undefined : hashes.join(","),
386
+ });
387
+ }
388
+ return merged;
389
+ });
390
+
391
+ /**
392
+ * The `containers` entry a fleet Container contributes to the Durable Object
393
+ * class it backs.
141
394
  */
142
395
  export const workerContainerBinding = (className: string, application: ContainerApplication) => ({
143
396
  className,
144
- name: Output.all(application.schedulingPolicy, application.applicationName).pipe(
145
- Output.map(([policy, name]) => (policy === "durable_object" ? name : undefined)),
146
- ),
147
- images: application.images,
148
- devImages: application.devImages,
149
397
  dev: application.dev,
150
398
  hash: application.hash.pipe(Output.map((hash) => hash?.image)),
151
399
  });
152
400
 
401
+ /**
402
+ * The `containers` entry a Durable Object-managed Container contributes to
403
+ * its class: the class's application name plus this Container's one image,
404
+ * keyed by the Container's id. The Worker merges every entry of the class.
405
+ */
406
+ export const durableObjectContainerBinding = (
407
+ className: string,
408
+ application: DurableObjectApplication,
409
+ container: ContainerApplication,
410
+ ) => {
411
+ const key = container.LogicalId;
412
+ return {
413
+ className,
414
+ name: application.applicationName,
415
+ images: container.image.pipe(
416
+ Output.map((image): Record<string, string> => (image === undefined ? {} : { [key]: image })),
417
+ ),
418
+ devImages: container.dev.pipe(
419
+ Output.map((dev): Record<string, DevContainerImage> =>
420
+ dev === undefined ? {} : { [key]: dev },
421
+ ),
422
+ ),
423
+ dev: undefined,
424
+ hash: container.hash.pipe(Output.map((hash) => hash?.image)),
425
+ };
426
+ };
427
+
153
428
  /**
154
429
  * Converge declared settings and reset settings removed from a prior declaration.
155
430
  * Omitted settings on new or adopted applications remain unmanaged.
156
431
  */
157
432
  export const durableObjectSettingsPatch = (
158
- news: DurableObjectContainerProps,
159
- olds: AnyContainerApplicationProps | undefined,
433
+ desired: DurableObjectApplicationSettings,
434
+ previous: DurableObjectApplicationSettings | undefined,
160
435
  observed: Pick<Containers.GetContainerApplicationResponse, "configuration" | "observability">,
161
436
  ): Pick<Containers.UpdateContainerApplicationRequest, "configuration" | "observability"> => {
162
437
  const configuration: Containers.DurableObjectContainerConfiguration = {
163
438
  // Cloudflare names the SSH setting after the CLI that connects to it.
164
- ...(news.ssh !== undefined
165
- ? { wranglerSsh: news.ssh }
166
- : olds?.ssh !== undefined
439
+ ...(desired.ssh !== undefined
440
+ ? { wranglerSsh: desired.ssh }
441
+ : previous?.ssh !== undefined
167
442
  ? { wranglerSsh: { enabled: false } }
168
443
  : {}),
169
- ...(news.authorizedKeys !== undefined
170
- ? { authorizedKeys: news.authorizedKeys }
171
- : olds?.authorizedKeys !== undefined
444
+ ...(desired.authorizedKeys !== undefined
445
+ ? { authorizedKeys: desired.authorizedKeys }
446
+ : previous?.authorizedKeys !== undefined
172
447
  ? { authorizedKeys: [] }
173
448
  : {}),
174
449
  };
175
450
  const observability =
176
- news.observability ??
177
- (olds?.observability !== undefined ? { logs: { enabled: false } } : undefined);
451
+ desired.observability ??
452
+ (previous?.observability !== undefined ? { logs: { enabled: false } } : undefined);
178
453
  const configurationChanged = Object.entries(configuration).some(
179
454
  ([key, value]) =>
180
455
  !deepEqual(value, normalizeNulls(observed.configuration[key as keyof typeof configuration])),
@@ -206,6 +481,3 @@ export const durableObjectPlaceholder = (
206
481
  version: undefined,
207
482
  dev: undefined,
208
483
  });
209
-
210
- export const namedImageHash = (images: Record<string, string>) =>
211
- Effect.map(sha256Object(images), (image) => ({ image, images }));
@@ -14,7 +14,13 @@ import type {
14
14
  ContainerServices,
15
15
  ContainerShape,
16
16
  } from "./ContainerApplication.ts";
17
- import { workerContainerBinding } from "./ContainerConfiguration.ts";
17
+ import {
18
+ containerApplicationSettings,
19
+ durableObjectContainerBinding,
20
+ isDurableObjectContainerResource,
21
+ workerContainerBinding,
22
+ } from "./ContainerConfiguration.ts";
23
+ import { declareDurableObjectApplication } from "./DurableObjectApplication.ts";
18
24
 
19
25
  const toHttpUrl = (url: string) =>
20
26
  url.startsWith("https:") ? `http:${url.slice("https:".length)}` : url;
@@ -50,17 +56,35 @@ const bindContainer = Effect.fn(function* <Shape, Req = never>(
50
56
  | Effect.Effect<ContainerApplication<Shape>, never, Req>,
51
57
  ) {
52
58
  const namespace = yield* DurableObject;
59
+ const worker = yield* Worker;
53
60
 
54
61
  const container = Effect.isEffect(containerEff) ? yield* containerEff : containerEff;
55
62
 
63
+ if (isDurableObjectContainerResource(container)) {
64
+ // The class has one container application. Every Container bound to it
65
+ // declares the same resource (deduped by id), attaches it to the
66
+ // namespace, and adds its application-wide settings; the Worker merges
67
+ // each Container's image into the class's `containers` entry.
68
+ const application = yield* declareDurableObjectApplication(worker, namespace.name);
69
+ yield* application.bind`${container.LogicalId}`({
70
+ durableObjects: { namespaceId: namespace.namespaceId },
71
+ container: {
72
+ id: container.LogicalId,
73
+ settings: containerApplicationSettings(container),
74
+ },
75
+ });
76
+ yield* worker.bind`${namespace.name}:${container.LogicalId}`({
77
+ containers: [durableObjectContainerBinding(namespace.name, application, container)],
78
+ });
79
+ return;
80
+ }
81
+
56
82
  yield* container.bind`${namespace}`({
57
83
  durableObjects: {
58
84
  namespaceId: namespace.namespaceId,
59
85
  },
60
86
  });
61
87
 
62
- const worker = yield* Worker;
63
-
64
88
  yield* worker.bind`${container.LogicalId}`({
65
89
  containers: [workerContainerBinding(namespace.name, container)],
66
90
  });