@sanoma-ai/connector-cloudflare 0.6.0 → 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.
@@ -1,30 +1,43 @@
1
- // The company's Durable Object, `SanomaInstance`, without the platform: `index.js` extends
2
- // `DurableObject` from `cloudflare:workers` with it, and the connector's fake runs it in Node over
3
- // a stubbed `ctx`, so both kits check this code. No imports, so Node loads it as it is.
1
+ // The company's Worker script without the platform: its fetch handler (`serve`) and its Durable
2
+ // Object (`Instance`). `index.js` binds them to `cloudflare:workers` and to the app bundle the
3
+ // deploy uploaded under `app/`; the connector's fake runs this file in Node over a stubbed `ctx`,
4
+ // so both kits check the code Cloudflare runs. No imports, so Node loads it as it is.
4
5
  //
5
- // The object runs the company's container (`ctx.container`): it starts it on a request, at the
6
- // Worker's `SANOMA_REVISION` and the image its upload names (`ctx.container.images.runtime`, as
7
- // the `durable_object` scheduling policy has it: the object says what runs), a running one at
8
- // another destroyed and started again (a deploy); it waits for `/healthz`, and proxies every
9
- // request to it. It answers `/__sanoma/health` without starting it (with `?start=1`, once it
10
- // runs), and holds the company's one wake (`/__sanoma/wake`) as an alarm, which starts the
11
- // container at `at` and calls its `/wake`. `startNow`, which the sites router calls over RPC
12
- // when a page on the Worker queued a run, starts the container from an alarm too, without the
13
- // caller waiting for it, and calls its `/wake`: a worker that stopped itself runs the queued run.
6
+ // The Worker script is the app: every request but the object's own routes and `/wake` goes to the
7
+ // app bundle's fetch, with the visitor's address from `cf-connecting-ip` and the object's
8
+ // `startNow` as its host. The object runs the company's worker in its container (`ctx.container`),
9
+ // never a visitor's request: it starts it at the Worker's `SANOMA_REVISION` and the image its upload
10
+ // names (`ctx.container.images.runtime`, as the `durable_object` scheduling policy has it), drains
11
+ // and replaces one running at another (a deploy's restart), holds the company's one wake as an
12
+ // alarm, and answers three routes: its health, its wake, and the app bundle the worker builds,
13
+ // pulled from the container's port with the instance token.
14
14
 
15
- /** The object's own route: the wake it holds. */
15
+ /** The object's route that holds the company's wake, with the instance token. */
16
16
  export const WAKE_PATH = "/__sanoma/wake";
17
- /** The object's route that answers `{ revision, running }`; with `?start=1`, once the container answers `/healthz`: what a deploy waits for. */
17
+ /** The object's route that answers what the Worker runs; with `?start=1` and the instance token, it starts the container at it. */
18
18
  export const HEALTH_PATH = "/__sanoma/health";
19
- /** How long the container keeps running after the last request: Cloudflare stops it then. */
19
+ /** The worker port's route of the app bundle at `?revision=`, which the object passes on with the instance token (`APP_BUNDLE_PATH` in `@sanoma-ai/workflows/shared`). */
20
+ export const APP_BUNDLE_PATH = "/__sanoma/app-bundle";
21
+ /** The Host contract's wake: the Worker has the worker started now (a wakeup's `GET {url}/wake`). */
22
+ const HOST_WAKE_PATH = "/wake";
23
+ /** The header the app reads the visitor's address from (`CLIENT_IP_HEADER` in `@sanoma-ai/workflows`). */
24
+ export const CLIENT_IP_HEADER = "x-sanoma-client-ip";
25
+ /** How long the container keeps running after the object's last request: Cloudflare stops it then. */
20
26
  export const INACTIVITY_MS = 10 * 60 * 1000;
21
- /** How long a start may take to answer `/healthz`: a cold start clones the source and installs it. */
27
+ /** The binding that names the object, in the Worker's env. */
28
+ export const NAMESPACE = "SANOMA_INSTANCE";
29
+ /** The name the upload gives the runtime image (`containers[].images`), which the object starts. */
30
+ export const IMAGE = "runtime";
31
+ /** How long a start may take to answer `/healthz`: the image pulled, the source cloned and installed. */
22
32
  const START_TIMEOUT_MS = 10 * 60 * 1000;
33
+ /** How long a drained worker has to stop on its own before its container is destroyed. */
34
+ const DRAIN_MS = 30 * 1000;
35
+ /** SIGTERM: the worker's own stop (it stops dequeuing, closes and exits). */
36
+ const SIGTERM = 15;
23
37
  /** How long one try of a starting container's `/healthz` may take. */
24
38
  const TRY_MS = 5_000;
