@sanoma-ai/connector-cloudflare 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/connector-cloudflare",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Cloudflare for Sanoma: a Container host that stops when quiet, and a Durable Object alarm that wakes it, from one Worker",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -61,12 +61,12 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@sanoma-ai/testing": "0.1.0",
64
- "@sanoma-ai/workflows": "0.9.0",
65
- "msw": "^3.0.2",
64
+ "@sanoma-ai/workflows": "0.10.0",
65
+ "msw": "^3.0.3",
66
66
  "zod": "^4.6.5"
67
67
  },
68
68
  "peerDependencies": {
69
- "@sanoma-ai/workflows": "^0.9.0",
69
+ "@sanoma-ai/workflows": "^0.10.0",
70
70
  "zod": "^4"
71
71
  },
72
72
  "engines": {
@@ -8,6 +8,8 @@ export declare const HEALTH_PATH: "/__sanoma/health";
8
8
  export declare const INACTIVITY_MS: number;
9
9
  /** The binding that names the object, in the Worker's env. */
10
10
  export declare const NAMESPACE: "SANOMA_INSTANCE";
11
+ /** The name the upload gives the runtime image (`containers[].images`), which the object starts. */
12
+ export declare const IMAGE: "runtime";
11
13
 
12
14
  /** The label naming an image: a digest of it. */
13
15
  export declare function imageLabel(image: string): Promise<string>;
@@ -16,11 +18,21 @@ export declare function imageLabel(image: string): Promise<string>;
16
18
  export interface ObjectState {
17
19
  container: {
18
20
  readonly running: boolean;
19
- start(options: { env: Record<string, string>; enableInternet: boolean; labels: Record<string, string> }): void;
20
- inspect(): Promise<{ labels: Record<string, string> } | null>;
21
+ /** The images the Worker's upload names (`containers[].images`), by name. */
22
+ readonly images: Record<string, string>;
23
+ start(options: {
24
+ image: string;
25
+ instance: string;
26
+ env: Record<string, string>;
27
+ enableInternet: boolean;
28
+ labels: Record<string, string>;
29
+ }): void;
30
+ inspect(): Promise<{ image?: string; labels: Record<string, string> } | null>;
21
31
  destroy(reason?: string): Promise<void>;
22
32
  getTcpPort(port: number): { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
23
33
  setInactivityTimeout(ms: number): Promise<void>;
34
+ /** Settles when the container started last ends: resolved on exit 0, rejected on any other. */
35
+ monitor?(): Promise<void>;
24
36
  };
25
37
  storage: {
26
38
  get<T>(key: string): Promise<T | undefined>;
@@ -3,8 +3,9 @@
3
3
  // a stubbed `ctx`, so both kits check this code. No imports, so Node loads it as it is.
4
4
  //
5
5
  // The object runs the company's container (`ctx.container`): it starts it on a request, at the
6
- // revision and image the Worker's `SANOMA_REVISION` and `SANOMA_IMAGE` name (a running one at
7
- // another is destroyed and started again: a deploy), waits for `/healthz`, and proxies every
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
8
9
  // request to it. It answers `/__sanoma/health` without starting it (with `?start=1`, once it
9
10
  // runs), and holds the company's one wake (`/__sanoma/wake`) as an alarm, which starts the
10
11
  // container at `at` and calls its `/wake`.
@@ -27,11 +28,16 @@ const MAX_POLL_MS = 1_000;
27
28
  const WAKE_KEY = "wake";
28
29
  /** The binding that names the object, in the Worker's env. */
29
30
  export const NAMESPACE = "SANOMA_INSTANCE";
31
+ /** The name the upload gives the runtime image (`containers[].images`), which the object starts. */
32
+ export const IMAGE = "runtime";
33
+ /** The container's size, one of the named types `start` takes: ½ vCPU, 4 GiB (`basic`, ¼ vCPU, is refused under this policy). */
34
+ const INSTANCE_TYPE = "standard-1";
30
35
 
31
36
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
37
  const json = (status, body) =>
33
38
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
34
- const problem = (status, error) => json(status, { error });
39
+ /** A refusal; with `exit`, how the container ended: a deploy stops waiting on it. */
40
+ const problem = (status, error, exit) => json(status, { error, ...(exit !== undefined && { exit }) });
35
41
 
36
42
  /** The Worker's text bindings (plain text and secrets): the container's environment. */
37
43
  function containerEnv(env) {
@@ -64,6 +70,12 @@ export class Instance {
64
70
  #starting;
65
71
  /** True once a container this object started (or found) answered `/healthz`. */
66
72
  #ready = false;
73
+ /** What the last try of a start saw, for the health route while the container is not ready. */
74
+ #last;
75
+ /** How the container this object started ended, when it did: what `monitor` told. */
76
+ #exit;
77
+ /** The start whose `monitor` may set `#exit`: an earlier container's end is not this one's. */
78
+ #started;
67
79
 
68
80
  constructor(ctx, env) {
69
81
  this.ctx = ctx;
@@ -86,13 +98,15 @@ export class Instance {
86
98
  try {
87
99
  await this.ensureRunning();
88
100
  } catch (error) {
89
- return problem(503, `the company's container did not start: ${error?.message ?? error}`);
101
+ return problem(503, `the company's container did not start: ${error?.message ?? error}`, this.#exit);
90
102
  }
91
103
  if (health) return this.health();
92
104
  const headers = new Headers(request.headers);
93
- headers.delete("host");
105
+ // The port's fetch reaches the container whatever the URL's host, which becomes the request's
106
+ // Host header: the one asked for, so the company serves the public site declared on it.
107
+ headers.set("x-forwarded-proto", url.protocol.slice(0, -1));
94
108
  const body = request.method === "GET" || request.method === "HEAD" ? undefined : request.body;
95
- const forwarded = new Request(new URL(url.pathname + url.search, "http://container"), {
109
+ const forwarded = new Request(new URL(url.pathname + url.search, `http://${url.host}`), {
96
110
  method: request.method,
97
111
  headers,
98
112
  body,
@@ -110,7 +124,13 @@ export class Instance {
110
124
 
111
125
  /** What the Worker runs, and whether its container does. */
112
126
  health() {
113
- return json(200, { revision: this.env.SANOMA_REVISION ?? "", running: this.ctx.container.running });
127
+ return json(200, {
128
+ revision: this.env.SANOMA_REVISION ?? "",
129
+ running: this.ctx.container.running,
130
+ ready: this.#ready,
131
+ ...(this.#last !== undefined && !this.#ready && { last: this.#last }),
132
+ ...(this.#exit !== undefined && { exit: this.#exit }),
133
+ });
114
134
  }
115
135
 
116
136
  /** Starts the container at the Worker's revision and image unless it runs them and answered; one start at a time. */
@@ -122,13 +142,29 @@ export class Instance {
122
142
 
123
143
  async #start() {
124
144
  this.#ready = false;
145
+ this.#exit = undefined;
125
146
  const container = this.ctx.container;
126
- const labels = { revision: this.env.SANOMA_REVISION ?? "", image: await imageLabel(this.env.SANOMA_IMAGE ?? "") };
147
+ const image = container.images?.[IMAGE];
148
+ // Not one to retry: the Worker was uploaded without its image.
149
+ if (!image) throw new Error(`the Worker names no "${IMAGE}" image among its containers' images`);
150
+ const labels = { revision: this.env.SANOMA_REVISION ?? "", image: await imageLabel(image) };
127
151
  const until = Date.now() + START_TIMEOUT_MS;
128
152
  let last;
153
+ const saw = (what) => (last = this.#last = what);
129
154
  let wait = POLL_MS;
130
155
  let checked = false;
131
156
  while (Date.now() < until) {
157
+ if (this.#exit) {
158
+ // Right after the application is made, the platform may not have assigned it to the
159
+ // namespace yet: that start fails and is tried again. Any other end is the answer, as a
160
+ // container that ended is not started again and again. Outside the try: a try's failure
161
+ // is what one try saw, and would otherwise swallow this.
162
+ if (!this.#exit.includes("no container application assigned")) {
163
+ throw new Error(`${this.#exit} before answering /healthz on port ${this.port} (${last})`);
164
+ }
165
+ saw(this.#exit);
166
+ this.#exit = undefined;
167
+ }
132
168
  try {
133
169
  if (!checked && container.running) {
134
170
  // One running from before: kept only if it says it started at these (a deploy otherwise).
@@ -138,7 +174,25 @@ export class Instance {
138
174
  }
139
175
  checked = true;
140
176
  if (!container.running) {
141
- container.start({ env: containerEnv(this.env), enableInternet: true, labels });
177
+ container.start({
178
+ image,
179
+ instance: INSTANCE_TYPE,
180
+ env: containerEnv(this.env),
181
+ enableInternet: true,
182
+ labels,
183
+ });
184
+ // `start` returns once its options are checked: a start that fails later, or a container
185
+ // that exits, is what `monitor` tells, and the next try reports it. Only this start's, and
186
+ // only until it answered: an earlier container's monitor may settle after this one started,
187
+ // and one that answered and then stopped (Cloudflare's own stop, a crash) is started again
188
+ // by the next request, not reported as a start that failed.
189
+ this.#exit = undefined;
190
+ const started = (this.#started = {});
191
+ const ended = (how) => void (this.#started === started && !this.#ready && (this.#exit = how));
192
+ container.monitor?.().then(
193
+ () => ended("the container exited"),
194
+ (error) => ended(`the container failed: ${error?.message ?? error}`),
195
+ );
142
196
  }
143
197
  // A try of its own time: a hung one would hold every request past the deadline.
144
198
  const res = await container
@@ -150,16 +204,17 @@ export class Instance {
150
204
  this.#ready = true;
151
205
  return;
152
206
  }
153
- last = `/healthz answered ${res.status}`;
207
+ saw(`/healthz answered ${res.status}`);
154
208
  } catch (error) {
155
209
  // Not listening yet, or not yet free to start again after a destroy.
156
- last = error?.message ?? String(error);
210
+ saw(error?.message ?? String(error));
157
211
  }
158
- // Doubling to a second: a container that crashes as it starts is not started four times a second.
212
+ // Doubling to a second: a container that is slow to listen is not asked four times a second.
159
213
  await sleep(wait);
160
214
  wait = Math.min(wait * 2, MAX_POLL_MS);
161
215
  }
162
- throw new Error(`no answer from /healthz on port ${this.port} in ${START_TIMEOUT_MS / 1000} s (${last})`);
216
+ const ended = this.#exit ? `; ${this.#exit}` : "";
217
+ throw new Error(`no answer from /healthz on port ${this.port} in ${START_TIMEOUT_MS / 1000} s (${last}${ended})`);
163
218
  }
164
219
 
165
220
  /** `/__sanoma/wake`, with the company's `SANOMA_WAKEUP_TOKEN`: the wake this object holds. */