@sanoma-ai/connector-cloudflare 0.4.0 → 0.6.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/README.md +58 -33
- package/dist/connector.d.ts +41 -2
- package/dist/connector.js +102 -7
- package/dist/connector.js.map +1 -1
- package/dist/driver.js +34 -4
- package/dist/driver.js.map +1 -1
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/fake.d.ts +16 -2
- package/dist/fake.js +62 -5
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/ops.d.ts +37 -7
- package/dist/ops.js +90 -53
- package/dist/ops.js.map +1 -1
- package/dist/records.js +2 -1
- package/dist/records.js.map +1 -1
- package/dist/resources.d.ts +31 -4
- package/dist/resources.js +7 -5
- package/dist/resources.js.map +1 -1
- package/dist/sites.d.ts +58 -0
- package/dist/sites.js +153 -0
- package/dist/sites.js.map +1 -0
- package/dist/worker.d.ts +8 -1
- package/dist/worker.js +8 -0
- package/dist/worker.js.map +1 -1
- package/package.json +5 -4
- package/worker/index.js +7 -2
- package/worker/instance.d.ts +11 -1
- package/worker/instance.js +75 -14
- package/worker/sites.js +17 -0
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
|
package/dist/worker.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,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.
|
|
3
|
+
"version": "0.6.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
|
-
"@sanoma-ai/testing": "0.1.
|
|
65
|
-
"@sanoma-ai/workflows": "0.11.
|
|
65
|
+
"@sanoma-ai/testing": "0.1.1",
|
|
66
|
+
"@sanoma-ai/workflows": "0.11.4",
|
|
66
67
|
"msw": "^3.0.3",
|
|
67
68
|
"zod": "^4.6.5"
|
|
68
69
|
},
|
|
69
70
|
"peerDependencies": {
|
|
70
|
-
"@sanoma-ai/workflows": "^0.11.
|
|
71
|
+
"@sanoma-ai/workflows": "^0.11.4",
|
|
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
|
|
package/worker/instance.d.ts
CHANGED
|
@@ -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
|
-
|
|
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> };
|
package/worker/instance.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
332
|
+
fetch: (request, env) => instanceOf(env).fetch(request),
|
|
272
333
|
};
|
package/worker/sites.js
ADDED
|
@@ -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
|
+
};
|