25
- /** How long between two tries of a starting container's `/healthz`. */
39
+ /** How long between two tries of a starting container's `/healthz`, doubling to `MAX_POLL_MS`. */
26
40
  const POLL_MS = 250;
27
- /** The longest wait between two tries. */
28
41
  const MAX_POLL_MS = 1_000;
29
42
  /** Where the wake is kept, in the object's storage. */
30
43
  const WAKE_KEY = "wake";
@@ -35,10 +48,6 @@ const START_KEY = "start";
35
48
  * held for later is set again: Cloudflare retries an alarm that throws up to six times, then drops it.
36
49
  */
37
50
  const START_RETRIES = 5;
38
- /** The binding that names the object, in the Worker's env. */
39
- export const NAMESPACE = "SANOMA_INSTANCE";
40
- /** The name the upload gives the runtime image (`containers[].images`), which the object starts. */
41
- export const IMAGE = "runtime";
42
51
  /** The container's size, one of the named types `start` takes: ½ vCPU, 4 GiB (`basic`, ¼ vCPU, is refused under this policy). */
43
52
  const INSTANCE_TYPE = "standard-1";
44
53
 
@@ -53,9 +62,9 @@ function containerEnv(env) {
53
62
  return Object.fromEntries(Object.entries(env).filter(([, value]) => typeof value === "string"));
54
63
  }
55
64
 
56
- /** The label naming an image (or the sites' domains): a digest of it, as a label holds 64 bytes and a pinned image is longer. */
57
- export async function imageLabel(image) {
58
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(image));
65
+ /** A label of a value too long for one (a label holds 64 bytes; a pinned image is longer): a digest of it. */
66
+ export async function digestLabel(value) {
67
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
59
68
  return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
60
69
  }
61
70
 
@@ -75,7 +84,7 @@ function parseRegistration(body) {
75
84
  }
76
85
 
