@sanoma-ai/connector-cloudflare 0.3.2 → 0.5.0

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.
@@ -8,7 +8,9 @@
8
8
  // another destroyed and started again (a deploy); it waits for `/healthz`, and proxies every
9
9
  // request to it. It answers `/__sanoma/health` without starting it (with `?start=1`, once it
10
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`.
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.
12
14
 
13
15
  /** The object's own route: the wake it holds. */
14
16
  export const WAKE_PATH = "/__sanoma/wake";
@@ -26,6 +28,13 @@ const POLL_MS = 250;
26
28
  const MAX_POLL_MS = 1_000;
27
29
  /** Where the wake is kept, in the object's storage. */
28
30
  const WAKE_KEY = "wake";
31
+ /** Where a start `startNow` asked for is kept until the alarm that makes it rings. */
32
+ const START_KEY = "start";
33
+ /**
34
+ * The retries of an alarm whose start keeps failing before that start is given up, so the wake
35
+ * held for later is set again: Cloudflare retries an alarm that throws up to six times, then drops it.
36
+ */
37
+ const START_RETRIES = 5;
29
38
  /** The binding that names the object, in the Worker's env. */
30
39
  export const NAMESPACE = "SANOMA_INSTANCE";
31
40
  /** The name the upload gives the runtime image (`containers[].images`), which the object starts. */
@@ -44,7 +53,7 @@ function containerEnv(env) {
44
53
  return Object.fromEntries(Object.entries(env).filter(([, value]) => typeof value === "string"));
45
54
  }
46
55
 
47
- /** The label naming an image: a digest of it, as a label holds 64 bytes and a pinned image is longer. */
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. */
48
57
  export async function imageLabel(image) {
49
58
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(image));
50
59
  return [...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -152,6 +161,8 @@ export class Instance {
152
161
  revision: this.env.SANOMA_REVISION ?? "",
153
162
  image: await imageLabel(image),
154
163
  ...(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) }),
155
166
  };
156
167
  const until = Date.now() + START_TIMEOUT_MS;
157
168
  let last;
@@ -178,8 +189,9 @@ export class Instance {
178
189
  info &&
179
190
  info.labels?.revision === labels.revision &&
180
191
  (info.labels?.image ?? "") === labels.image &&
181
- (info.labels?.configuration ?? "") === (labels.configuration ?? "");
182
- if (!same) await container.destroy("a new revision, image or configuration");
192
+ (info.labels?.configuration ?? "") === (labels.configuration ?? "") &&
193
+ (info.labels?.domains ?? "") === (labels.domains ?? "");
194
+ if (!same) await container.destroy("a new revision, image, configuration or domains");
183
195
  }
184
196
  checked = true;
185
197
  if (!container.running) {
@@ -235,9 +247,11 @@ export class Instance {
235
247
  }
236
248
  const storage = this.ctx.storage;
237
249
  if (request.method === "GET") return json(200, (await storage.get(WAKE_KEY)) ?? { at: null });
250
+ // A start `startNow` asked for keeps its alarm, which sets the wake's once it rang.
251
+ const starting = Boolean(await storage.get(START_KEY));
238
252
  if (request.method === "DELETE") {
239
253
  await storage.delete(WAKE_KEY);
240
- await storage.deleteAlarm();
254
+ if (!starting) await storage.deleteAlarm();
241
255
  return new Response(null, { status: 204 });
242
256
  }
243
257
  if (request.method !== "PUT") return problem(405, "GET, PUT or DELETE");
@@ -245,14 +259,55 @@ export class Instance {
245
259
  if (!wake) return problem(400, "the body must be { at: an ISO 8601 time }");
246
260
  // Whatever it held is replaced: a wake that rings for an older one starts a worker with nothing to do.
247
261
  await storage.put(WAKE_KEY, wake);
248
- await storage.setAlarm(Date.parse(wake.at));
262
+ if (starting) await alarmBy(storage, Date.parse(wake.at));
263
+ else await storage.setAlarm(Date.parse(wake.at));
249
264
  return new Response(null, { status: 204 });
250
265
  }
251
266
 
252
- /** The wake is due: start the container and send it `/wake`, then note when. A throw makes Cloudflare retry the alarm. */
253
- async alarm() {
254
- const wake = await this.ctx.storage.get(WAKE_KEY);
255
- if (!wake) return;
267
+ /**
268
+ * 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.
271
+ */
272
+ async startNow() {
273
+ const storage = this.ctx.storage;
274
+ await storage.put(START_KEY, true);
275
+ await alarmBy(storage, Date.now());
276
+ }
277
+
278
+ /**
279
+ * 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.
282
+ */
283
+ async alarm(info) {
284
+ const storage = this.ctx.storage;
285
+ let woke = false;
286
+ if (await storage.get(START_KEY)) {
287
+ try {
288
+ await this.#wakeContainer();
289
+ woke = true;
290
+ } catch (error) {
291
+ if ((info?.retryCount ?? 0) < START_RETRIES) throw error;
292
+ console.error(`starting the container for a queued run failed: ${error?.message ?? error}`);
293
+ }
294
+ await storage.delete(START_KEY);
295
+ }
296
+ const wake = await storage.get(WAKE_KEY);
297
+ if (!wake || wake.rang) return;
298
+ const at = Date.parse(wake.at);
299
+ if (at > Date.now()) return storage.setAlarm(at);
300
+ if (!woke) await this.#wakeContainer();
301
+ // 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() });
304
+ }
305
+
306
+ /**
307
+ * 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.
309
+ */
310
+ async #wakeContainer() {
256
311
  await this.ensureRunning();
257
312
  const res = await this.ctx.container.getTcpPort(this.port).fetch("http://container/wake");
258
313
  await res.body?.cancel();
@@ -260,13 +315,19 @@ export class Instance {
260
315
  console.error(`GET /wake answered ${res.status}`);
261
316
  throw new Error(`the container answered /wake with ${res.status}`);
262
317
  }
263
- // When it rang, which a GET shows: unless another wake replaced it meanwhile.
264
- const held = await this.ctx.storage.get(WAKE_KEY);
265
- if (held?.at === wake.at) await this.ctx.storage.put(WAKE_KEY, { ...held, rang: new Date().toISOString() });
266
318
  }
267
319
  }
268
320
 
321
+ /** Sets the object's alarm to ring by `at`: an alarm set to ring sooner is left as it is. */
322
+ async function alarmBy(storage, at) {
323
+ const set = await storage.getAlarm();
324
+ if (set === null || set > at) await storage.setAlarm(at);
325
+ }
326
+
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");
329
+
269
330
  /** The Worker's default export: every request goes to the one object. */
270
331
  export const worker = {
271
- fetch: (request, env) => env[NAMESPACE].getByName("instance").fetch(request),
332
+ fetch: (request, env) => instanceOf(env).fetch(request),
272
333
  };
@@ -0,0 +1,17 @@
1
+ // The company's Worker when it serves public sites: the main module `cloudflare.container`
2
+ // uploads in place of `index.js` once the container built the sites bundle (`sites/index.js`,
3
+ // `buildWorker` in `@sanoma-ai/app/build`). A request the bundle serves (a page of a public site
4
+ // without a login, on its domain) is answered here, on the Worker, and never waits for the
5
+ // container; everything else goes to the one object, as `index.js` sends it. A run a page starts
6
+ // has the object start the container (`startNow`), which runs it.
7
+ import { instanceOf, worker } from "./instance.js";
8
+ import sites from "./sites/index.js";
9
+
10
+ export { SanomaInstance } from "./index.js";
11
+
12
+ export default {
13
+ fetch(request, env, ctx) {
14
+ if (!sites.serves(request, env)) return worker.fetch(request, env);
15
+ return sites.fetch(request, env, ctx, { startNow: () => instanceOf(env).startNow() });
16
+ },
17
+ };