@sanoma-ai/connector-cloudflare 0.6.1 → 0.6.3
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 +35 -19
- package/dist/deploy.d.ts +66 -0
- package/dist/deploy.js +143 -0
- package/dist/deploy.js.map +1 -0
- package/dist/driver.d.ts +7 -2
- package/dist/driver.js +22 -3
- package/dist/driver.js.map +1 -1
- package/dist/fake.d.ts +46 -20
- package/dist/fake.js +248 -41
- package/dist/fake.js.map +1 -1
- package/dist/ops.d.ts +58 -15
- package/dist/ops.js +224 -134
- package/dist/ops.js.map +1 -1
- package/dist/records.d.ts +2 -2
- package/dist/steps.d.ts +2 -1
- package/dist/steps.js +7 -2
- package/dist/steps.js.map +1 -1
- package/features/cloudflare.feature +6 -2
- package/package.json +4 -3
- package/worker/index.js +10 -6
- package/worker/instance.d.ts +25 -13
- package/worker/instance.js +128 -78
- package/worker/no-app.js +10 -0
- package/dist/sites.d.ts +0 -58
- package/dist/sites.js +0 -153
- package/dist/sites.js.map +0 -1
- package/dist/worker.d.ts +0 -19
- package/dist/worker.js +0 -29
- package/dist/worker.js.map +0 -1
- package/worker/sites.js +0 -17
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`,
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* workers.dev URLs
|
|
68
|
-
* this process, one server on a port of this machine per Worker name (a name with no
|
|
69
|
-
* answers workers.dev's 404).
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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 = {
|
|
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
|
|
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`,
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* workers.dev URLs
|
|
244
|
-
* this process, one server on a port of this machine per Worker name (a name with no
|
|
245
|
-
* answers workers.dev's 404).
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* `
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
-
/**
|
|
264
|
-
|
|
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
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()) {
|