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.
- package/lib/Cloudflare/Containers/Container.d.ts +128 -40
- package/lib/Cloudflare/Containers/Container.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/Container.js +124 -24
- package/lib/Cloudflare/Containers/Container.js.map +1 -1
- package/lib/Cloudflare/Containers/ContainerApplication.d.ts +59 -50
- package/lib/Cloudflare/Containers/ContainerApplication.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/ContainerApplication.js.map +1 -1
- package/lib/Cloudflare/Containers/ContainerClient.d.ts +20 -7
- package/lib/Cloudflare/Containers/ContainerClient.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/ContainerClient.js +22 -5
- package/lib/Cloudflare/Containers/ContainerClient.js.map +1 -1
- package/lib/Cloudflare/Containers/ContainerConfiguration.d.ts +132 -13
- package/lib/Cloudflare/Containers/ContainerConfiguration.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/ContainerConfiguration.js +219 -35
- package/lib/Cloudflare/Containers/ContainerConfiguration.js.map +1 -1
- package/lib/Cloudflare/Containers/ContainerPlatform.d.ts +1 -1
- package/lib/Cloudflare/Containers/ContainerPlatform.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/ContainerPlatform.js +21 -2
- package/lib/Cloudflare/Containers/ContainerPlatform.js.map +1 -1
- package/lib/Cloudflare/Containers/ContainerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/ContainerProvider.js +74 -177
- package/lib/Cloudflare/Containers/ContainerProvider.js.map +1 -1
- package/lib/Cloudflare/Containers/DurableObjectApplication.d.ts +84 -0
- package/lib/Cloudflare/Containers/DurableObjectApplication.d.ts.map +1 -0
- package/lib/Cloudflare/Containers/DurableObjectApplication.js +249 -0
- package/lib/Cloudflare/Containers/DurableObjectApplication.js.map +1 -0
- package/lib/Cloudflare/Containers/LocalContainerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/LocalContainerProvider.js +20 -41
- package/lib/Cloudflare/Containers/LocalContainerProvider.js.map +1 -1
- package/lib/Cloudflare/Containers/index.d.ts +2 -1
- package/lib/Cloudflare/Containers/index.d.ts.map +1 -1
- package/lib/Cloudflare/Containers/index.js +2 -1
- package/lib/Cloudflare/Containers/index.js.map +1 -1
- package/lib/Cloudflare/Providers.d.ts.map +1 -1
- package/lib/Cloudflare/Providers.js +2 -1
- package/lib/Cloudflare/Providers.js.map +1 -1
- package/lib/Cloudflare/StateStore/Api.d.ts +3 -0
- package/lib/Cloudflare/Workers/DurableObject.d.ts +75 -1
- package/lib/Cloudflare/Workers/DurableObject.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/DurableObject.js +50 -0
- package/lib/Cloudflare/Workers/DurableObject.js.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +29 -23
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
- package/lib/Cloudflare/Workers/Worker.d.ts +2 -0
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.js +67 -4
- package/lib/Cloudflare/Workers/WorkerAsyncBindings.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBinding.d.ts +6 -0
- package/lib/Cloudflare/Workers/WorkerBinding.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerProvider.js +54 -8
- package/lib/Cloudflare/Workers/WorkerProvider.js.map +1 -1
- package/lib/State/HttpStateApi.d.ts +13 -13
- package/package.json +6 -6
- package/src/Cloudflare/Containers/Container.ts +126 -56
- package/src/Cloudflare/Containers/ContainerApplication.ts +60 -53
- package/src/Cloudflare/Containers/ContainerClient.ts +47 -13
- package/src/Cloudflare/Containers/ContainerConfiguration.ts +316 -44
- package/src/Cloudflare/Containers/ContainerPlatform.ts +27 -3
- package/src/Cloudflare/Containers/ContainerProvider.ts +96 -256
- package/src/Cloudflare/Containers/DurableObjectApplication.ts +382 -0
- package/src/Cloudflare/Containers/LocalContainerProvider.ts +20 -48
- package/src/Cloudflare/Containers/index.ts +10 -0
- package/src/Cloudflare/Providers.ts +2 -0
- package/src/Cloudflare/Workers/DurableObject.ts +77 -1
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +32 -21
- package/src/Cloudflare/Workers/Worker.ts +2 -0
- package/src/Cloudflare/Workers/WorkerAsyncBindings.ts +95 -4
- 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
|
|
106
|
-
/**
|
|
107
|
-
|
|
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 =
|
|
312
|
+
export const fromContainer = (
|
|
304
313
|
getContainer: () => cf.Container | undefined,
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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'.
|
|
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
...(
|
|
165
|
-
? { wranglerSsh:
|
|
166
|
-
:
|
|
439
|
+
...(desired.ssh !== undefined
|
|
440
|
+
? { wranglerSsh: desired.ssh }
|
|
441
|
+
: previous?.ssh !== undefined
|
|
167
442
|
? { wranglerSsh: { enabled: false } }
|
|
168
443
|
: {}),
|
|
169
|
-
...(
|
|
170
|
-
? { authorizedKeys:
|
|
171
|
-
:
|
|
444
|
+
...(desired.authorizedKeys !== undefined
|
|
445
|
+
? { authorizedKeys: desired.authorizedKeys }
|
|
446
|
+
: previous?.authorizedKeys !== undefined
|
|
172
447
|
? { authorizedKeys: [] }
|
|
173
448
|
: {}),
|
|
174
449
|
};
|
|
175
450
|
const observability =
|
|
176
|
-
|
|
177
|
-
(
|
|
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 {
|
|
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
|
});
|