77
86
  export class Instance {
78
- /** The start in progress, which every request waits on. */
87
+ /** The start in progress, which every caller waits on. */
79
88
  #starting;
80
89
  /** True once a container this object started (or found) answered `/healthz`. */
81
90
  #ready = false;
@@ -98,46 +107,59 @@ export class Instance {
98
107
  return Number(this.env.PORT);
99
108
  }
100
109
 
110
+ /** The object's routes; never a visitor's request, which the app answers on the Worker. */
101
111
  async fetch(request) {
102
112
  const url = new URL(request.url);
103
113
  if (url.pathname === WAKE_PATH) return this.wake(request);
104
- const health = url.pathname === HEALTH_PATH;
105
- // A read asks without starting the container: a stopped host is a healthy one.
106
- if (health && !url.searchParams.has("start")) return this.health();
107
- try {
108
- await this.ensureRunning();
109
- } catch (error) {
110
- return problem(503, `the company's container did not start: ${error?.message ?? error}`, this.#exit);
114
+ if (url.pathname === HEALTH_PATH && !url.searchParams.has("start")) return this.health(200);
115
+ if (url.pathname !== HEALTH_PATH && url.pathname !== APP_BUNDLE_PATH) {
116
+ return problem(404, `the object answers ${WAKE_PATH}, ${HEALTH_PATH} and ${APP_BUNDLE_PATH} alone`);
111
117
  }
112
- if (health) return this.health();
113
- const headers = new Headers(request.headers);
114
- // The port's fetch reaches the container whatever the URL's host, which becomes the request's
115
- // Host header: the one asked for, so the company serves the public site declared on it.
116
- headers.set("x-forwarded-proto", url.protocol.slice(0, -1));
117
- const body = request.method === "GET" || request.method === "HEAD" ? undefined : request.body;
118
- const forwarded = new Request(new URL(url.pathname + url.search, `http://${url.host}`), {
119
- method: request.method,
120
- headers,
121
- body,
122
- redirect: "manual",
123
- ...(body && { duplex: "half" }),
124
- });
118
+ const refused = this.#refuse(request);
119
+ if (refused) return refused;
120
+ // A container that started and ended is the answer: asking again would only start it again.
121
+ // Told once: the next ask starts it again.
122
+ const exit = this.#starting ? undefined : this.#exit;
123
+ if (exit) {
124
+ this.#exit = undefined;
125
+ return problem(503, `the company's container did not start: ${exit}`, exit);
126
+ }
127
+ if (!this.#ready || !this.ctx.container.running) {
128
+ // Started from an alarm, which outlives this request (the caller may be the worker this
129
+ // start drains): the caller asks again until it is ready. One already under way is not asked again.
130
+ if (!this.#starting) await this.startNow();
131
+ return url.pathname === HEALTH_PATH ? this.health(202) : json(202, { starting: true });
132
+ }
133
+ if (url.pathname === HEALTH_PATH) return this.health(200);
134
+ // The worker builds the bundle at the revision asked for, in a checkout of its own.
125
135
  try {
126
- return await this.ctx.container.getTcpPort(this.port).fetch(forwarded);
136
+ return await this.ctx.container.getTcpPort(this.port).fetch(`http://container${url.pathname}${url.search}`, {
137
+ headers: { authorization: `Bearer ${this.env.SANOMA_WAKEUP_TOKEN}` },
138
+ });
127
139
  } catch (error) {
128
- // Stopped since it started (it crashed, or Cloudflare stopped it): the next request starts it again.
140
+ // Stopped since it answered (Cloudflare's own stop, a crash): the next ask starts it again.
129
141
  this.#ready = false;
130
142
  return problem(503, `the company's container did not answer: ${error?.message ?? error}`);
131
143
  }
132
144
  }
133
145
 
134
- /** What the Worker runs, and whether its container does. */
135
- health() {
136
- return json(200, {
146
+ /** Why a request without the company's instance token is refused, if it is. */
147
+ #refuse(request) {
148
+ const token = this.env.SANOMA_WAKEUP_TOKEN;
149
+ if (!token) return problem(404, "no instance token here: SANOMA_WAKEUP_TOKEN is not among the host's secrets");
150
+ if (!sameToken(request.headers.get("authorization") ?? "", `Bearer ${token}`)) {
151
+ return problem(401, "the token is not this host's SANOMA_WAKEUP_TOKEN");
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /** What the Worker runs, and whether its container does and answered at it. */
157
+ health(status) {
158
+ return json(status, {
137
159
  revision: this.env.SANOMA_REVISION ?? "",
138
160
  ...(this.env.SANOMA_CONFIG_REVISION && { configurationRevision: this.env.SANOMA_CONFIG_REVISION }),
139
161
  running: this.ctx.container.running,
140
- ready: this.#ready,
162
+ ready: this.#ready && this.ctx.container.running,
141
163
  ...(this.#last !== undefined && !this.#ready && { last: this.#last }),
142
164
  ...(this.#exit !== undefined && { exit: this.#exit }),
143
165
  });
@@ -159,10 +181,10 @@ export class Instance {
159
181
  if (!image) throw new Error(`the Worker names no "${IMAGE}" image among its containers' images`);
160
182
  const labels = {
161
183
  revision: this.env.SANOMA_REVISION ?? "",
162
- image: await imageLabel(image),
184
+ image: await digestLabel(image),
163
185
  ...(this.env.SANOMA_CONFIG_REVISION && { configuration: this.env.SANOMA_CONFIG_REVISION }),
164
- // The sites' domains, which its environment and the sites bundle it built name.
165
- ...(this.env.SANOMA_DOMAINS && { domains: await imageLabel(this.env.SANOMA_DOMAINS) }),
186
+ // The sites' domains, which the worker's environment, and the bundles it builds, name.
187
+ ...(this.env.SANOMA_DOMAINS && { domains: await digestLabel(this.env.SANOMA_DOMAINS) }),
166
188
  };
167
189
  const until = Date.now() + START_TIMEOUT_MS;
168
190
  let last;
@@ -183,7 +205,8 @@ export class Instance {
183
205
  }
184
206
  try {
185
207
  if (!checked && container.running) {
186
- // One running from before: kept only if it says it started at these (a deploy otherwise).
208
+ // One running from before: kept only if it says it started at these; else the deploy's
209
+ // restart, its worker drained first, so it stops dequeuing before its container goes.
187
210
  const info = await container.inspect();
188
211
  const same =
189
212
  info &&
@@ -191,7 +214,7 @@ export class Instance {
191
214
  (info.labels?.image ?? "") === labels.image &&
192
215
  (info.labels?.configuration ?? "") === (labels.configuration ?? "") &&
193
216
  (info.labels?.domains ?? "") === (labels.domains ?? "");
194
- if (!same) await container.destroy("a new revision, image, configuration or domains");
217
+ if (!same) await this.#replace(container);
195
218
  }
196
219
  checked = true;
197
220
  if (!container.running) {
@@ -206,7 +229,7 @@ export class Instance {
206
229
  // that exits, is what `monitor` tells, and the next try reports it. Only this start's, and
207
230
  // only until it answered: an earlier container's monitor may settle after this one started,
208
231
  // and one that answered and then stopped (Cloudflare's own stop, a crash) is started again
209
- // by the next request, not reported as a start that failed.
232
+ // by the next ask, not reported as a start that failed.
210
233
  this.#exit = undefined;
211
234
  const started = (this.#started = {});
212
235
  const ended = (how) => void (this.#started === started && !this.#ready && (this.#exit = how));
@@ -215,7 +238,7 @@ export class Instance {
215
238
  (error) => ended(`the container failed: ${error?.message ?? error}`),
216
239
  );
217
240
  }
218
- // A try of its own time: a hung one would hold every request past the deadline.
241
+ // A try of its own time: a hung one would hold every caller past the deadline.
219
242
  const res = await container
220
243
  .getTcpPort(this.port)
221
244
  .fetch("http://container/healthz", { signal: AbortSignal.timeout(TRY_MS) });
@@ -235,16 +258,23 @@ export class Instance {
235
258
  wait = Math.min(wait * 2, MAX_POLL_MS);
236
259
  }
237
260
  const ended = this.#exit ? `; ${this.#exit}` : "";
238
- throw new Error(`no answer from /healthz on port ${this.port} in ${START_TIMEOUT_MS / 1000} s (${last}${ended})`);
261
+ throw new Error(
262
+ `still starting after ${START_TIMEOUT_MS / 1000} s: no answer from /healthz on port ${this.port} (${last}${ended})`,
263
+ );
264
+ }
265
+
266
+ /** Drains the running container's worker (SIGTERM: its own stop), then destroys what is left of it. */
267
+ async #replace(container) {
268
+ container.signal(SIGTERM);
269
+ const until = Date.now() + DRAIN_MS;
270
+ while (container.running && Date.now() < until) await sleep(POLL_MS);
271
+ if (container.running) await container.destroy("a new revision, image, configuration or domains");
239
272
  }
240
273
 
241
274
  /** `/__sanoma/wake`, with the company's `SANOMA_WAKEUP_TOKEN`: the wake this object holds. */
242
275
  async wake(request) {
243
- const token = this.env.SANOMA_WAKEUP_TOKEN;
244
- if (!token) return problem(404, "no wakeup here: SANOMA_WAKEUP_TOKEN is not among the host's secrets");
245
- if (!sameToken(request.headers.get("authorization") ?? "", `Bearer ${token}`)) {
246
- return problem(401, "the token is not this host's SANOMA_WAKEUP_TOKEN");
247
- }
276
+ const refused = this.#refuse(request);
277
+ if (refused) return refused;
248
278
  const storage = this.ctx.storage;
249
279
  if (request.method === "GET") return json(200, (await storage.get(WAKE_KEY)) ?? { at: null });
250
280
  // A start `startNow` asked for keeps its alarm, which sets the wake's once it rang.
@@ -266,8 +296,9 @@ export class Instance {
266
296
 
267
297
  /**
268
298
  * Starts the container soon and sends it `/wake`, without waiting for it: a run was queued for
269
- * it (the sites router's call, over RPC, once a page on the Worker started one). An alarm now,
270
- * which outlives the request that asked, makes the start; a wake held for later is set again once it is made.
299
+ * it (the app's call over RPC), a wake came, or a deploy asks for its bundle or its restart. An
300
+ * alarm now, which outlives the request that asked, makes the start; a wake held for later is set
301
+ * again once it is made.
271
302
  */
272
303
  async startNow() {
273
304
  const storage = this.ctx.storage;
@@ -277,8 +308,9 @@ export class Instance {
277
308
 
278
309
  /**
279
310
  * A start `startNow` asked for, then the wake if it is due: start the container and send it
280
- * `/wake` (once for both), then note when the wake rang. A wake not due yet is set again. A throw makes Cloudflare retry the
281
- * alarm; a start that still fails after `START_RETRIES` is given up, the wake kept.
311
+ * `/wake` (once for both), then note when the wake rang. A wake not due yet is set again. A throw
312
+ * makes Cloudflare retry the alarm; a start that still fails after `START_RETRIES` is given up,
313
+ * the wake kept.
282
314
  */
283
315
  async alarm(info) {
284
316
  const storage = this.ctx.storage;
@@ -299,13 +331,13 @@ export class Instance {
299
331
  if (at > Date.now()) return storage.setAlarm(at);
300
332
  if (!woke) await this.#wakeContainer();
301
333
  // When it rang, which a GET shows: unless another wake replaced it meanwhile.
302
- const held = await this.ctx.storage.get(WAKE_KEY);
303
- if (held?.at === wake.at) await this.ctx.storage.put(WAKE_KEY, { ...held, rang: new Date().toISOString() });
334
+ const held = await storage.get(WAKE_KEY);
335
+ if (held?.at === wake.at) await storage.put(WAKE_KEY, { ...held, rang: new Date().toISOString() });
304
336
  }
305
337
 
306
338
  /**
307
339
  * Starts the container unless it runs, then sends it `/wake`: a container that is up may hold a
308
- * worker that stopped itself, which only a wake (or a write through its app) starts again.
340
+ * worker that stopped itself, which only a wake starts again.
309
341
  */
310
342
  async #wakeContainer() {
311
343
  await this.ensureRunning();
@@ -324,10 +356,28 @@ async function alarmBy(storage, at) {
324
356
  if (set === null || set > at) await storage.setAlarm(at);
325
357
  }
326
358
 
327
- /** The Worker's one object, which every request and the sites router's `startNow` go to. */
328
- export const instanceOf = (env) => env[NAMESPACE].getByName("instance");
359
+ /** The Worker's one object. */
360
+ const instanceOf = (env) => env[NAMESPACE].getByName("instance");
329
361
 
330
- /** The Worker's default export: every request goes to the one object. */
331
- export const worker = {
332
- fetch: (request, env) => instanceOf(env).fetch(request),
333
- };
362
+ /**
363
+ * The Worker script's fetch: the object's routes to the object, the Host contract's `/wake` as a
364
+ * start now, and everything else to the app bundle (`loadApp`, imported on the first request, so
365
+ * the object's isolate never evaluates it), its visitor's address set from Cloudflare's
366
+ * `cf-connecting-ip`, a client's own copy overwritten: the one trust decision the app relies on.
367
+ */
368
+ export async function serve(request, env, ctx, loadApp) {
369
+ const url = new URL(request.url);
370
+ const object = instanceOf(env);
371
+ if (url.pathname === WAKE_PATH || url.pathname === HEALTH_PATH || url.pathname === APP_BUNDLE_PATH) {
372
+ return object.fetch(request);
373
+ }
374
+ if (url.pathname === HOST_WAKE_PATH && request.method === "GET") {
375
+ await object.startNow();
376
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
377
+ }
378
+ const headers = new Headers(request.headers);
379
+ headers.set(CLIENT_IP_HEADER, request.headers.get("cf-connecting-ip") ?? "");
380
+ const { default: app } = await loadApp();
381
+ // The object's stub is the app's host: its `startNow` over RPC.
382
+ return app.fetch(new Request(request, { headers }), env, ctx, object);
383
+ }
@@ -0,0 +1,10 @@
1
+ // The app bundle of a Worker made before its first bundle was built (a create's first uploads,
2
+ // as `app/index.js`): the worker builds the bundle once its container runs, and the create
3
+ // uploads the Worker with it. Nothing was served here before, so a visitor is told to come back.
4
+ export default {
5
+ fetch: () =>
6
+ new Response("The company's app is being set up: try again in a minute.\n", {
7
+ status: 503,
8
+ headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "30" },
9
+ }),
10
+ };
package/dist/sites.d.ts DELETED
@@ -1,58 +0,0 @@
1
- import { type SitesBundle } from "@sanoma-ai/workflows/shared";
2
- import type { CloudflareApi, Upload } from "./ops.ts";
3
- /** The binding the bundle reads its assets through (`WorkerEnv.ASSETS`). */
4
- export declare const ASSETS = "ASSETS";
5
- /**
6
- * The plain-text binding of a Worker whose sites upload is still to be made: the deploy's upload
7
- * sets it, `serveSites`' takes it off. A read that finds it plans an update, which makes that upload.
8
- */
9
- export declare const SITES_PENDING = "SANOMA_SITES_PENDING";
10
- /**
11
- * How the Worker serves its assets: the client build under `/assets/` straight from them (a path
12
- * there they lack goes on to the Worker, as the app's own do), every other path through the
13
- * Worker first, which answers a prerendered page from them itself; never an HTML rewrite or a
14
- * fallback page of the assets' own.
15
- */
16
- export declare const ASSETS_CONFIG: {
17
- readonly html_handling: "none";
18
- readonly not_found_handling: "none";
19
- readonly run_worker_first: readonly ["/*", "!/assets/*"];
20
- };
21
- /** An asset's hash as a session's manifest names it: as the API reference's example has it, sha256 over its base64 and its extension, 32 hex digits. */
22
- export declare const assetHash: (base64: string, path: string) => string;
23
- interface Polling {
24
- /** How long the container may take to build the bundle. */
25
- startTimeoutMs: number;
26
- /** How long between two asks. */
27
- pollMs: number;
28
- }
29
- /**
30
- * The bundle the container at `url` built, asked with the instance token until it answers it
31
- * (202 while it builds); undefined when it has no site to serve on the Worker (404), or the
32
- * runtime has no such route: a 401 or a 404 without `SITES_BUNDLE_HEADER`, an image from before
33
- * it, whose staff login refuses the path (or whose app has no page there). A failed build, or a
34
- * token it refuses, is a refusal; any other answer (the object's 503 while the container starts
35
- * again, an error of Cloudflare's, a body cut off) is asked again, and still one at the timeout
36
- * is a retryable `DriverError`.
37
- */
38
- export declare function sitesBundle(cf: CloudflareApi, script: string, url: string, token: string, { startTimeoutMs, pollMs }: Polling): Promise<SitesBundle | undefined>;
39
- /** Puts `assets` in through an upload session for `script`'s next upload: the completion jwt that upload redeems. */
40
- export declare function uploadAssets(cf: CloudflareApi, script: string, assets: SitesBundle["assets"]): Promise<string>;
41
- /** What `serveSites` uploads again: the Worker as the deploy uploaded it. */
42
- interface Deployed extends Polling {
43
- script: string;
44
- url: string;
45
- /** The instance token (`SANOMA_WAKEUP_TOKEN`), which the container's bundle route takes. */
46
- token: string;
47
- metadata: Omit<Upload, "migrations">;
48
- }
49
- /**
50
- * The public sites on the Worker: the bundle the container built, its assets uploaded, and the
51
- * Worker uploaded again with them and the sites router as its entry (its bindings, image and
52
- * revision as they were, so its container keeps running). When the container has none to give,
53
- * the Worker is uploaded again as the deploy uploaded it. Either way without `SITES_PENDING`,
54
- * which the deploy's upload carries: a Worker whose sites upload failed keeps it, which a read
55
- * sees, so the next apply makes this upload again.
56
- */
57
- export declare function serveSites(cf: CloudflareApi, { script, url, token, metadata, ...polling }: Deployed): Promise<void>;
58
- export {};
package/dist/sites.js DELETED
@@ -1,153 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { extname } from "node:path";
3
- import { setTimeout as delay } from "node:timers/promises";
4
- import { DriverError, errorMessage } from "@sanoma-ai/workflows";
5
- import { SITES_BUNDLE_HEADER, SITES_BUNDLE_PATH } from "@sanoma-ai/workflows/shared";
6
- import mime from "mime";
7
- import { refused } from "./errors.js";
8
- import { SITES_MODULE, sitesRouter } from "./worker.js";
9
- // The public sites on the company's Worker (sanoma#428): once a deploy's container answers at its
10
- // revision, the sites bundle it built (`buildWorker` in `@sanoma-ai/app/build`) is downloaded from
11
- // it with the instance token, its assets go in through an assets upload session, and the Worker is
12
- // uploaded again with the bundle's modules under `sites/`, the router `worker/sites.js` as its
13
- // entry and the assets bound as `ASSETS`. A page of a public site without a login then answers
14
- // from the Worker, never waiting for the container; everything else goes to the object as before.
15
- /** The binding the bundle reads its assets through (`WorkerEnv.ASSETS`). */
16
- export const ASSETS = "ASSETS";
17
- /**
18
- * The plain-text binding of a Worker whose sites upload is still to be made: the deploy's upload
19
- * sets it, `serveSites`' takes it off. A read that finds it plans an update, which makes that upload.
20
- */
21
- export const SITES_PENDING = "SANOMA_SITES_PENDING";
22
- /**
23
- * How the Worker serves its assets: the client build under `/assets/` straight from them (a path
24
- * there they lack goes on to the Worker, as the app's own do), every other path through the
25
- * Worker first, which answers a prerendered page from them itself; never an HTML rewrite or a
26
- * fallback page of the assets' own.
27
- */
28
- export const ASSETS_CONFIG = {
29
- html_handling: "none",
30
- not_found_handling: "none",
31
- run_worker_first: ["/*", "!/assets/*"],
32
- };
33
- /** An asset's hash as a session's manifest names it: as the API reference's example has it, sha256 over its base64 and its extension, 32 hex digits. */
34
- export const assetHash = (base64, path) => createHash("sha256")
35
- .update(base64 + extname(path).slice(1))
36
- .digest("hex")
37
- .slice(0, 32);
38
- /**
39
- * The bundle the container at `url` built, asked with the instance token until it answers it
40
- * (202 while it builds); undefined when it has no site to serve on the Worker (404), or the
41
- * runtime has no such route: a 401 or a 404 without `SITES_BUNDLE_HEADER`, an image from before
42
- * it, whose staff login refuses the path (or whose app has no page there). A failed build, or a
43
- * token it refuses, is a refusal; any other answer (the object's 503 while the container starts
44
- * again, an error of Cloudflare's, a body cut off) is asked again, and still one at the timeout
45
- * is a retryable `DriverError`.
46
- */
47
- export async function sitesBundle(cf, script, url, token, { startTimeoutMs, pollMs }) {
48
- const until = Date.now() + startTimeoutMs;
49
- for (;;) {
50
- let last;
51
- try {
52
- const res = await cf.send(`${url}${SITES_BUNDLE_PATH}`, {
53
- method: "GET",
54
- headers: { authorization: `Bearer ${token}` },
55
- });
56
- const route = res.headers.has(SITES_BUNDLE_HEADER);
57
- if (!route && (res.status === 401 || res.status === 404))
58
- return void (await res.body?.cancel());
59
- const text = await res.text();
60
- const body = parsed(text);
61
- if (route && res.status === 200 && body !== undefined)
62
- return body;
63
- const error = body?.error;
64
- const said = typeof error === "string" ? error : `${SITES_BUNDLE_PATH} answered ${res.status}`;
65
- if (route && res.status === 404)
66
- return undefined;
67
- if (route && res.status !== 200 && res.status !== 202) {
68
- throw refused(`${script}: its container did not give the sites bundle (its sites are served from the container meanwhile): ${said}`);
69
- }
70
- last = !route ? said : res.status === 202 ? "it is building the bundle" : "its bundle was cut off";
71
- }
72
- catch (error) {
73
- if (!(error instanceof TypeError || (error instanceof Error && error.name === "TimeoutError")))
74
- throw error;
75
- // No answer this time (the container stopped under it, a timeout, a body cut off): the next ask starts it again.
76
- last = errorMessage(error);
77
- }
78
- if (Date.now() >= until) {
79
- throw new DriverError(`cloudflare: ${script}: no sites bundle from its container in ${Math.round(startTimeoutMs / 1000)} s (its sites are served from the container meanwhile): ${last}`, { retryable: true });
80
- }
81
- await delay(pollMs);
82
- }
83
- }
84
- /** `text` as JSON; undefined when it is none (a body cut off where the JSON is). */
85
- const parsed = (text) => {
86
- try {
87
- return JSON.parse(text);
88
- }
89
- catch {
90
- return undefined;
91
- }
92
- };
93
- /** Puts `assets` in through an upload session for `script`'s next upload: the completion jwt that upload redeems. */
94
- export async function uploadAssets(cf, script, assets) {
95
- const byHash = new Map();
96
- const manifest = {};
97
- for (const { path, content } of assets) {
98
- const hash = assetHash(content, path);
99
- manifest[path] = { hash, size: Buffer.byteLength(content, "base64") };
100
- byHash.set(hash, { hash, base64: content, contentType: mime.getType(path) ?? "application/octet-stream" });
101
- }
102
- const { jwt, buckets } = await cf.assetsSession(script, manifest);
103
- // Every asset already in: the session's jwt is the completion one.
104
- if (!buckets.length)
105
- return jwt;
106
- // Every bucket's files first, so a hash no asset has is refused before any request starts.
107
- const files = buckets.map((bucket) => bucket.map((hash) => {
108
- const file = byHash.get(hash);
109
- if (!file)
110
- throw refused(`${script}: the assets upload session asks for ${hash}, which no asset hashes to`);
111
- return file;
112
- }));
113
- // Independent requests under one jwt: the one that completes the set answers the completion jwt.
114
- const answers = await Promise.all(files.map((bucket) => cf.uploadAssets(jwt, bucket)));
115
- const completion = answers.find((answer) => answer !== undefined);
116
- if (!completion)
117
- throw refused(`${script}: the last bucket of assets was uploaded, and Cloudflare gave no completion jwt`);
118
- return completion;
119
- }
120
- /**
121
- * The public sites on the Worker: the bundle the container built, its assets uploaded, and the
122
- * Worker uploaded again with them and the sites router as its entry (its bindings, image and
123
- * revision as they were, so its container keeps running). When the container has none to give,
124
- * the Worker is uploaded again as the deploy uploaded it. Either way without `SITES_PENDING`,
125
- * which the deploy's upload carries: a Worker whose sites upload failed keeps it, which a read
126
- * sees, so the next apply makes this upload again.
127
- */
128
- export async function serveSites(cf, { script, url, token, metadata, ...polling }) {
129
- const bundle = await sitesBundle(cf, script, url, token, polling);
130
- const bindings = metadata.bindings.filter((b) => b.name !== SITES_PENDING);
131
- if (!bundle)
132
- return cf.upload(script, { ...metadata, bindings });
133
- if (bundle.mainModule !== "index.js") {
134
- throw refused(`${script}: the sites bundle's entry is ${bundle.mainModule}; the router imports sites/index.js`);
135
- }
136
- const jwt = await uploadAssets(cf, script, bundle.assets);
137
- await cf.upload(script, {
138
- ...metadata,
139
- main_module: SITES_MODULE,
140
- bindings: [...bindings, { type: "assets", name: ASSETS }],
141
- assets: { jwt, config: ASSETS_CONFIG },
142
- modules: [
143
- ...metadata.modules,
144
- await sitesRouter(),
145
- ...bundle.modules.map(({ name, type, content }) => ({
146
- name: `sites/${name}`,
147
- type,
148
- content: Buffer.from(content, "base64"),
149
- })),
150
- ],
151
- });
152
- }
153
- //# sourceMappingURL=sites.js.map
package/dist/sites.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"sites.js","sourceRoot":"","sources":["../src/sites.ts"],"names":[],"mappings":"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"}
package/dist/worker.d.ts DELETED
@@ -1,19 +0,0 @@
1
- import type { SitesBundle } from "@sanoma-ai/workflows/shared";
2
- /** The Worker's entry module, which imports `cloudflare:workers` and `instance.js`. */
3
- export declare const MAIN_MODULE = "index.js";
4
- /** The entry module of a Worker that serves the public sites, beside `MODULES` and the sites bundle under `sites/` (`sites.ts`). */
5
- export declare const SITES_MODULE = "sites.js";
6
- /** The Durable Object class the Worker exports, which the container application runs behind. */
7
- export declare const OBJECT_CLASS = "SanomaInstance";
8
- /** One module of the Worker: its file name, which the upload names it by, and its source. */
9
- export interface WorkerModule {
10
- name: string;
11
- content: string | Uint8Array<ArrayBuffer>;
12
- /** What the module is, as the upload types its part: an ES module unless it says. */
13
- type?: SitesBundle["modules"][number]["type"];
14
- }
15
- /** The Worker's modules, read from the package once. */
16
- export declare function workerModules(): Promise<WorkerModule[]>;
17
- /** The sites router, `worker/sites.js`, read from the package once. */
18
- export declare function sitesRouter(): Promise<WorkerModule>;
19
- export { HEALTH_PATH, IMAGE, INACTIVITY_MS, Instance, NAMESPACE, type ObjectState, worker, } from "../worker/instance.js";
package/dist/worker.js DELETED
@@ -1,29 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- // The company's Worker, as the package ships it in `worker/`: the files `cloudflare.container`
3
- // uploads, and the object's code, which the fake runs in this process.
4
- const WORKER = new URL("../worker/", import.meta.url);
5
- /** The Worker's entry module, which imports `cloudflare:workers` and `instance.js`. */
6
- export const MAIN_MODULE = "index.js";
7
- /** The modules uploaded, the entry first. */
8
- const MODULES = [MAIN_MODULE, "instance.js"];
9
- /** The entry module of a Worker that serves the public sites, beside `MODULES` and the sites bundle under `sites/` (`sites.ts`). */
10
- export const SITES_MODULE = "sites.js";
11
- /** The Durable Object class the Worker exports, which the container application runs behind. */
12
- export const OBJECT_CLASS = "SanomaInstance";
13
- let modules;
14
- /** The Worker's modules, read from the package once. */
15
- export function workerModules() {
16
- modules ??= Promise.all(MODULES.map(async (name) => ({ name, content: await readFile(new URL(name, WORKER), "utf8") }))).catch((err) => {
17
- modules = undefined;
18
- throw err;
19
- });
20
- return modules;
21
- }
22
- let router;
23
- /** The sites router, `worker/sites.js`, read from the package once. */
24
- export function sitesRouter() {
25
- router ??= readFile(new URL(SITES_MODULE, WORKER), "utf8").then((content) => ({ name: SITES_MODULE, content }));
26
- return router;
27
- }
28
- export { HEALTH_PATH, IMAGE, INACTIVITY_MS, Instance, NAMESPACE, worker, } from "../worker/instance.js";
29
- //# sourceMappingURL=worker.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,+FAA+F;AAC/F,uEAAuE;AAEvE,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,YAAY,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;AAEtD,uFAAuF;AACvF,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;AACtC,6CAA6C;AAC7C,MAAM,OAAO,GAAG,CAAC,WAAW,EAAE,aAAa,CAAU,CAAC;AACtD,oIAAoI;AACpI,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;AAEvC,gGAAgG;AAChG,MAAM,CAAC,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAU7C,IAAI,OAA4C,CAAC;AAEjD,wDAAwD;AACxD,MAAM,UAAU,aAAa;IAC3B,OAAO,KAAK,OAAO,CAAC,GAAG,CACrB,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,CAChG,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACvB,OAAO,GAAG,SAAS,CAAC;QACpB,MAAM,GAAG,CAAC;IACZ,CAAC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,IAAI,MAAyC,CAAC;AAE9C,uEAAuE;AACvE,MAAM,UAAU,WAAW;IACzB,MAAM,KAAK,QAAQ,CAAC,IAAI,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IAChH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,OAAO,EACL,WAAW,EACX,KAAK,EACL,aAAa,EACb,QAAQ,EACR,SAAS,EAET,MAAM,GACP,MAAM,uBAAuB,CAAC"}