@sanoma-ai/connector-cloudflare 0.6.1 → 0.6.2
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/README.md +35 -19
- package/dist/deploy.d.ts +66 -0
- package/dist/deploy.js +143 -0
- package/dist/deploy.js.map +1 -0
- package/dist/driver.d.ts +7 -2
- package/dist/driver.js +22 -3
- package/dist/driver.js.map +1 -1
- package/dist/fake.d.ts +46 -20
- package/dist/fake.js +248 -41
- package/dist/fake.js.map +1 -1
- package/dist/ops.d.ts +58 -15
- package/dist/ops.js +224 -134
- package/dist/ops.js.map +1 -1
- package/dist/records.d.ts +2 -2
- package/dist/steps.d.ts +2 -1
- package/dist/steps.js +7 -2
- package/dist/steps.js.map +1 -1
- package/features/cloudflare.feature +6 -2
- package/package.json +4 -3
- package/worker/index.js +10 -6
- package/worker/instance.d.ts +25 -13
- package/worker/instance.js +128 -78
- package/worker/no-app.js +10 -0
- package/dist/sites.d.ts +0 -58
- package/dist/sites.js +0 -153
- package/dist/sites.js.map +0 -1
- package/dist/worker.d.ts +0 -19
- package/dist/worker.js +0 -29
- package/dist/worker.js.map +0 -1
- package/worker/sites.js +0 -17
package/dist/ops.js
CHANGED
|
@@ -5,14 +5,14 @@ import { instanceUrl, mintInstanceToken, WAKEUP_TOKEN } from "@sanoma-ai/workflo
|
|
|
5
5
|
import { alarm, container } from "./connector.js";
|
|
6
6
|
import { refused } from "./errors.js";
|
|
7
7
|
import { accountPinned } from "./registry.js";
|
|
8
|
-
import {
|
|
9
|
-
import { HEALTH_PATH, IMAGE,
|
|
10
|
-
// Cloudflare as the operations use it (`CloudflareApi`), and the
|
|
8
|
+
import { appBundle, appModules, ASSETS_CONFIG, MAIN_MODULE, OBJECT_CLASS, uploadAssets, waitFor, workerModules, } from "./deploy.js";
|
|
9
|
+
import { HEALTH_PATH, IMAGE, NAMESPACE } from "../worker/instance.js";
|
|
10
|
+
// Cloudflare as the operations use it (`CloudflareApi`), and the host's and the alarm's
|
|
11
11
|
// operations over it: the driver's calls its API with the SDK and the Worker over HTTP, the
|
|
12
12
|
// fake's answers from its state and its Worker in this process. Both go through `containerOps`
|
|
13
13
|
// and `alarmOps` (and `recordsOps`, in `records.ts`), so they upload, plan and refuse alike.
|
|
14
|
-
/** The Workers runtime the
|
|
15
|
-
|
|
14
|
+
/** The Workers runtime of a create's uploads before its first bundle; the bundle's own after. */
|
|
15
|
+
const COMPATIBILITY_DATE = "2026-10-01";
|
|
16
16
|
/** The migration that makes the Worker's object class, on its first upload only. */
|
|
17
17
|
export const MIGRATION = { new_tag: "v1", new_sqlite_classes: [OBJECT_CLASS] };
|
|
18
18
|
/** How long the account may take to prepare an image to run, as Wrangler waits for it. */
|
|
@@ -29,13 +29,17 @@ const TEXT = {
|
|
|
29
29
|
/** The app's own custom domain, when declared: the state's `url` is then `https://<hostname>`. */
|
|
30
30
|
hostname: "SANOMA_HOSTNAME",
|
|
31
31
|
url: HOST_URL,
|
|
32
|
-
/** Set while the public sites' upload is still to be made (`sites.ts`). */
|
|
33
|
-
sitesPending: SITES_PENDING,
|
|
34
32
|
};
|
|
33
|
+
/** The app's database, a Hyperdrive config over the host's `SANOMA_DATABASE_URL` (`AppBundleEnv.DATABASE`). */
|
|
34
|
+
const DATABASE = "DATABASE";
|
|
35
|
+
/** The app bundle's static assets (`AppBundleEnv.ASSETS`). */
|
|
36
|
+
const ASSETS = "ASSETS";
|
|
35
37
|
/** Names a secret may not take: the Worker's own bindings. */
|
|
36
|
-
const RESERVED = new Set([NAMESPACE, ...Object.values(TEXT)]);
|
|
38
|
+
const RESERVED = new Set([NAMESPACE, DATABASE, ASSETS, ...Object.values(TEXT)]);
|
|
37
39
|
/** Whether a Worker's bindings are Sanoma's: its own object's namespace among them. */
|
|
38
40
|
const isOurs = (bindings) => bindings.some((b) => b.type === "durable_object_namespace" && b.name === NAMESPACE);
|
|
41
|
+
/** Whether a Worker serves the app: only the upload of a bundle binds its database. */
|
|
42
|
+
const servesApp = (bindings) => bindings.some((b) => b.type === "hyperdrive" && b.name === DATABASE);
|
|
39
43
|
/** The company's Worker, named after it. */
|
|
40
44
|
export const scriptOf = (name) => `sanoma-${name}`;
|
|
41
45
|
/** The zone a hostname is looked up in: its last two labels (`www.acme.example` → `acme.example`). */
|
|
@@ -99,26 +103,34 @@ const same = (field, a, b) => field === "domains"
|
|
|
99
103
|
? domainsText((a ?? {})) === domainsText((b ?? {}))
|
|
100
104
|
: (a ?? null) === (b ?? null);
|
|
101
105
|
/**
|
|
102
|
-
* `{ revision }` when the Worker answers, else why not
|
|
103
|
-
*
|
|
104
|
-
* container
|
|
106
|
+
* `{ revision }` when the Worker's object answers its health, else why not. A read asks without
|
|
107
|
+
* `start`: a stopped host is healthy, and stays stopped. With `start` and the instance token, only
|
|
108
|
+
* once its container runs at what the Worker names and answered, which the ask starts (202
|
|
109
|
+
* meanwhile). A container that ended is `exit`: asking again would only start it again, so a deploy
|
|
105
110
|
* stops asking.
|
|
106
111
|
*/
|
|
107
|
-
const health = async (cf, url,
|
|
112
|
+
const health = async (cf, url, token) => {
|
|
108
113
|
let res;
|
|
109
114
|
try {
|
|
110
|
-
res = await cf.send(`${url}${HEALTH_PATH}${
|
|
115
|
+
res = await cf.send(`${url}${HEALTH_PATH}${token === undefined ? "" : "?start=1"}`, {
|
|
116
|
+
method: "GET",
|
|
117
|
+
...(token !== undefined && { headers: { authorization: `Bearer ${token}` } }),
|
|
118
|
+
});
|
|
111
119
|
}
|
|
112
120
|
catch (error) {
|
|
113
121
|
return { error: errorMessage(error), exit: false };
|
|
114
122
|
}
|
|
115
123
|
const body = (await res.json().catch(() => undefined));
|
|
116
|
-
if (res.
|
|
124
|
+
if (res.status === 200 && typeof body?.revision === "string")
|
|
117
125
|
return {
|
|
118
126
|
revision: body.revision,
|
|
119
127
|
...(typeof body.configurationRevision === "string" && { configurationRevision: body.configurationRevision }),
|
|
120
128
|
};
|
|
121
|
-
const error = typeof body?.error === "string"
|
|
129
|
+
const error = typeof body?.error === "string"
|
|
130
|
+
? body.error
|
|
131
|
+
: res.status === 202
|
|
132
|
+
? `its container is starting${typeof body?.last === "string" ? ` (${body.last})` : ""}`
|
|
133
|
+
: `${HEALTH_PATH} answered ${res.status}`;
|
|
122
134
|
return { error, exit: typeof body?.exit === "string" };
|
|
123
135
|
};
|
|
124
136
|
/** Whether the full name `name` is in the zone `zone`: the zone's apex or a name under it. */
|
|
@@ -141,16 +153,64 @@ const zonesOf = async (cf, name, hostnames) => {
|
|
|
141
153
|
}
|
|
142
154
|
return new Map(hostnames.map((hostname) => [hostname, ids.get(apexOf(hostname))]));
|
|
143
155
|
};
|
|
156
|
+
/** The Hyperdrive config's origin from the host's database URL; a refusal that never shows the URL, which holds the password. */
|
|
157
|
+
export function originOf(name, databaseUrl) {
|
|
158
|
+
let url;
|
|
159
|
+
try {
|
|
160
|
+
url = new URL(databaseUrl);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Said below, without the URL.
|
|
164
|
+
}
|
|
165
|
+
const scheme = url?.protocol.slice(0, -1);
|
|
166
|
+
let decoded;
|
|
167
|
+
try {
|
|
168
|
+
if (url) {
|
|
169
|
+
decoded = {
|
|
170
|
+
database: decodeURIComponent(url.pathname.slice(1)),
|
|
171
|
+
user: decodeURIComponent(url.username),
|
|
172
|
+
password: decodeURIComponent(url.password),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// A malformed escape: said below, without the URL.
|
|
178
|
+
}
|
|
179
|
+
if (!url ||
|
|
180
|
+
!decoded ||
|
|
181
|
+
(scheme !== "postgres" && scheme !== "postgresql") ||
|
|
182
|
+
!url.hostname ||
|
|
183
|
+
!decoded.user ||
|
|
184
|
+
!decoded.database) {
|
|
185
|
+
throw refused(`container ${name}: SANOMA_DATABASE_URL is not a postgres:// URL with a user, a host and a database, which Hyperdrive needs`, "invalid");
|
|
186
|
+
}
|
|
187
|
+
return { scheme, host: url.hostname, port: Number(url.port || 5432), ...decoded };
|
|
188
|
+
}
|
|
144
189
|
/**
|
|
145
|
-
* `cloudflare.host`'s
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
190
|
+
* `cloudflare.host`'s operations over `api()`, made on each call (`cloudflare.container`'s are
|
|
191
|
+
* the same, `type` the one whose fields a call is parsed by). A host is given by apply what its
|
|
192
|
+
* data file leaves out; its port is the image's. A create and an update are one upsert, a deploy
|
|
193
|
+
* at revision R in three steps, each with its own wait, so a visitor is answered throughout:
|
|
194
|
+
*
|
|
195
|
+
* 1. Build first: the app bundle at R, asked of the Worker's object, which starts its container
|
|
196
|
+
* when it is stopped and passes the ask on to the worker running there (`appBundle`): a
|
|
197
|
+
* create's container, and an update's still at R-1, builds it in a checkout of R.
|
|
198
|
+
* 2. One upload: the Worker at R, the bundle's modules under `app/`, its assets through an upload
|
|
199
|
+
* session, and every binding (the text that says what it runs, the secrets, the database as a
|
|
200
|
+
* Hyperdrive config, the assets, the object's namespace and its container's image). The
|
|
201
|
+
* visitors' next request runs it.
|
|
202
|
+
* 3. Restart last: the object, asked for its health with `start`, drains the worker at R-1 and
|
|
203
|
+
* starts the container at R (`instance.js`), and the wait ends once it answers there. An update
|
|
204
|
+
* whose plan is none (the upload made, the restart not seen: a deploy that died in its wait, or
|
|
205
|
+
* the restart of the worker that ran it) makes this step alone.
|
|
206
|
+
*
|
|
207
|
+
* Before them, as Wrangler orders it: the image copied and pinned, then prepared, the Hyperdrive
|
|
208
|
+
* config made or set again, and on a create the Worker uploaded without an app (`no-app.js`, the
|
|
209
|
+
* migration on its first upload), its workers.dev URL turned on and its application made, then
|
|
210
|
+
* uploaded once more so its object learns the application. A domain declared before and no longer
|
|
211
|
+
* is detached before the upload, and a missing one attached after it. The instance token
|
|
212
|
+
* (`SANOMA_WAKEUP_TOKEN`), which the bundle and the restart are asked with, is the declared one,
|
|
213
|
+
* else the stored one, minted when none is, and always bound.
|
|
154
214
|
*/
|
|
155
215
|
export function containerOps(api, { startTimeoutMs, pollMs }, type = container) {
|
|
156
216
|
/** The Worker `name` runs, from its bindings and its application; `checkHealth` asks it too. None for a Worker that is not Sanoma's. */
|
|
@@ -187,80 +247,83 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
187
247
|
// Where the app answers now: at its hostname once attached; its health is asked on workers.dev.
|
|
188
248
|
url: servedAt(hostname, url),
|
|
189
249
|
worker_url: url,
|
|
190
|
-
healthy: checkHealth && app !== undefined && "revision" in (await health(cf, url
|
|
250
|
+
healthy: checkHealth && app !== undefined && "revision" in (await health(cf, url)),
|
|
191
251
|
};
|
|
192
252
|
const secrets = bindings.filter((b) => b.type === "secret_text").map((b) => b.name);
|
|
193
|
-
|
|
194
|
-
const sitesPending = text.has(TEXT.sitesPending);
|
|
195
|
-
return { state, secrets, sitesPending };
|
|
253
|
+
return { state, secrets, serves: servesApp(bindings) };
|
|
196
254
|
};
|
|
197
255
|
const read = async ({ id }) => {
|
|
198
256
|
const found = await inspect(id, true);
|
|
199
257
|
return found ? { id, gone: false, state: found.state } : { id, gone: true };
|
|
200
258
|
};
|
|
201
|
-
|
|
202
|
-
const diff = async ({ id, desired, }) => {
|
|
259
|
+
const plan = async ({ id, desired }) => {
|
|
203
260
|
if (desired)
|
|
204
261
|
checkHostname(desired.name, desired.hostname, desired.domains);
|
|
205
262
|
const found = id === undefined ? undefined : await inspect(id, false);
|
|
206
263
|
if (!desired)
|
|
207
|
-
return {
|
|
264
|
+
return { action: found ? "delete" : "none", ...NONE };
|
|
208
265
|
const { secrets, ...declared } = desired;
|
|
209
266
|
const unknown = [...type.fields.vendorOwned];
|
|
210
267
|
// A create plans without the account: it needs no credentials yet.
|
|
211
268
|
if (!found)
|
|
212
|
-
return {
|
|
269
|
+
return { action: "create", planned: declared, unknown, replace: [] };
|
|
213
270
|
const planned = declared;
|
|
214
271
|
const { state } = found;
|
|
215
|
-
if (planned.name !== state.name)
|
|
216
|
-
return {
|
|
217
|
-
}
|
|
272
|
+
if (planned.name !== state.name)
|
|
273
|
+
return { action: "replace", planned, unknown, replace: ["name"] };
|
|
218
274
|
const changed =
|
|
219
275
|
// `domains` and `hostname` left out are compared too: removing one is a change.
|
|
220
|
-
COMPARED.some((field) => (field in planned || OPTIONAL.has(field)) && !same(field, planned[field], state[field])) ||
|
|
221
|
-
|
|
222
|
-
|
|
276
|
+
COMPARED.some((field) => (field in planned || OPTIONAL.has(field)) && !same(field, planned[field], state[field])) ||
|
|
277
|
+
// The instance token is bound whether declared or not.
|
|
278
|
+
sorted(new Set([...Object.keys(secrets ?? {}), WAKEUP_TOKEN])) !== sorted(found.secrets) ||
|
|
279
|
+
// A create that ended before its upload of the app.
|
|
280
|
+
!found.serves;
|
|
281
|
+
if (!changed)
|
|
282
|
+
return { action: "none", planned: state, ...NONE };
|
|
223
283
|
// What the Worker will serve: the declared domains and hostname, none of the found ones left
|
|
224
284
|
// out; the app at the declared hostname, or on workers.dev without one.
|
|
225
285
|
const { domains: _domains, hostname: _hostname, ...was } = state;
|
|
226
286
|
const url = servedAt(planned.hostname, state.worker_url);
|
|
227
287
|
return {
|
|
288
|
+
action: "update",
|
|
228
289
|
planned: {
|
|
229
|
-
|
|
230
|
-
planned
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
planned.configurationRevision == null && { configurationRevision: null }),
|
|
235
|
-
url,
|
|
236
|
-
},
|
|
237
|
-
...NONE,
|
|
290
|
+
...was,
|
|
291
|
+
...planned,
|
|
292
|
+
...(state.configurationRevision !== undefined &&
|
|
293
|
+
planned.configurationRevision == null && { configurationRevision: null }),
|
|
294
|
+
url,
|
|
238
295
|
},
|
|
239
|
-
|
|
240
|
-
sitesOnly: !changed && state.id !== null,
|
|
296
|
+
...NONE,
|
|
241
297
|
};
|
|
242
298
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
299
|
+
/** `desired` parsed, its secrets resolved, and the instance token: the declared one, else the stored one, minted when none is. */
|
|
300
|
+
const resolved = async (desired, store) => {
|
|
301
|
+
const declared = type.schema.parse(await resolveSecrets(desired, store));
|
|
302
|
+
const token = declared.secrets?.[WAKEUP_TOKEN] ?? (await store.ensure(WAKEUP_TOKEN, mintInstanceToken));
|
|
303
|
+
return { declared, token };
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Step 3: has the object restart the container at `revision` (its health with `start`, the worker
|
|
307
|
+
* at another drained first) and waits until it answers there. Asking again is harmless: a
|
|
308
|
+
* container that runs at it is kept.
|
|
309
|
+
*/
|
|
310
|
+
const restarted = async (cf, { script, url, token }, revision, configurationRevision) => {
|
|
311
|
+
// Retryable once its time is up: the upload is made, so the next apply plans none, and waits for this step alone.
|
|
312
|
+
await waitFor(`${script}: still starting at ${revision}`, { startTimeoutMs, pollMs }, async () => {
|
|
313
|
+
const seen = await health(cf, url, token);
|
|
249
314
|
if ("revision" in seen &&
|
|
250
315
|
seen.revision === revision &&
|
|
251
316
|
(seen.configurationRevision ?? "") === (configurationRevision ?? ""))
|
|
252
|
-
return;
|
|
317
|
+
return { value: undefined };
|
|
253
318
|
// Not a wait: the container started and ended, and asking again would only start it again.
|
|
254
319
|
if ("exit" in seen && seen.exit)
|
|
255
320
|
throw refused(`${script} did not start at ${revision}: ${seen.error}`);
|
|
256
|
-
|
|
257
|
-
|
|
321
|
+
return {
|
|
322
|
+
last: "revision" in seen
|
|
258
323
|
? `it runs ${seen.revision} with configuration ${seen.configurationRevision ?? "unset"}`
|
|
259
|
-
: seen.error
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
await delay(pollMs);
|
|
263
|
-
}
|
|
324
|
+
: seen.error,
|
|
325
|
+
};
|
|
326
|
+
});
|
|
264
327
|
};
|
|
265
328
|
/** Waits until the account prepared `image` to run, as Wrangler does before an upload. */
|
|
266
329
|
const prepared = async (cf, image) => {
|
|
@@ -287,18 +350,14 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
287
350
|
await delay(pollMs);
|
|
288
351
|
}
|
|
289
352
|
};
|
|
290
|
-
/**
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
* not follow), the public sites' upload alone.
|
|
294
|
-
*/
|
|
295
|
-
const put = async (desired, { secrets: store }, sitesOnly = false) => {
|
|
296
|
-
const declared = type.schema.parse(await resolveSecrets(desired, store));
|
|
353
|
+
/** Makes the Worker and its application match `desired` at its revision, in the three steps of `containerOps`: a create and an update alike. */
|
|
354
|
+
const put = async (desired, { secrets: store }) => {
|
|
355
|
+
const { declared, token } = await resolved(desired, store);
|
|
297
356
|
const { name, image, source, ref, revision, configurationRevision, domains } = declared;
|
|
298
357
|
const port = declared.port ?? RUNTIME_PORT;
|
|
299
358
|
const hostname = declared.hostname ?? undefined;
|
|
300
359
|
checkHostname(name, hostname, domains);
|
|
301
|
-
const secrets = declared.secrets
|
|
360
|
+
const secrets = { ...declared.secrets, [WAKEUP_TOKEN]: token };
|
|
302
361
|
if (!name || !image || !source || !ref) {
|
|
303
362
|
const missing = !name ? "name" : !image ? "image" : !source ? "source" : "ref";
|
|
304
363
|
throw refused(`container ${name ?? ""}: no ${missing}: apply gives a host one`);
|
|
@@ -309,6 +368,10 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
309
368
|
const taken = Object.keys(secrets).filter((key) => RESERVED.has(key));
|
|
310
369
|
if (taken.length)
|
|
311
370
|
throw refused(`container ${name}: ${taken.join(", ")} is the Worker's own; name the secret otherwise`);
|
|
371
|
+
if (!secrets.SANOMA_DATABASE_URL) {
|
|
372
|
+
throw refused(`container ${name}: no SANOMA_DATABASE_URL among its secrets: the app on the Worker reaches the database through Hyperdrive over it`);
|
|
373
|
+
}
|
|
374
|
+
const origin = originOf(name, secrets.SANOMA_DATABASE_URL);
|
|
312
375
|
const cf = api();
|
|
313
376
|
const script = scriptOf(name);
|
|
314
377
|
const url = await cf.workerUrl(script);
|
|
@@ -329,8 +392,12 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
329
392
|
const missing = await zonesOf(cf, name, [...hostnames].filter((h) => !held.has(h)));
|
|
330
393
|
const runs = accountPinned(image, cf.account) ? image : await cf.copyImage(image);
|
|
331
394
|
await prepared(cf, runs);
|
|
395
|
+
// The Hyperdrive config the Worker binds as its database, named after it: made, or set to the URL again.
|
|
396
|
+
const config = await cf.hyperdrive(script);
|
|
397
|
+
if (config)
|
|
398
|
+
await cf.updateHyperdrive(config.id, script, origin);
|
|
399
|
+
const database = config?.id ?? (await cf.createHyperdrive(script, origin)).id;
|
|
332
400
|
const served = domains ? domainsText(domains) : undefined;
|
|
333
|
-
const token = secrets[WAKEUP_TOKEN];
|
|
334
401
|
const text = {
|
|
335
402
|
[TEXT.source]: source,
|
|
336
403
|
[TEXT.ref]: ref,
|
|
@@ -342,63 +409,81 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
342
409
|
[TEXT.url]: url,
|
|
343
410
|
...(served && { [TEXT.domains]: served }),
|
|
344
411
|
...(hostname && { [TEXT.hostname]: hostname }),
|
|
345
|
-
// Until `serveSites` uploads the Worker again, without it.
|
|
346
|
-
...(domains && token && { [TEXT.sitesPending]: "1" }),
|
|
347
412
|
};
|
|
348
413
|
const bindings = [
|
|
349
414
|
{ type: "durable_object_namespace", name: NAMESPACE, class_name: OBJECT_CLASS },
|
|
350
415
|
...Object.entries(text).map(([key, value]) => ({ type: "plain_text", name: key, text: value })),
|
|
351
|
-
...Object.entries(secrets).map(([key, value]) => ({
|
|
352
|
-
type: "secret_text",
|
|
353
|
-
name: key,
|
|
354
|
-
text: value,
|
|
355
|
-
})),
|
|
416
|
+
...Object.entries(secrets).map(([key, value]) => ({ type: "secret_text", name: key, text: value })),
|
|
356
417
|
];
|
|
357
|
-
const
|
|
418
|
+
const { entry, noApp } = await workerModules();
|
|
419
|
+
const worker = {
|
|
358
420
|
main_module: MAIN_MODULE,
|
|
359
|
-
compatibility_date: COMPATIBILITY_DATE,
|
|
360
|
-
bindings,
|
|
361
421
|
containers: [{ name: script, class_name: OBJECT_CLASS, images: { [IMAGE]: runs } }],
|
|
362
422
|
observability: { enabled: true },
|
|
363
|
-
modules: await workerModules(),
|
|
364
423
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
424
|
+
/** The Worker without an app: what a create runs until its first bundle is built. */
|
|
425
|
+
const bare = {
|
|
426
|
+
...worker,
|
|
427
|
+
bindings,
|
|
428
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
429
|
+
compatibility_flags: [],
|
|
430
|
+
modules: [...entry, noApp],
|
|
431
|
+
};
|
|
432
|
+
if (!current)
|
|
433
|
+
await cf.upload(script, { ...bare, migrations: MIGRATION });
|
|
434
|
+
await cf.enableSubdomain(script);
|
|
435
|
+
let app = await cf.application(script);
|
|
436
|
+
/** Whether the Worker runs at R already, without its app: uploaded so in this put. */
|
|
437
|
+
let bareAtRevision = !current;
|
|
438
|
+
if (!app) {
|
|
439
|
+
// The application is made for the object's namespace, which the first upload made.
|
|
440
|
+
const namespace = (await cf.bindings(script))?.find((b) => b.name === NAMESPACE)?.namespace_id;
|
|
441
|
+
if (!namespace)
|
|
442
|
+
throw refused(`${script}: Cloudflare names no namespace for its ${NAMESPACE} binding`);
|
|
443
|
+
app = await cf.createApplication(script, namespace);
|
|
444
|
+
// The object learns which application backs its namespace from a deployment made after
|
|
445
|
+
// the application exists: without this upload every start answers "There is no container
|
|
446
|
+
// application assigned to this Durable Object namespace", for good (found live 2026-10-29;
|
|
447
|
+
// a Worker deployed again after the application started at once).
|
|
448
|
+
await cf.upload(script, bare);
|
|
449
|
+
bareAtRevision = true;
|
|
369
450
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
app
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// the application exists: without this second upload every start answers "There is no
|
|
390
|
-
// container application assigned to this Durable Object namespace", for good (found live
|
|
391
|
-
// 2026-10-29; a Worker deployed again after the application started at once).
|
|
392
|
-
await cf.upload(script, metadata);
|
|
393
|
-
}
|
|
394
|
-
for (const [domain, zone] of missing)
|
|
395
|
-
await cf.attachDomain(script, domain, zone);
|
|
396
|
-
await started(cf, script, url, revision, configurationRevision);
|
|
451
|
+
const at = { script, url, token };
|
|
452
|
+
// 1. Build first.
|
|
453
|
+
let bundle = await appBundle(cf, { ...at, revision, earlier: !bareAtRevision }, { startTimeoutMs, pollMs });
|
|
454
|
+
if ("stale" in bundle && !bareAtRevision) {
|
|
455
|
+
// The deployment cannot build it (another token, a runtime without the route, a revision
|
|
456
|
+
// whose worker ends): the Worker at R without its app starts R's container, which can; its
|
|
457
|
+
// pages say the app is being set up until step 2.
|
|
458
|
+
await cf.upload(script, bare);
|
|
459
|
+
bundle = await appBundle(cf, { ...at, revision, earlier: false }, { startTimeoutMs, pollMs });
|
|
460
|
+
}
|
|
461
|
+
if ("stale" in bundle)
|
|
462
|
+
throw refused(`${script}: no app bundle at ${revision}: ${bundle.stale}`);
|
|
463
|
+
const jwt = bundle.assets.length ? await uploadAssets(cf, script, bundle.assets) : undefined;
|
|
464
|
+
// 2. One upload. A domain declared before and no longer is detached while the bindings still
|
|
465
|
+
// record it, and a missing one attached once the upload records it: whatever fails in between,
|
|
466
|
+
// none is left attached and unrecorded, and the next apply attaches what is still missing.
|
|
467
|
+
for (const domain of attached) {
|
|
468
|
+
if (before.has(domain.hostname) && !hostnames.has(domain.hostname))
|
|
469
|
+
await cf.detachDomain(domain.id);
|
|
397
470
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
471
|
+
await cf.upload(script, {
|
|
472
|
+
...worker,
|
|
473
|
+
bindings: [
|
|
474
|
+
...bindings,
|
|
475
|
+
{ type: "hyperdrive", name: DATABASE, id: database },
|
|
476
|
+
...(jwt ? [{ type: "assets", name: ASSETS }] : []),
|
|
477
|
+
],
|
|
478
|
+
compatibility_date: bundle.compatibilityDate,
|
|
479
|
+
compatibility_flags: bundle.compatibilityFlags,
|
|
480
|
+
...(jwt && { assets: { jwt, config: ASSETS_CONFIG } }),
|
|
481
|
+
modules: [...entry, ...appModules(script, bundle)],
|
|
482
|
+
});
|
|
483
|
+
for (const [domain, zone] of missing)
|
|
484
|
+
await cf.attachDomain(script, domain, zone);
|
|
485
|
+
// 3. Restart last.
|
|
486
|
+
await restarted(cf, at, revision, configurationRevision);
|
|
402
487
|
const state = {
|
|
403
488
|
id: app.id,
|
|
404
489
|
name,
|
|
@@ -422,23 +507,24 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
422
507
|
plan,
|
|
423
508
|
create: ({ desired }, call) => put(desired, call),
|
|
424
509
|
update: async ({ id, desired, expect, }, call) => {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (found.state) {
|
|
430
|
-
// Matching bindings also describe a rollout whose startup failed. An explicit update
|
|
431
|
-
// confirms readiness even when there is nothing to upload; read/plan remain passive.
|
|
432
|
-
const { revision, configurationRevision } = container.schema
|
|
433
|
-
.pick({ revision: true, configurationRevision: true })
|
|
434
|
-
.parse(desired);
|
|
435
|
-
if (!revision)
|
|
436
|
-
throw refused(`container ${id}: no revision to confirm`);
|
|
437
|
-
await started(api(), scriptOf(id), found.state.worker_url, revision, configurationRevision);
|
|
438
|
-
return { id, state: { ...found.state, healthy: true } };
|
|
439
|
-
}
|
|
510
|
+
const fresh = await plan({ id, desired });
|
|
511
|
+
if (fresh.action !== "none") {
|
|
512
|
+
replanned(`${type.type}.update`, fresh, expect);
|
|
513
|
+
return put(desired, call);
|
|
440
514
|
}
|
|
441
|
-
|
|
515
|
+
// The Worker runs what is declared: the restart alone, which confirms it (matching bindings
|
|
516
|
+
// also describe a deploy whose restart did not finish). Read and plan remain passive.
|
|
517
|
+
// The token resolved alone: a restart reads no other secret, and uploads none.
|
|
518
|
+
const state = fresh.planned;
|
|
519
|
+
if (!state.revision)
|
|
520
|
+
throw refused(`container ${id}: no revision to confirm`);
|
|
521
|
+
const declaredToken = desired.secrets?.[WAKEUP_TOKEN];
|
|
522
|
+
const token = declaredToken === undefined
|
|
523
|
+
? await call.secrets.ensure(WAKEUP_TOKEN, mintInstanceToken)
|
|
524
|
+
: String(await resolveSecrets(declaredToken, call.secrets));
|
|
525
|
+
const at = { script: scriptOf(id), url: state.worker_url, token };
|
|
526
|
+
await restarted(api(), at, state.revision, state.configurationRevision);
|
|
527
|
+
return { id, state: { ...state, healthy: true } };
|
|
442
528
|
},
|
|
443
529
|
delete: async ({ id }) => {
|
|
444
530
|
const cf = api();
|
|
@@ -457,6 +543,10 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
|
|
|
457
543
|
if (app)
|
|
458
544
|
await cf.deleteApplication(app.id);
|
|
459
545
|
await cf.deleteScript(script);
|
|
546
|
+
// After the Worker, which binds it.
|
|
547
|
+
const database = await cf.hyperdrive(script);
|
|
548
|
+
if (database)
|
|
549
|
+
await cf.deleteHyperdrive(database.id);
|
|
460
550
|
return { id, gone: true };
|
|
461
551
|
},
|
|
462
552
|
};
|