@sanoma-ai/connector-cloudflare 0.6.1 → 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.
package/dist/fake.d.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import type { AppBundle } from "@sanoma-ai/app";
1
2
  import { type FakeOptions } from "@sanoma-ai/workflows/fake";
2
3
  import { host } from "./connector.ts";
3
- import { type Application, type ReadBinding, type ZoneRecord } from "./ops.ts";
4
+ import { type Application, type HyperdriveOrigin, type ReadBinding, type ZoneRecord } from "./ops.ts";
4
5
  /** A `GET /wake` a container answered: when. */
5
6
  export interface FakeWake {
6
7
  at: Date;
@@ -21,6 +22,12 @@ export type StartContainer = (input: {
21
22
  export interface FakeCloudflareOptions extends FakeOptions {
22
23
  /** What a container runs. Default: a stub that answers `/healthz` 200 and `/wake` 204. */
23
24
  start?: StartContainer;
25
+ /**
26
+ * The app bundle a container's worker builds at a revision, which its port's
27
+ * `/__sanoma/app-bundle` answers (202 on the first ask, as the runtime's route does). Default:
28
+ * `stubAppBundle`.
29
+ */
30
+ bundle?: (revision: string) => AppBundle;
24
31
  /** The zones the account holds, by name. Default: every one asked for, as a sandbox's account holds its domains. */
25
32
  zones?: readonly string[];
26
33
  }
@@ -37,8 +44,14 @@ export interface FakeCloudflareState extends Record<string, unknown> {
37
44
  images: Record<string, string>;
38
45
  main_module?: string;
39
46
  modules?: string[];
47
+ compatibility_flags?: string[];
40
48
  assets?: Record<string, string>;
41
49
  }>;
50
+ /** The account's Hyperdrive configs, by id: each one's name and its origin, its password left out. */
51
+ hyperdrives: Record<string, {
52
+ name: string;
53
+ origin: Omit<HyperdriveOrigin, "password">;
54
+ }>;
42
55
  /** The account's assets, by hash: the type each was uploaded with (the content is not kept). */
43
56
  assets: Record<string, string>;
44
57
  /** The container applications, by id. */
@@ -58,27 +71,40 @@ export interface FakeCloudflareState extends Record<string, unknown> {
58
71
  /** Each zone's DNS records, by the zone's name: any set's, and any added by hand (a test's). */
59
72
  dns: Record<string, ZoneRecord[]>;
60
73
  }
74
+ /**
75
+ * The app bundle the fake's containers build unless it is given `bundle`: one main module and one
76
+ * asset. Its fetch answers `/healthz`, an asset from `ASSETS`, a POST by having the host start the
77
+ * worker (as a page that queues a run does), and any other page with the revision it was built at
78
+ * (`x-sanoma-revision`) and the visitor's address it was given (`x-sanoma-client-ip`); a 500
79
+ * without its `DATABASE` binding. Not the app's own bundle: that one is a build for `workerd`
80
+ * (`nodejs_compat`, its wasm modules), which Node cannot import.
81
+ */
82
+ export declare function stubAppBundle(revision: string): AppBundle;
61
83
  /**
62
84
  * An in-memory Cloudflare account for tests, as the API answers: Workers in `state.scripts`,
63
- * container applications in `state.applications`, the images copied into its registry in
64
- * `state.images` (nothing is pulled), their custom domains in `state.domains` (every zone held
65
- * unless `zones` names them), each zone's DNS records in `state.dns`, by the zone's name (a batch
66
- * is all or nothing: an identical record or one beside a CNAME fails it whole), and the account's
67
- * workers.dev URLs served in
68
- * this process, one server on a port of this machine per Worker name (a name with no Worker
69
- * answers workers.dev's 404). Each Worker runs the package's own object code
70
- * (`worker/instance.js`) over a `ctx` in this process: its container runs `start`'s runtime
71
- * (the image and the revision label it was started at, the Worker's text bindings as its
72
- * environment), its storage is in memory, and its alarm rings on a timer, again after a throw.
73
- * An upload restarts the object over the new bindings and images, its container, storage and
74
- * alarm kept: a new image starts the container again at the next request, as the object finds its
75
- * label changed. `copyImage` pins its copy by a made-up digest, and `prepareImage` answers ready
76
- * for an image pinned in the account's registry. An assets upload session asks for the hashes
77
- * `state.assets` lacks, in one bucket, and an upload that redeems its completion jwt keeps each
78
- * path's hash on the Worker (`state.scripts[…].assets`); the Worker's requests still go to its
79
- * object, as the fake runs no sites bundle. `stop(name)` stops a company's
80
- * container as ten quiet minutes would, `fire()` rings every alarm now, `wakes()` lists the
81
- * `/wake`s the containers answered, `close()` (and `reset()`) stops everything.
85
+ * container applications in `state.applications`, Hyperdrive configs in `state.hyperdrives`
86
+ * (their passwords left out), the images copied into its registry in `state.images` (nothing is
87
+ * pulled), their custom domains in `state.domains` (every zone held unless `zones` names them),
88
+ * each zone's DNS records in `state.dns`, by the zone's name (a batch is all or nothing: an
89
+ * identical record or one beside a CNAME fails it whole), and the account's workers.dev URLs
90
+ * served in this process, one server on a port of this machine per Worker name (a name with no
91
+ * Worker answers workers.dev's 404).
92
+ *
93
+ * Each Worker runs the package's own code (`worker/instance.js`): its fetch (`serve`) over the
94
+ * upload's `app/index.js`, written to a folder and imported (the placeholder of a create's first
95
+ * uploads, then the bundle's main module, `stubAppBundle` unless `bundle` says), its `DATABASE`
96
+ * the config's direct URL, its `ASSETS` the uploaded contents; and its object over a `ctx` in this
97
+ * process: its container runs `start`'s runtime (the image and the revision label it was started
98
+ * at, the Worker's text bindings as its environment), its port's `/__sanoma/app-bundle` answers
99
+ * `bundle`'s as the runtime's route does, SIGTERM stops the runtime, its storage is in memory, and
100
+ * its alarm rings on a timer, again after a throw. An upload restarts the object over the new
101
+ * bindings and images, its container, storage and alarm kept: the object finds the labels changed
102
+ * at the next start, and drains and replaces it. `copyImage` pins its copy by a made-up digest,
103
+ * and `prepareImage` answers ready for an image pinned in the account's registry. An assets upload
104
+ * session asks for the hashes `state.assets` lacks, in one bucket, and an upload that redeems its
105
+ * completion jwt keeps each path's hash on the Worker (`state.scripts[…].assets`). `stop(name)`
106
+ * stops a company's container as ten quiet minutes would, `fire()` rings every alarm now,
107
+ * `wakes()` lists the `/wake`s the containers answered, `close()` (and `reset()`) stops everything.
82
108
  */
83
109
  export declare function fakeCloudflare(options?: FakeCloudflareOptions): import("@sanoma-ai/workflows/fake").Fake<FakeCloudflareState, import("@sanoma-ai/workflows").OpIdOf<"cloudflare", {
84
110
  host: (typeof host)["ops"];
package/dist/fake.js CHANGED
@@ -1,13 +1,27 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { createHash } from "node:crypto";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
11
  import { createServer } from "node:http";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
3
15
  import { DriverError, errorMessage, retryableStatus } from "@sanoma-ai/workflows";
4
16
  import { defineFake } from "@sanoma-ai/workflows/fake";
17
+ import { APP_BUNDLE_HEADER, APP_BUNDLE_PATH } from "@sanoma-ai/workflows/shared";
5
18
  import { NodeRequest, sendNodeResponse } from "srvx/node";
19
+ import { Instance, serve } from "../worker/instance.js";
6
20
  import { caaContent, cloudflareOps, host } from "./connector.js";
21
+ import { OBJECT_CLASS } from "./deploy.js";
7
22
  import { alarmOps, containerOps, inZone, scriptOf, } from "./ops.js";
8
23
  import { recordsOps } from "./records.js";
9
24
  import { accountImage, accountPinned, pinImage } from "./registry.js";
10
- import { Instance, OBJECT_CLASS, worker } from "./worker.js";
11
25
  /** The fake account's id, which its registry's references name: the fixtures' made-up one. */
12
26
  const FAKE_ACCOUNT = "0123456789abcdef0123456789abcdef";
13
27
  /** The named sizes `start` takes under the policy: `basic` is not one of them (the runtime refuses it, as it did live on 2026-10-28). */
@@ -35,6 +49,8 @@ function dnsRecordOf(zone, { type, name, ttl, comment, proxied, content, priorit
35
49
  proxied,
36
50
  };
37
51
  }
52
+ /** A Hyperdrive config's origin as a database URL: what the fake's `DATABASE` binding gives. */
53
+ const connectionString = ({ scheme, user, password, host: at, port, database }) => `${scheme}://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${at}:${port}/${encodeURIComponent(database)}`;
38
54
  /** workers.dev's answer for a name with no Worker behind it. */
39
55
  const nothingHere = () => new Response("<!doctype html><title>There is nothing here yet</title>", {
40
56
  status: 404,
@@ -60,6 +76,57 @@ const stubContainer = async () => {
60
76
  stop: () => new Promise((done) => server.close(() => done())),
61
77
  };
62
78
  };
79
+ const b64 = (text) => Buffer.from(text).toString("base64");
80
+ /**
81
+ * The app bundle the fake's containers build unless it is given `bundle`: one main module and one
82
+ * asset. Its fetch answers `/healthz`, an asset from `ASSETS`, a POST by having the host start the
83
+ * worker (as a page that queues a run does), and any other page with the revision it was built at
84
+ * (`x-sanoma-revision`) and the visitor's address it was given (`x-sanoma-client-ip`); a 500
85
+ * without its `DATABASE` binding. Not the app's own bundle: that one is a build for `workerd`
86
+ * (`nodejs_compat`, its wasm modules), which Node cannot import.
87
+ */
88
+ export function stubAppBundle(revision) {
89
+ const main = `const REVISION = ${JSON.stringify(revision)};
90
+ export default {
91
+ async fetch(request, env, ctx, host) {
92
+ if (typeof env.DATABASE?.connectionString !== "string") return new Response("no DATABASE binding\\n", { status: 500 });
93
+ const url = new URL(request.url);
94
+ if (url.pathname === "/healthz") return new Response("ok\\n");
95
+ if (url.pathname.startsWith("/assets/")) return env.ASSETS.fetch(request);
96
+ if (request.method === "POST") {
97
+ ctx.waitUntil(host.startNow());
98
+ return new Response(null, { status: 202 });
99
+ }
100
+ return new Response(\`<!doctype html><title>\${REVISION}</title>\\n\`, {
101
+ headers: {
102
+ "content-type": "text/html",
103
+ "x-sanoma-revision": REVISION,
104
+ "x-sanoma-client-ip": request.headers.get("x-sanoma-client-ip") ?? "",
105
+ },
106
+ });
107
+ },
108
+ };
109
+ `;
110
+ const asset = b64("console.log('sanoma');\n");
111
+ return {
112
+ version: revision,
113
+ mainModule: "index.js",
114
+ modules: [{ name: "index.js", type: "esm", content: b64(main) }],
115
+ assets: [
116
+ {
117
+ path: "/assets/app.js",
118
+ content: asset,
119
+ hash: createHash("sha256").update(`${asset}js`).digest("hex").slice(0, 32),
120
+ size: Buffer.byteLength(asset, "base64"),
121
+ },
122
+ ],
123
+ compatibilityDate: "2026-10-01",
124
+ compatibilityFlags: ["nodejs_compat"],
125
+ prerendered: [],
126
+ };
127
+ }
128
+ /** An answer of the runtime's app-bundle route, marked as the route's. */
129
+ const bundleAnswer = (status, body) => Response.json(body, { status, headers: { [APP_BUNDLE_HEADER]: "1" } });
63
130
  /**
64
131
  * One Durable Object of a Worker, in this process: the `ctx` `worker/instance.js` runs over
65
132
  * (`ctx.container` running `start`'s runtime, `ctx.storage` in memory, one alarm on a timer),
@@ -67,7 +134,7 @@ const stubContainer = async () => {
67
134
  * `images` are the ones the Worker's last upload named, by name; `source` names the image a
68
135
  * pinned one was copied from, or nothing when no application runs behind the object.
69
136
  */
70
- function fakeObject(start, images, source, started) {
137
+ function fakeObject(start, bundle, images, source, started) {
71
138
  const storage = new Map();
72
139
  let running;
73
140
  let alarm;
@@ -82,6 +149,21 @@ function fakeObject(start, images, source, started) {
82
149
  running = undefined;
83
150
  await was?.runtime.then((runtime) => runtime.stop(), () => undefined);
84
151
  };
152
+ /** The runtime's own `/__sanoma/app-bundle`, as the worker port answers it (`appBundleRoute`). */
153
+ const bundleRoute = (request, mine) => {
154
+ const token = mine.token;
155
+ if (!token || request.headers.get("authorization") !== `Bearer ${token}`) {
156
+ return bundleAnswer(401, { error: "the token is not this instance's SANOMA_WAKEUP_TOKEN" });
157
+ }
158
+ const revision = new URL(request.url).searchParams.get("revision") ?? "";
159
+ if (!revision)
160
+ return bundleAnswer(400, { error: "revision must be a commit id (?revision=<sha>)" });
161
+ if (!mine.built.has(revision)) {
162
+ mine.built.add(revision);
163
+ return bundleAnswer(202, { building: revision });
164
+ }
165
+ return bundleAnswer(200, bundle(revision));
166
+ };
85
167
  /** Runs `work` as a request to the object: the container's inactivity counts from the last one's end. */
86
168
  const active = async (work) => {
87
169
  open++;
@@ -152,7 +234,14 @@ function fakeObject(start, images, source, started) {
152
234
  started.push(made);
153
235
  return made;
154
236
  });
155
- const mine = { runtime, image, labels: { ...labels }, port: Number(env.PORT) };
237
+ const mine = {
238
+ runtime,
239
+ image,
240
+ labels: { ...labels },
241
+ port: Number(env.PORT),
242
+ ...(env.SANOMA_WAKEUP_TOKEN && { token: env.SANOMA_WAKEUP_TOKEN }),
243
+ built: new Set(),
244
+ };
156
245
  running = mine;
157
246
  // A runtime that does not start leaves no container, as a failed start does.
158
247
  runtime.catch(() => {
@@ -163,6 +252,21 @@ function fakeObject(start, images, source, started) {
163
252
  inspect: async () => (running ? { image: running.image, labels: { ...running.labels } } : null),
164
253
  // A runtime that does not start is a container that ended; one that runs never does here.
165
254
  monitor: () => (running ? running.runtime.then(() => new Promise(() => { })) : Promise.resolve()),
255
+ // The worker's own stop: it drains and exits, and the container with it.
256
+ signal(signal) {
257
+ if (signal !== 15)
258
+ throw new TypeError(`the fake sends SIGTERM (15) alone, not ${signal}`);
259
+ const was = running;
260
+ if (!was)
261
+ return;
262
+ void was.runtime
263
+ .then((runtime) => runtime.stop())
264
+ .catch(() => undefined)
265
+ .finally(() => {
266
+ if (running === was)
267
+ running = undefined;
268
+ });
269
+ },
166
270
  destroy,
167
271
  getTcpPort: (port) => ({
168
272
  async fetch(input, init) {
@@ -170,9 +274,12 @@ function fakeObject(start, images, source, started) {
170
274
  throw new Error("the container is not running");
171
275
  if (port !== running.port)
172
276
  throw new Error(`nothing listens on port ${port} in the container`);
173
- const runtime = await running.runtime;
277
+ const mine = running;
278
+ const runtime = await mine.runtime;
174
279
  const request = new Request(input, init);
175
280
  const { pathname, search } = new URL(request.url);
281
+ if (pathname === APP_BUNDLE_PATH)
282
+ return bundleRoute(request, mine);
176
283
  const headers = new Headers(request.headers);
177
284
  for (const name of HOP)
178
285
  headers.delete(name);
@@ -236,52 +343,109 @@ function fakeObject(start, images, source, started) {
236
343
  }
237
344
  /**
238
345
  * An in-memory Cloudflare account for tests, as the API answers: Workers in `state.scripts`,
239
- * container applications in `state.applications`, the images copied into its registry in
240
- * `state.images` (nothing is pulled), their custom domains in `state.domains` (every zone held
241
- * unless `zones` names them), each zone's DNS records in `state.dns`, by the zone's name (a batch
242
- * is all or nothing: an identical record or one beside a CNAME fails it whole), and the account's
243
- * workers.dev URLs served in
244
- * this process, one server on a port of this machine per Worker name (a name with no Worker
245
- * answers workers.dev's 404). Each Worker runs the package's own object code
246
- * (`worker/instance.js`) over a `ctx` in this process: its container runs `start`'s runtime
247
- * (the image and the revision label it was started at, the Worker's text bindings as its
248
- * environment), its storage is in memory, and its alarm rings on a timer, again after a throw.
249
- * An upload restarts the object over the new bindings and images, its container, storage and
250
- * alarm kept: a new image starts the container again at the next request, as the object finds its
251
- * label changed. `copyImage` pins its copy by a made-up digest, and `prepareImage` answers ready
252
- * for an image pinned in the account's registry. An assets upload session asks for the hashes
253
- * `state.assets` lacks, in one bucket, and an upload that redeems its completion jwt keeps each
254
- * path's hash on the Worker (`state.scripts[…].assets`); the Worker's requests still go to its
255
- * object, as the fake runs no sites bundle. `stop(name)` stops a company's
256
- * container as ten quiet minutes would, `fire()` rings every alarm now, `wakes()` lists the
257
- * `/wake`s the containers answered, `close()` (and `reset()`) stops everything.
346
+ * container applications in `state.applications`, Hyperdrive configs in `state.hyperdrives`
347
+ * (their passwords left out), the images copied into its registry in `state.images` (nothing is
348
+ * pulled), their custom domains in `state.domains` (every zone held unless `zones` names them),
349
+ * each zone's DNS records in `state.dns`, by the zone's name (a batch is all or nothing: an
350
+ * identical record or one beside a CNAME fails it whole), and the account's workers.dev URLs
351
+ * served in this process, one server on a port of this machine per Worker name (a name with no
352
+ * Worker answers workers.dev's 404).
353
+ *
354
+ * Each Worker runs the package's own code (`worker/instance.js`): its fetch (`serve`) over the
355
+ * upload's `app/index.js`, written to a folder and imported (the placeholder of a create's first
356
+ * uploads, then the bundle's main module, `stubAppBundle` unless `bundle` says), its `DATABASE`
357
+ * the config's direct URL, its `ASSETS` the uploaded contents; and its object over a `ctx` in this
358
+ * process: its container runs `start`'s runtime (the image and the revision label it was started
359
+ * at, the Worker's text bindings as its environment), its port's `/__sanoma/app-bundle` answers
360
+ * `bundle`'s as the runtime's route does, SIGTERM stops the runtime, its storage is in memory, and
361
+ * its alarm rings on a timer, again after a throw. An upload restarts the object over the new
362
+ * bindings and images, its container, storage and alarm kept: the object finds the labels changed
363
+ * at the next start, and drains and replaces it. `copyImage` pins its copy by a made-up digest,
364
+ * and `prepareImage` answers ready for an image pinned in the account's registry. An assets upload
365
+ * session asks for the hashes `state.assets` lacks, in one bucket, and an upload that redeems its
366
+ * completion jwt keeps each path's hash on the Worker (`state.scripts[…].assets`). `stop(name)`
367
+ * stops a company's container as ten quiet minutes would, `fire()` rings every alarm now,
368
+ * `wakes()` lists the `/wake`s the containers answered, `close()` (and `reset()`) stops everything.
258
369
  */
259
370
  export function fakeCloudflare(options = {}) {
260
371
  const start = options.start ?? stubContainer;
372
+ const bundle = options.bundle ?? stubAppBundle;
261
373
  const servers = new Map();
262
374
  const objects = new Map();
263
- /** Each Worker's env: its bindings' text, the secrets' with it, which the state does not keep. */
264
- const envs = new Map();
375
+ /**
376
+ * Each Worker as its last upload runs it: its env (its bindings' text, the secrets' with it,
377
+ * which the state does not keep, its database, assets and object), and its app, the upload's
378
+ * `app/index.js` imported from a folder of its own on the first request.
379
+ */
380
+ const workers = new Map();
381
+ /** Where each upload's modules are written, to be imported as files. */
382
+ let uploads;
383
+ /** Each Hyperdrive config's database URL, which the state does not keep: its password is in it. */
384
+ const databases = new Map();
385
+ /** The assets' contents, by hash, which the state does not keep. */
386
+ const contents = new Map();
265
387
  const started = [];
266
388
  /** The open assets upload sessions, by jwt: the Worker's name, its manifest and the hashes still to upload. */
267
389
  const sessions = new Map();
268
390
  /** The completion jwts an upload may redeem: the Worker's name and its manifest, each path's hash. */
269
391
  const completed = new Map();
270
- /** The request to the Worker named `script`, as workers.dev passes it on. */
271
- const dispatch = async (state, script, request) => {
272
- const env = envs.get(script);
273
- if (!env || !state.scripts[script]?.subdomain)
392
+ /** The asset of the Worker named `script` at the request's path, if its last upload has one. */
393
+ const assetOf = (state, script, request) => {
394
+ const hash = state.scripts[script]?.assets?.[new URL(request.url).pathname];
395
+ const found = hash === undefined ? undefined : contents.get(hash);
396
+ return found && new Response(found.content, { headers: { "content-type": found.contentType } });
397
+ };
398
+ /**
399
+ * The request to the Worker named `script`, as workers.dev passes it on: a path under
400
+ * `/assets/` its assets have straight from them (`run_worker_first`), anything else to the
401
+ * Worker script's fetch (`serve`, the package's own) over its last upload, with Cloudflare's
402
+ * `cf-connecting-ip` set from the visitor's address.
403
+ */
404
+ const dispatch = async (state, script, request, visitor = "127.0.0.1") => {
405
+ const worker = workers.get(script);
406
+ if (!worker || !state.scripts[script]?.subdomain)
274
407
  return nothingHere();
275
- return worker.fetch(request, env);
408
+ if (new URL(request.url).pathname.startsWith("/assets/")) {
409
+ const asset = assetOf(state, script, request);
410
+ if (asset)
411
+ return asset;
412
+ }
413
+ const headers = new Headers(request.headers);
414
+ headers.set("cf-connecting-ip", visitor);
415
+ const ctx = {
416
+ waitUntil: (promise) => void promise.catch((err) => console.warn(`cloudflare fake: a waitUntil threw: ${errorMessage(err)}`)),
417
+ };
418
+ return serve(new Request(request, { headers }), worker.env, ctx, worker.loadApp);
419
+ };
420
+ /** Writes an upload's modules to a folder of their own: the app is imported from there. */
421
+ const written = (modules) => {
422
+ uploads ??= mkdtempSync(join(tmpdir(), "sanoma-cloudflare-fake-"));
423
+ const dir = mkdtempSync(join(uploads, "upload-"));
424
+ for (const { name, content } of modules.filter((m) => m.name.startsWith("app/"))) {
425
+ const file = join(dir, name);
426
+ mkdirSync(dirname(file), { recursive: true });
427
+ writeFileSync(file, content);
428
+ }
429
+ let app;
430
+ return () => (app ??= import(__rewriteRelativeImportExtension(pathToFileURL(join(dir, "app", "index.js")).href)));
431
+ };
432
+ /** A Hyperdrive config written: its origin in the state without the password, its URL beside it. */
433
+ const keep = (state, id, name, origin) => {
434
+ const { password: _password, ...shown } = origin;
435
+ (state.hyperdrives ??= {})[id] = { name, origin: shown };
436
+ databases.set(id, connectionString(origin));
276
437
  };
277
438
  const scriptAt = (url) => [...servers].find(([, s]) => s.url === new URL(url).origin)?.[0];
278
439
  /** The Worker name's URL: a server of its own, made at the first ask, which outlives the Worker as workers.dev's name does. */
279
- const serve = async (state, script) => {
440
+ const listenAt = async (state, script) => {
280
441
  const found = servers.get(script);
281
442
  if (found)
282
443
  return found.url;
283
444
  const server = createServer((req, res) => {
284
- void dispatch(state, script, new NodeRequest({ req, res })).then((answer) => sendNodeResponse(res, answer), (err) => {
445
+ // Made from its parts: undici's Request cannot copy the server's own request.
446
+ const { method, url, headers, body } = new NodeRequest({ req, res });
447
+ const request = new Request(url, { method, headers, body, duplex: "half" });
448
+ void dispatch(state, script, request, req.socket.remoteAddress).then((answer) => sendNodeResponse(res, answer), (err) => {
285
449
  if (res.headersSent)
286
450
  return void res.destroy();
287
451
  res.writeHead(500, { "content-type": "text/plain" }).end(`${errorMessage(err)}\n`);
@@ -308,13 +472,15 @@ export function fakeCloudflare(options = {}) {
308
472
  };
309
473
  const recordsOf = (zoneId) => state.dns[zoneNamed(zoneId)] ?? [];
310
474
  return {
311
- workerUrl: (script) => serve(state, script),
475
+ workerUrl: (script) => listenAt(state, script),
312
476
  async bindings(script) {
313
477
  return structuredClone(state.scripts[script]?.bindings);
314
478
  },
315
- async upload(script, { modules, main_module, bindings, containers, migrations, assets }) {
479
+ async upload(script, { modules, main_module, bindings, containers, migrations, assets, compatibility_flags }) {
316
480
  if (!modules.some((m) => m.name === main_module))
317
481
  throw fail(400, 10021, `no module named ${main_module}`);
482
+ if (!modules.some((m) => m.name === "app/index.js"))
483
+ throw fail(400, 10021, "no module named app/index.js");
318
484
  const redeemed = assets && completed.get(assets.jwt);
319
485
  if (assets && redeemed?.script !== script) {
320
486
  throw fail(400, 10203, "the assets' completion jwt is not one this Worker's upload session gave");
@@ -343,10 +509,22 @@ export function fakeCloudflare(options = {}) {
343
509
  }),
344
510
  };
345
511
  }
346
- else if (binding.type !== "assets")
512
+ else if (binding.type === "hyperdrive") {
513
+ const url = databases.get(binding.id);
514
+ if (url === undefined)
515
+ throw fail(400, 10021, `no Hyperdrive config ${binding.id}`);
516
+ // The direct URL: Hyperdrive's pooled one is Cloudflare's own.
517
+ env[binding.name] = { connectionString: url };
518
+ }
519
+ else if (binding.type === "assets") {
520
+ env[binding.name] = {
521
+ fetch: async (request) => assetOf(state, script, request) ?? new Response(null, { status: 404 }),
522
+ };
523
+ }
524
+ else
347
525
  env[binding.name] = binding.text;
348
526
  }
349
- envs.set(script, env);
527
+ workers.set(script, { env, loadApp: written(modules) });
350
528
  // The namespace the first upload made, kept across uploads as Cloudflare keeps it.
351
529
  const namespace_id = state.scripts[script]?.bindings.find((b) => b.namespace_id)?.namespace_id ?? nextId();
352
530
  state.scripts[script] = {
@@ -354,17 +532,18 @@ export function fakeCloudflare(options = {}) {
354
532
  ? { type, name }
355
533
  : type === "durable_object_namespace"
356
534
  ? { type, name, namespace_id }
357
- : { type, name, ...("text" in rest && { text: rest.text }) }),
535
+ : { type, name, ...("text" in rest && { text: rest.text }), ...("id" in rest && { id: rest.id }) }),
358
536
  subdomain: state.scripts[script]?.subdomain ?? false,
359
537
  images: { ...runs.images },
360
538
  main_module,
361
539
  modules: modules.map((m) => m.name),
540
+ ...(compatibility_flags.length > 0 && { compatibility_flags: [...compatibility_flags] }),
362
541
  ...(redeemed && { assets: { ...redeemed.manifest } }),
363
542
  };
364
543
  if (assets)
365
544
  completed.delete(assets.jwt);
366
545
  const object = objects.get(script) ??
367
- fakeObject(start, () => state.scripts[script]?.images ?? {}, (image) => appOf(script) && (state.images[image] ?? image), started);
546
+ fakeObject(start, bundle, () => state.scripts[script]?.images ?? {}, (image) => appOf(script) && (state.images[image] ?? image), started);
368
547
  objects.set(script, object);
369
548
  object.restart(new Instance(object.ctx, env));
370
549
  },
@@ -374,9 +553,30 @@ export function fakeCloudflare(options = {}) {
374
553
  throw fail(404, 10007, `workers/scripts/${script}: This Worker does not exist on your account.`);
375
554
  found.subdomain = true;
376
555
  },
556
+ async hyperdrive(name) {
557
+ const id = Object.entries(state.hyperdrives ?? {}).find(([, config]) => config.name === name)?.[0];
558
+ return id === undefined ? undefined : { id };
559
+ },
560
+ async createHyperdrive(name, origin) {
561
+ if (Object.values((state.hyperdrives ??= {})).some((config) => config.name === name)) {
562
+ throw fail(409, 2017, `hyperdrive/configs: a configuration named ${name} exists`);
563
+ }
564
+ const id = nextId();
565
+ keep(state, id, name, origin);
566
+ return { id };
567
+ },
568
+ async updateHyperdrive(id, name, origin) {
569
+ if (!state.hyperdrives?.[id])
570
+ throw fail(404, 2005, `hyperdrive/configs/${id}: not found`);
571
+ keep(state, id, name, origin);
572
+ },
573
+ async deleteHyperdrive(id) {
574
+ delete state.hyperdrives?.[id];
575
+ databases.delete(id);
576
+ },
377
577
  async deleteScript(script) {
378
578
  delete state.scripts[script];
379
- envs.delete(script);
579
+ workers.delete(script);
380
580
  await objects.get(script)?.remove();
381
581
  objects.delete(script);
382
582
  },
@@ -496,10 +696,11 @@ export function fakeCloudflare(options = {}) {
496
696
  const session = sessions.get(jwt);
497
697
  if (!session)
498
698
  throw fail(401, 10000, "workers/assets/upload: the jwt is no open upload session's");
499
- for (const { hash, contentType } of files) {
699
+ for (const { hash, base64, contentType } of files) {
500
700
  if (!session.missing.delete(hash))
501
701
  throw fail(400, 10203, `workers/assets/upload: ${hash} is not asked for`);
502
702
  (state.assets ??= {})[hash] = contentType;
703
+ contents.set(hash, { content: Buffer.from(base64, "base64"), contentType });
503
704
  }
504
705
  if (session.missing.size)
505
706
  return undefined;
@@ -521,6 +722,7 @@ export function fakeCloudflare(options = {}) {
521
722
  seq: 0,
522
723
  scripts: {},
523
724
  assets: {},
725
+ hyperdrives: {},
524
726
  applications: {},
525
727
  images: {},
526
728
  zones: {},
@@ -539,7 +741,12 @@ export function fakeCloudflare(options = {}) {
539
741
  for (const object of objects.values())
540
742
  await object.remove();
541
743
  objects.clear();
542
- envs.clear();
744
+ workers.clear();
745
+ databases.clear();
746
+ contents.clear();
747
+ if (uploads)
748
+ rmSync(uploads, { recursive: true, force: true });
749
+ uploads = undefined;
543
750
  sessions.clear();
544
751
  completed.clear();
545
752
  for (const { server } of servers.values()) {