@sanoma-ai/connector-cloudflare 0.4.0 → 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.
package/dist/sites.js ADDED
@@ -0,0 +1,153 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sites.js","sourceRoot":"","sources":["../src/sites.ts"],"names":[],"mappings":"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"}
package/dist/worker.d.ts CHANGED
@@ -1,12 +1,19 @@
1
+ import type { SitesBundle } from "@sanoma-ai/workflows/shared";
1
2
  /** The Worker's entry module, which imports `cloudflare:workers` and `instance.js`. */
2
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";
3
6
  /** The Durable Object class the Worker exports, which the container application runs behind. */
4
7
  export declare const OBJECT_CLASS = "SanomaInstance";
5
8
  /** One module of the Worker: its file name, which the upload names it by, and its source. */
6
9
  export interface WorkerModule {
7
10
  name: string;
8
- content: 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"];
9
14
  }
10
15
  /** The Worker's modules, read from the package once. */
11
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>;
12
19
  export { HEALTH_PATH, IMAGE, INACTIVITY_MS, Instance, NAMESPACE, type ObjectState, worker, } from "../worker/instance.js";
package/dist/worker.js CHANGED
@@ -6,6 +6,8 @@ const WORKER = new URL("../worker/", import.meta.url);
6
6
  export const MAIN_MODULE = "index.js";
7
7
  /** The modules uploaded, the entry first. */
8
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";
9
11
  /** The Durable Object class the Worker exports, which the container application runs behind. */
10
12
  export const OBJECT_CLASS = "SanomaInstance";
11
13
  let modules;
@@ -17,5 +19,11 @@ export function workerModules() {
17
19
  });
18
20
  return modules;
19
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
+ }
20
28
  export { HEALTH_PATH, IMAGE, INACTIVITY_MS, Instance, NAMESPACE, worker, } from "../worker/instance.js";
21
29
  //# sourceMappingURL=worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,+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;AAEtD,gGAAgG;AAChG,MAAM,CAAC,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAQ7C,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,OAAO,EACL,WAAW,EACX,KAAK,EACL,aAAa,EACb,QAAQ,EACR,SAAS,EAET,MAAM,GACP,MAAM,uBAAuB,CAAC"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/connector-cloudflare",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Cloudflare for Sanoma: a Container host that stops when quiet, a Durable Object alarm that wakes it, from one Worker, and a zone's DNS records",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -58,16 +58,17 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "cloudflare": "^7.3.0",
61
+ "mime": "^4.1.1",
61
62
  "srvx": "^0.11.22"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@sanoma-ai/testing": "0.1.0",
65
- "@sanoma-ai/workflows": "0.11.2",
66
+ "@sanoma-ai/workflows": "0.11.3",
66
67
  "msw": "^3.0.3",
67
68
  "zod": "^4.6.5"
68
69
  },
69
70
  "peerDependencies": {
70
- "@sanoma-ai/workflows": "^0.11.2",
71
+ "@sanoma-ai/workflows": "^0.11.3",
71
72
  "zod": "^4"
72
73
  },
73
74
  "engines": {
package/worker/index.js CHANGED
@@ -14,8 +14,13 @@ export class SanomaInstance extends DurableObject {
14
14
  return this.instance.fetch(request);
15
15
  }
16
16
 
17
- alarm() {
18
- return this.instance.alarm();
17
+ alarm(info) {
18
+ return this.instance.alarm(info);
19
+ }
20
+
21
+ /** Over RPC, from the sites router (`sites.js`): a page on the Worker queued a run. */
22
+ startNow() {
23
+ return this.instance.startNow();
19
24
  }
20
25
  }
21
26
 
@@ -38,6 +38,7 @@ export interface ObjectState {
38
38
  get<T>(key: string): Promise<T | undefined>;
39
39
  put(key: string, value: unknown): Promise<void>;
40
40
  delete(key: string): Promise<boolean>;
41
+ getAlarm(): Promise<number | null>;
41
42
  setAlarm(at: number): Promise<void>;
42
43
  deleteAlarm(): Promise<void>;
43
44
  };
@@ -48,8 +49,17 @@ export interface ObjectState {
48
49
  export declare class Instance {
49
50
  constructor(ctx: ObjectState, env: Record<string, unknown>);
50
51
  fetch(request: Request): Promise<Response>;
51
- alarm(): Promise<void>;
52
+ /** `info` as Cloudflare gives it: how many times this alarm was retried. */
53
+ alarm(info?: { retryCount: number; isRetry: boolean }): Promise<void>;
54
+ /** Starts the container from an alarm now, without waiting for it: a run was queued for it. */
55
+ startNow(): Promise<void>;
52
56
  }
53
57
 
58
+ /** The Worker's one object, which every request and the sites router's `startNow` go to. */
59
+ export declare function instanceOf(env: Record<string, unknown>): {
60
+ fetch(request: Request): Promise<Response>;
61
+ startNow(): Promise<void>;
62
+ };
63
+
54
64
  /** The Worker's default export: every request goes to the one object. */
55
65
  export declare const worker: { fetch(request: Request, env: Record<string, unknown>): Promise<Response> };
@@ -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
+ };