@sproutboat/wire 0.2.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.
@@ -0,0 +1,715 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ createBroker,
8
+ cronMatches,
9
+ encodeFrame,
10
+ encodeV1,
11
+ listen,
12
+ type Broker,
13
+ type Frame,
14
+ type FetchLike,
15
+ } from "./broker";
16
+ import { createHash } from "node:crypto";
17
+ import { walkAssets, type AssetManifest } from "@sproutboat/assets";
18
+ import { jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
19
+
20
+ /** Reply frames are JSON; these narrow one field down to what an assertion reads. */
21
+ const obj = (value: JsonValue | undefined): JsonObject => jsonObject(value ?? null) ?? {};
22
+ const arr = (value: JsonValue | undefined): JsonValue[] => (Array.isArray(value) ? value : []);
23
+
24
+ const brokers: Broker[] = [];
25
+ afterEach(() => {
26
+ for (const b of brokers) b.close();
27
+ brokers.length = 0;
28
+ });
29
+
30
+ function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
31
+ const b = createBroker(opts);
32
+ brokers.push(b);
33
+ return b;
34
+ }
35
+
36
+ test("ping round-trips", async () => {
37
+ const b = make();
38
+ expect(await b.dispatch({ op: "ping", msg: "hi" })).toMatchObject({ ok: true, op: "pong", echo: "hi" });
39
+ });
40
+
41
+ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
42
+ const b = make({ bindings: { kv: ["CACHE"] } });
43
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: false, value: null });
44
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
45
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k2", value: "v2" });
46
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: true, value: "v" });
47
+ expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).toEqual({ ok: true, keys: ["k", "k2"] });
48
+ await b.dispatch({ op: "kv.delete", ns: "CACHE", key: "k" });
49
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
50
+ });
51
+
52
+ test("an unbound KV namespace is rejected", async () => {
53
+ const b = make({ bindings: { kv: ["CACHE"] } });
54
+ await expect(b.dispatch({ op: "kv.put", ns: "OTHER", key: "k", value: "v" })).rejects.toThrow("not bound");
55
+ });
56
+
57
+ test("secrets resolve only when both bound and present", async () => {
58
+ const b = make({ bindings: { secrets: ["API_KEY", "MISSING"] }, secrets: { API_KEY: "s3cr3t" } });
59
+ expect(await b.dispatch({ op: "secret.get", name: "API_KEY" })).toEqual({ ok: true, value: "s3cr3t" });
60
+ await expect(b.dispatch({ op: "secret.get", name: "MISSING" })).rejects.toThrow("no value");
61
+ await expect(b.dispatch({ op: "secret.get", name: "UNBOUND" })).rejects.toThrow("not bound");
62
+ });
63
+
64
+ test("fetch is gated by the exact-host allowlist", async () => {
65
+ const calls: string[] = [];
66
+ const fetchImpl: FetchLike = async (url) => {
67
+ calls.push(String(url));
68
+ return new Response("body", { status: 200, headers: { "x-mark": "1" } });
69
+ };
70
+ const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
71
+
72
+ await expect(b.dispatch({ op: "fetch", url: "https://evil.example.com/x" })).rejects.toThrow("allowlist");
73
+ expect(calls).toEqual([]);
74
+
75
+ const res = await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
76
+ expect(res).toMatchObject({ ok: true, status: 200, body: "body" });
77
+ expect(calls).toEqual(["https://api.example.com/x"]);
78
+ });
79
+
80
+ test("D1: query / run / batch on a bound database, isolated per name", async () => {
81
+ const b = make({ bindings: { d1: ["DB", "OTHER"] } });
82
+ await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" });
83
+ const ins = await b.dispatch({ op: "d1.query", db: "DB", sql: "INSERT INTO t (name) VALUES (?)", params: ["ada"] });
84
+ expect(ins).toMatchObject({ ok: true, results: [] });
85
+ expect(obj(ins.meta).changes).toBe(1);
86
+ expect(obj(ins.meta).last_row_id).toBe(1);
87
+
88
+ const sel = await b.dispatch({ op: "d1.query", db: "DB", sql: "SELECT * FROM t", params: [] });
89
+ expect(sel.results).toEqual([{ id: 1, name: "ada" }]);
90
+
91
+ const batch = await b.dispatch({
92
+ op: "d1.batch",
93
+ db: "DB",
94
+ statements: [
95
+ { sql: "INSERT INTO t (name) VALUES (?)", params: ["grace"] },
96
+ { sql: "SELECT count(*) AS n FROM t", params: [] },
97
+ ],
98
+ });
99
+ expect(obj(arr(batch.results)[1]).results).toEqual([{ n: 2 }]);
100
+
101
+ // a different bound name is a different database
102
+ await expect(b.dispatch({ op: "d1.query", db: "OTHER", sql: "SELECT * FROM t", params: [] })).rejects.toThrow(
103
+ "no such table",
104
+ );
105
+ await expect(b.dispatch({ op: "d1.query", db: "NOPE", sql: "SELECT 1", params: [] })).rejects.toThrow("not bound");
106
+ });
107
+
108
+ test("R2: put / get / head / list / delete on a bound bucket", async () => {
109
+ const b = make({ bindings: { r2: ["ASSETS"] } });
110
+ const put = await b.dispatch({
111
+ op: "r2.put",
112
+ bucket: "ASSETS",
113
+ key: "a/1.txt",
114
+ body: "hello",
115
+ customMetadata: { k: "v" },
116
+ });
117
+ expect(put).toMatchObject({ ok: true });
118
+ expect(obj(put.object).size).toBe(5);
119
+
120
+ const got = await b.dispatch({ op: "r2.get", bucket: "ASSETS", key: "a/1.txt" });
121
+ expect(got).toMatchObject({ ok: true, found: true, body: "hello" });
122
+ expect(obj(got.object).customMetadata).toEqual({ k: "v" });
123
+
124
+ const head = await b.dispatch({ op: "r2.head", bucket: "ASSETS", key: "a/1.txt" });
125
+ expect(head).toMatchObject({ found: true });
126
+ expect(head.body).toBeUndefined();
127
+
128
+ await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: "a/2.txt", body: "two" });
129
+ await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: "b/3.txt", body: "three" });
130
+ const list = await b.dispatch({ op: "r2.list", bucket: "ASSETS", prefix: "a/" });
131
+ expect(arr(list.objects).map((o) => obj(o).key)).toEqual(["a/1.txt", "a/2.txt"]);
132
+
133
+ await b.dispatch({ op: "r2.delete", bucket: "ASSETS", key: "a/1.txt" });
134
+ expect(await b.dispatch({ op: "r2.get", bucket: "ASSETS", key: "a/1.txt" })).toMatchObject({ found: false });
135
+ await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
136
+ });
137
+
138
+ test("R2: list paginates with a cursor", async () => {
139
+ const b = make({ bindings: { r2: ["ASSETS"] } });
140
+ for (let i = 0; i < 5; i++) await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: `k${i}`, body: "x" });
141
+ const p1 = await b.dispatch({ op: "r2.list", bucket: "ASSETS", limit: 2 });
142
+ expect(arr(p1.objects).map((o) => obj(o).key)).toEqual(["k0", "k1"]);
143
+ expect(p1.truncated).toBe(true);
144
+ const p2 = await b.dispatch({ op: "r2.list", bucket: "ASSETS", limit: 2, cursor: p1.cursor });
145
+ expect(arr(p2.objects).map((o) => obj(o).key)).toEqual(["k2", "k3"]);
146
+ });
147
+
148
+ test("Durable Object storage: get / put / delete / list / deleteAll scoped to (class, id)", async () => {
149
+ const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
150
+ expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({ found: false });
151
+ await b.dispatch({ op: "do.storage.put", cls: "Counter", id: "a", key: "n", value: "5" });
152
+ await b.dispatch({ op: "do.storage.put", cls: "Counter", id: "a", key: "m", value: "9" });
153
+ await b.dispatch({ op: "do.storage.put", cls: "Counter", id: "b", key: "n", value: "1" });
154
+ expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({
155
+ found: true,
156
+ value: "5",
157
+ });
158
+ expect((await b.dispatch({ op: "do.storage.list", cls: "Counter", id: "a", prefix: "" })).entries).toEqual([
159
+ ["m", "9"],
160
+ ["n", "5"],
161
+ ]);
162
+ expect(await b.dispatch({ op: "do.storage.delete", cls: "Counter", id: "a", key: "m" })).toEqual({
163
+ ok: true,
164
+ deleted: true,
165
+ });
166
+ await b.dispatch({ op: "do.storage.delete_all", cls: "Counter", id: "a" });
167
+ expect((await b.dispatch({ op: "do.storage.list", cls: "Counter", id: "a", prefix: "" })).entries).toEqual([]);
168
+ expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "b", key: "n" })).toMatchObject({
169
+ found: true,
170
+ value: "1",
171
+ });
172
+ await expect(b.dispatch({ op: "do.storage.get", cls: "Nope", id: "a", key: "n" })).rejects.toThrow("not bound");
173
+ });
174
+
175
+ test("Analytics Engine: write is bound-gated; query reads back count + recent rows", async () => {
176
+ const b = make({ bindings: { analytics: ["METRICS"] } });
177
+ expect(await b.dispatch({ op: "ae.write", dataset: "METRICS", blobs: ["GET", "/a"], doubles: [1] })).toEqual({
178
+ ok: true,
179
+ });
180
+ await b.dispatch({ op: "ae.write", dataset: "METRICS", blobs: ["POST", "/b"], doubles: [1] });
181
+ await expect(b.dispatch({ op: "ae.write", dataset: "OTHER" })).rejects.toThrow("not bound");
182
+
183
+ const q = await b.dispatch({ op: "ae.query", dataset: "METRICS", limit: 5 });
184
+ expect(q.count).toBe(2);
185
+ const rows = arr(q.rows);
186
+ expect(obj(rows[0]).blobs).toEqual(["POST", "/b"]); // newest first
187
+ await expect(b.dispatch({ op: "ae.query", dataset: "OTHER" })).rejects.toThrow("not bound");
188
+ });
189
+
190
+ test("Queues: send enqueues; the consumer delivers a batch and acked messages are removed", async () => {
191
+ const delivered: JsonObject[] = [];
192
+ const fetchImpl: FetchLike = async (_url, init) => {
193
+ const body = obj(parseJsonValue(String(init?.body)));
194
+ delivered.push(body);
195
+ // ack all but the first
196
+ return new Response(
197
+ JSON.stringify({
198
+ ack: arr(body.messages)
199
+ .slice(1)
200
+ .map((m) => obj(m).id),
201
+ retry: [],
202
+ }),
203
+ { status: 200 },
204
+ );
205
+ };
206
+
207
+ const b = make({ bindings: { queues: ["JOBS"] }, sproutUrl: "http://127.0.0.1:1/", fetchImpl });
208
+ await b.dispatch({ op: "queue.send", queue: "JOBS", body: JSON.stringify({ n: 1 }) });
209
+ await b.dispatch({ op: "queue.send", queue: "JOBS", body: JSON.stringify({ n: 2 }) });
210
+ await b.dispatch({ op: "queue.send", queue: "JOBS", body: JSON.stringify({ n: 3 }) });
211
+
212
+ // the consumer runs on a 500ms interval
213
+ await Bun.sleep(900);
214
+ expect(delivered.length).toBeGreaterThan(0);
215
+ const first = obj(delivered[0]);
216
+ expect(first.queue).toBe("JOBS");
217
+ expect(arr(first.messages).length).toBe(3);
218
+
219
+ await expect(b.dispatch({ op: "queue.send", queue: "NOPE", body: "x" })).rejects.toThrow("not bound");
220
+ });
221
+
222
+ test("assets.get: serves a bound file, falls back per not_found_handling", async () => {
223
+ const dir = mkdtempSync(join(tmpdir(), "sb-assets-"));
224
+ const assetsDir = join(dir, "assets");
225
+ mkdirSync(join(assetsDir, "sub"), { recursive: true });
226
+ writeFileSync(join(assetsDir, "index.html"), "<h1>home</h1>");
227
+ writeFileSync(join(assetsDir, "sub", "page.css"), "body{}");
228
+ const write = (notFound: AssetManifest["notFound"]): void => {
229
+ const m: AssetManifest = { notFound, runSproutFirst: false, files: walkAssets(assetsDir) };
230
+ writeFileSync(join(dir, "assets.json"), JSON.stringify(m));
231
+ };
232
+
233
+ write("none");
234
+ const b1 = make({ bindings: { assets: "ASSETS" }, assetsDir });
235
+ expect(await b1.dispatch({ op: "assets.get", path: "/index.html" })).toMatchObject({
236
+ found: true,
237
+ status: 200,
238
+ type: "text/html; charset=utf-8",
239
+ body: "<h1>home</h1>",
240
+ });
241
+ expect(await b1.dispatch({ op: "assets.get", path: "/" })).toMatchObject({ found: true, body: "<h1>home</h1>" });
242
+ expect(await b1.dispatch({ op: "assets.get", path: "/sub/page.css" })).toMatchObject({
243
+ type: "text/css; charset=utf-8",
244
+ });
245
+ expect(await b1.dispatch({ op: "assets.get", path: "/missing" })).toMatchObject({ found: false, status: 404 });
246
+
247
+ write("single-page-application");
248
+ const b2 = make({ bindings: { assets: "ASSETS" }, assetsDir });
249
+ expect(await b2.dispatch({ op: "assets.get", path: "/client/route" })).toMatchObject({
250
+ found: true,
251
+ status: 200,
252
+ body: "<h1>home</h1>",
253
+ });
254
+
255
+ await expect(
256
+ make({ bindings: { assets: "" }, assetsDir }).dispatch({ op: "assets.get", path: "/x" }),
257
+ ).rejects.toThrow("not bound");
258
+ rmSync(dir, { recursive: true, force: true });
259
+ });
260
+
261
+ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
262
+ const at = (iso: string) => new Date(iso);
263
+ expect(cronMatches("* * * * *", at("2026-08-31T04:07:00Z"))).toBe(true);
264
+ expect(cronMatches("0 3 * * *", at("2026-08-31T03:00:00Z"))).toBe(true);
265
+ expect(cronMatches("0 3 * * *", at("2026-08-31T04:00:00Z"))).toBe(false);
266
+ expect(cronMatches("*/15 * * * *", at("2026-08-31T04:15:00Z"))).toBe(true);
267
+ expect(cronMatches("*/15 * * * *", at("2026-08-31T04:16:00Z"))).toBe(false);
268
+ expect(cronMatches("0 9-17 * * 1", at("2026-08-31T12:00:00Z"))).toBe(true); // 2026-08-31 is a Monday
269
+ expect(cronMatches("0 9-17 * * 1", at("2026-08-30T12:00:00Z"))).toBe(false); // Sunday
270
+ expect(cronMatches("0 0 1,15 * *", at("2026-08-15T00:00:00Z"))).toBe(true);
271
+ });
272
+
273
+ // #74 — a binding wired to an account-level resource stores in its own file
274
+ // under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
275
+ // broker with a fresh per-deployment db but the same resourceDir).
276
+ test("resource-backed KV persists across brokers and is shared by id", async () => {
277
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-res-"));
278
+ try {
279
+ const resourceDir = join(root, "resources");
280
+ const kvId = "kv_0123456789abcdef01234567";
281
+ mkdirSync(join(root, "dep-a"), { recursive: true });
282
+ mkdirSync(join(root, "dep-b"), { recursive: true });
283
+
284
+ const first = createBroker({
285
+ db: join(root, "dep-a", "state.sqlite"),
286
+ resourceDir,
287
+ bindings: { kv: ["LINKS"], resources: { LINKS: { kind: "kv", id: kvId } } },
288
+ });
289
+ await first.dispatch({ op: "kv.put", ns: "LINKS", key: "a", value: "1" });
290
+ first.close();
291
+ // stored in the per-resource file, not a per-deployment one
292
+ expect(existsSync(join(resourceDir, `${kvId}.sqlite`))).toBe(true);
293
+
294
+ // a different deployment, different binding name, same id → same data
295
+ const second = createBroker({
296
+ db: join(root, "dep-b", "state.sqlite"),
297
+ resourceDir,
298
+ bindings: { kv: ["SHORTENER"], resources: { SHORTENER: { kind: "kv", id: kvId } } },
299
+ });
300
+ expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toEqual({
301
+ ok: true,
302
+ found: true,
303
+ value: "1",
304
+ });
305
+ second.close();
306
+ } finally {
307
+ rmSync(root, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("a bare-string binding still uses the per-broker db, not resourceDir", async () => {
312
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-bare-"));
313
+ try {
314
+ const resourceDir = join(root, "resources");
315
+ mkdirSync(join(root, "dep"), { recursive: true });
316
+ const b = createBroker({ db: join(root, "dep", "state.sqlite"), resourceDir, bindings: { kv: ["CACHE"] } });
317
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
318
+ b.close();
319
+ expect(existsSync(resourceDir)).toBe(false);
320
+ expect(existsSync(join(root, "dep", "state.sqlite"))).toBe(true);
321
+ } finally {
322
+ rmSync(root, { recursive: true, force: true });
323
+ }
324
+ });
325
+
326
+ test("queue and alarm poll indexes migrate existing broker and resource stores", async () => {
327
+ const root = mkdtempSync(join(tmpdir(), "sb-trigger-index-"));
328
+ const state = join(root, "state.sqlite");
329
+ const resourceDir = join(root, "resources");
330
+ const queueId = "queue_0123456789abcdef01234567";
331
+ const oldState = new Database(state, { create: true });
332
+ oldState.exec(
333
+ "CREATE TABLE mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0); " +
334
+ "CREATE TABLE do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
335
+ );
336
+ oldState.close();
337
+ mkdirSync(resourceDir, { recursive: true });
338
+ const oldQueue = new Database(join(resourceDir, `${queueId}.sqlite`), { create: true });
339
+ oldQueue.exec(
340
+ "CREATE TABLE mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
341
+ );
342
+ oldQueue.close();
343
+
344
+ const broker = createBroker({
345
+ db: state,
346
+ resourceDir,
347
+ bindings: {
348
+ queues: ["JOBS"],
349
+ do: [{ binding: "COUNTER", className: "Counter" }],
350
+ resources: { JOBS: { kind: "queue", id: queueId } },
351
+ },
352
+ });
353
+ // Resource-backed stores open on first binding use, just as they do in a
354
+ // deployed worker. Opening the queue upgrades its existing SQLite file.
355
+ await broker.dispatch({ op: "queue.send", queue: "JOBS", body: "first" });
356
+ broker.close();
357
+
358
+ const names = (path: string): string[] => {
359
+ const store = new Database(path);
360
+ const rows = store.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'index'").all();
361
+ store.close();
362
+ return rows.map((row) => row.name);
363
+ };
364
+ expect(names(state)).toEqual(expect.arrayContaining(["mq_due", "do_alarm_due"]));
365
+ expect(names(join(resourceDir, `${queueId}.sqlite`))).toContain("mq_due");
366
+ rmSync(root, { recursive: true, force: true });
367
+ });
368
+
369
+ test("queue and alarm due queries use their poll indexes", () => {
370
+ const root = mkdtempSync(join(tmpdir(), "sb-trigger-plan-"));
371
+ const path = join(root, "state.sqlite");
372
+ const broker = createBroker({
373
+ db: path,
374
+ bindings: { queues: ["JOBS"], do: [{ binding: "COUNTER", className: "Counter" }] },
375
+ });
376
+ broker.close();
377
+ const store = new Database(path);
378
+ const plan = (sql: string): string =>
379
+ store
380
+ .query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`)
381
+ .all()
382
+ .map((row) => row.detail)
383
+ .join("\n");
384
+ expect(
385
+ plan(
386
+ "SELECT id, body, attempts FROM mq WHERE queue = 'JOBS' AND dead = 0 AND visible_at <= 1 ORDER BY visible_at LIMIT 10",
387
+ ),
388
+ ).toContain("mq_due");
389
+ expect(plan("SELECT cls, id, at, attempts FROM do_alarm WHERE at <= 1 ORDER BY at LIMIT 10")).toContain(
390
+ "do_alarm_due",
391
+ );
392
+ store.close();
393
+ rmSync(root, { recursive: true, force: true });
394
+ });
395
+
396
+ test("token line is enforced by handlePayload", async () => {
397
+ const b = make({ token: "t0k" });
398
+ expect(await b.handlePayload(`wrong\n${JSON.stringify({ op: "ping" })}`)).toEqual({
399
+ ok: false,
400
+ error: "unauthorized",
401
+ });
402
+ expect(await b.handlePayload(`t0k\n${JSON.stringify({ op: "ping" })}`)).toMatchObject({ ok: true, op: "pong" });
403
+ });
404
+
405
+ test("listen(): framed request/reply over a real socket, delivered split", async () => {
406
+ const b = make({ bindings: { kv: ["CACHE"] } });
407
+ const server = listen(b, "127.0.0.1", 0);
408
+ let recv = Buffer.alloc(0);
409
+ let resolveReply: (s: string) => void;
410
+ const reply = new Promise<string>((r) => {
411
+ resolveReply = r;
412
+ });
413
+ try {
414
+ const sock = await Bun.connect({
415
+ hostname: "127.0.0.1",
416
+ port: server.port,
417
+ socket: {
418
+ data(_s, chunk) {
419
+ recv = Buffer.concat([recv, chunk]);
420
+ if (recv.length >= 4 && recv.length >= 4 + recv.readUInt32LE(0)) {
421
+ resolveReply(recv.subarray(4, 4 + recv.readUInt32LE(0)).toString("utf8"));
422
+ }
423
+ },
424
+ },
425
+ });
426
+ const frame = encodeFrame({ op: "ping", msg: "x" });
427
+ sock.write(frame.subarray(0, 3)); // header split mid-length
428
+ await Bun.sleep(5);
429
+ sock.write(frame.subarray(3));
430
+ expect(JSON.parse(await reply)).toMatchObject({ ok: true, op: "pong", echo: "x" });
431
+ sock.end();
432
+ } finally {
433
+ server.stop();
434
+ }
435
+ });
436
+
437
+ // --- Durable Object alarms (#125) -------------------------------------------
438
+
439
+ const doBindings = { do: [{ binding: "COUNTER", className: "Counter" }] };
440
+
441
+ test("alarm: set, read back, and delete", async () => {
442
+ const b = make({ bindings: doBindings });
443
+ expect((await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" })).at).toBeNull();
444
+
445
+ await b.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: 1_800_000_000_000 });
446
+ expect((await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" })).at).toBe(1_800_000_000_000);
447
+
448
+ // Workers keeps at most one pending alarm per object: a later set replaces.
449
+ await b.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: 1_900_000_000_000 });
450
+ expect((await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" })).at).toBe(1_900_000_000_000);
451
+
452
+ expect((await b.dispatch({ op: "do.alarm.delete", cls: "Counter", id: "a" })).deleted).toBe(true);
453
+ expect((await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" })).at).toBeNull();
454
+ });
455
+
456
+ test("alarm: one object's alarm is not another's", async () => {
457
+ const b = make({ bindings: doBindings });
458
+ await b.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: 1_800_000_000_000 });
459
+ expect((await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "b" })).at).toBeNull();
460
+ });
461
+
462
+ test("alarm: a class that is not bound is refused", async () => {
463
+ const b = make({ bindings: doBindings });
464
+ await expect(b.dispatch({ op: "do.alarm.set", cls: "Ghost", id: "a", at: 1 })).rejects.toThrow(/not bound/);
465
+ });
466
+
467
+ test("alarm: a due alarm is delivered once, and a self-rescheduling handler survives", async () => {
468
+ const delivered: JsonObject[] = [];
469
+ let b: Broker | undefined;
470
+ const fetchImpl: FetchLike = async (_url, init) => {
471
+ const body = obj(parseJsonValue(String(init?.body)));
472
+ delivered.push(body);
473
+ // What a self-rescheduling alarm() does: set the next one *during*
474
+ // delivery. Deleting the claimed row after this would erase it.
475
+ await b!.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: Date.now() + 60_000 });
476
+ return new Response("", { status: 204 });
477
+ };
478
+
479
+ b = make({ bindings: doBindings, sproutUrl: "http://127.0.0.1:1/", fetchImpl });
480
+ await b.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: Date.now() - 1 });
481
+
482
+ await Bun.sleep(900);
483
+ expect(delivered.length).toBe(1);
484
+ expect(obj(delivered[0]).cls).toBe("Counter");
485
+ expect(obj(delivered[0]).id).toBe("a");
486
+
487
+ // the alarm the handler scheduled is still pending, not clobbered by the claim
488
+ const pending = await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" });
489
+ expect(pending.at).not.toBeNull();
490
+ expect(Number(pending.at)).toBeGreaterThan(Date.now());
491
+ });
492
+
493
+ test("alarm: a failed delivery is retried, not lost", async () => {
494
+ let attempts = 0;
495
+ const fetchImpl: FetchLike = async () => {
496
+ attempts += 1;
497
+ return new Response("boom", { status: 500 });
498
+ };
499
+ const b = make({ bindings: doBindings, sproutUrl: "http://127.0.0.1:1/", fetchImpl });
500
+ await b.dispatch({ op: "do.alarm.set", cls: "Counter", id: "a", at: Date.now() - 1 });
501
+
502
+ await Bun.sleep(900);
503
+ expect(attempts).toBeGreaterThan(0);
504
+ // re-armed for a later retry rather than dropped on the floor
505
+ const pending = await b.dispatch({ op: "do.alarm.get", cls: "Counter", id: "a" });
506
+ expect(pending.at).not.toBeNull();
507
+ });
508
+
509
+ // --- service bindings (#48) --------------------------------------------------
510
+
511
+ const svcBindings = { services: [{ binding: "AUTH", service: "auth-api" }] };
512
+
513
+ test("service: forwards through the edge with the target's Host header", async () => {
514
+ const seen: Array<{ url: string; host: string | null; method: string; body: string | null }> = [];
515
+ const fetchImpl: FetchLike = async (url, init) => {
516
+ const headers = new Headers(init?.headers);
517
+ seen.push({
518
+ url: String(url),
519
+ host: headers.get("host"),
520
+ method: String(init?.method),
521
+ body: init?.body == null ? null : String(init.body),
522
+ });
523
+ return new Response("pong", { status: 200, headers: { "content-type": "text/plain" } });
524
+ };
525
+ const b = make({
526
+ bindings: svcBindings,
527
+ services: { AUTH: "auth-api.andrea.example.com" },
528
+ edgeUrl: "http://127.0.0.1:8080/",
529
+ fetchImpl,
530
+ });
531
+
532
+ const reply = await b.dispatch({
533
+ op: "service.fetch",
534
+ binding: "AUTH",
535
+ url: "https://service/verify?token=abc",
536
+ method: "POST",
537
+ headers: [["content-type", "application/json"]],
538
+ body: '{"t":1}',
539
+ });
540
+
541
+ expect(reply.status).toBe(200);
542
+ expect(reply.body).toBe("pong");
543
+ // The path and query survive; the destination is the edge, and the Host
544
+ // header is what actually routes it to the target deployment.
545
+ expect(seen[0].url).toBe("http://127.0.0.1:8080/verify?token=abc");
546
+ expect(seen[0].host).toBe("auth-api.andrea.example.com");
547
+ expect(seen[0].method).toBe("POST");
548
+ expect(seen[0].body).toBe('{"t":1}');
549
+ });
550
+
551
+ test("service: a binding the artifact never declared is refused", async () => {
552
+ const b = make({ bindings: svcBindings, services: { AUTH: "a.example.com" }, edgeUrl: "http://127.0.0.1:8080/" });
553
+ await expect(b.dispatch({ op: "service.fetch", binding: "GHOST", url: "https://service/" })).rejects.toThrow(
554
+ /service not bound: GHOST/,
555
+ );
556
+ });
557
+
558
+ test("service: declared but not deployed says so, instead of a bare 502", async () => {
559
+ const b = make({ bindings: svcBindings, services: {}, edgeUrl: "http://127.0.0.1:8080/" });
560
+ await expect(b.dispatch({ op: "service.fetch", binding: "AUTH", url: "https://service/" })).rejects.toThrow(
561
+ /"auth-api" is not deployed/,
562
+ );
563
+ });
564
+
565
+ test("service: a call is not subject to the outbound allowlist", async () => {
566
+ // The target is reached internally through the edge, so a project with no
567
+ // `outbound` hosts can still call a service binding.
568
+ const fetchImpl: FetchLike = async () => new Response("ok", { status: 200 });
569
+ const b = make({
570
+ bindings: { ...svcBindings, outbound: [] },
571
+ services: { AUTH: "auth-api.andrea.example.com" },
572
+ edgeUrl: "http://127.0.0.1:8080/",
573
+ fetchImpl,
574
+ });
575
+ expect((await b.dispatch({ op: "service.fetch", binding: "AUTH", url: "https://service/" })).status).toBe(200);
576
+ // ...while real egress to the same host is still refused.
577
+ await expect(b.dispatch({ op: "fetch", url: "https://auth-api.andrea.example.com/" })).rejects.toThrow(
578
+ /outbound allowlist/,
579
+ );
580
+ });
581
+
582
+ // --- v1 frames (#63) --------------------------------------------------------
583
+
584
+ /** Send one v1 frame over a real socket and return the decoded reply. */
585
+ async function v1(
586
+ server: { port: number },
587
+ json: Frame,
588
+ binary?: Uint8Array,
589
+ ): Promise<{ json: JsonObject; bytes: Uint8Array }> {
590
+ const payload = encodeV1(json, binary);
591
+ const frame = new Uint8Array(4 + payload.length);
592
+ new DataView(frame.buffer).setUint32(0, payload.length, true);
593
+ frame.set(payload, 4);
594
+ const chunks: Uint8Array[] = [];
595
+ const socket = await Bun.connect({
596
+ hostname: "127.0.0.1",
597
+ port: server.port,
598
+ socket: { data: (_s, d) => void chunks.push(new Uint8Array(d)) },
599
+ });
600
+ socket.write(frame);
601
+ for (let i = 0; i < 200 && chunks.length === 0; i++) await Bun.sleep(5);
602
+ await Bun.sleep(20);
603
+ socket.end();
604
+ const all = Buffer.concat(chunks);
605
+ const jsonLen = all.readUInt32LE(5);
606
+ return {
607
+ json: obj(parseJsonValue(all.subarray(9, 9 + jsonLen).toString("utf8"))),
608
+ bytes: new Uint8Array(all.subarray(9 + jsonLen)),
609
+ };
610
+ }
611
+
612
+ test("v1: an object body round-trips as bytes, not as escaped JSON", async () => {
613
+ const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
614
+ const server = listen(b, "127.0.0.1", 0);
615
+ // Every byte value, including the ones JSON escapes and the ones that are not
616
+ // valid UTF-8 — the whole point of carrying them outside the JSON.
617
+ const body = new Uint8Array(1024);
618
+ for (let i = 0; i < body.length; i++) body[i] = i % 256;
619
+
620
+ const put = await v1(server, { v: 1, token: "tok", op: "r2.put", bucket: "UP", key: "k" }, body);
621
+ expect(put.json.ok).toBe(true);
622
+ expect(obj(put.json.object).size).toBe(1024);
623
+
624
+ const got = await v1(server, { v: 1, token: "tok", op: "r2.get", bucket: "UP", key: "k" });
625
+ expect(got.json.found).toBe(true);
626
+ expect(got.bytes.length).toBe(1024);
627
+ expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
628
+ server.stop();
629
+ });
630
+
631
+ test("v1: a binary static asset round-trips without UTF-8 replacement", async () => {
632
+ const dir = mkdtempSync(join(tmpdir(), "sb-binary-asset-"));
633
+ const assets = join(dir, "assets");
634
+ mkdirSync(assets);
635
+ const body = new Uint8Array(256);
636
+ for (let index = 0; index < body.length; index++) body[index] = index;
637
+ writeFileSync(join(assets, "fixture.bin"), body);
638
+ writeFileSync(
639
+ join(dir, "assets.json"),
640
+ JSON.stringify({
641
+ notFound: "none",
642
+ runSproutFirst: false,
643
+ files: { "/fixture.bin": { type: "application/octet-stream", hash: "fixture" } },
644
+ }),
645
+ );
646
+ const b = make({ bindings: { assets: "ASSETS" }, assetsDir: assets, token: "tok" });
647
+ const server = listen(b, "127.0.0.1", 0);
648
+ const got = await v1(server, { v: 1, token: "tok", op: "assets.get", path: "/fixture.bin" });
649
+ expect(got.json.found).toBe(true);
650
+ expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
651
+ server.stop();
652
+ b.close();
653
+ rmSync(dir, { recursive: true, force: true });
654
+ });
655
+
656
+ test("v1: the etag is sha256 of the bytes, matching the v0 path", async () => {
657
+ const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
658
+ const server = listen(b, "127.0.0.1", 0);
659
+ const body = new TextEncoder().encode("the file contents");
660
+ const put = await v1(server, { v: 1, token: "tok", op: "r2.put", bucket: "UP", key: "k" }, body);
661
+ expect(obj(put.json.object).etag).toBe(createHash("sha256").update(body).digest("hex"));
662
+ server.stop();
663
+ });
664
+
665
+ test("v1: a wrong token is refused before the op runs", async () => {
666
+ const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
667
+ const server = listen(b, "127.0.0.1", 0);
668
+ const reply = await v1(server, { v: 1, token: "nope", op: "r2.put", bucket: "UP", key: "k" }, new Uint8Array(4));
669
+ expect(reply.json.ok).toBe(false);
670
+ expect(reply.json.error).toBe("unauthorized");
671
+ server.stop();
672
+ });
673
+
674
+ test("v0 frames still work, so an older artifact keeps running", async () => {
675
+ // The compatibility that matters: rollback can reactivate a sprout built
676
+ // before v1 existed, and it will speak the token-line format forever.
677
+ const b = make({ bindings: { kv: ["CACHE"] }, token: "tok" });
678
+ const reply = await b.handleFrame(Buffer.from('tok\n{"op":"kv.put","ns":"CACHE","key":"k","value":"v"}', "utf8"));
679
+ const len = reply.readUInt32LE(0);
680
+ expect(reply[4]).not.toBe(1); // a v0 request gets a v0 reply
681
+ expect(obj(parseJsonValue(reply.subarray(4, 4 + len).toString("utf8"))).ok).toBe(true);
682
+ });
683
+
684
+ test("a resent request is applied once, not twice (#63 §3)", async () => {
685
+ const b = make({ bindings: { d1: ["DB"] } });
686
+ const frame = (msg: Frame) => Buffer.from(`\n${JSON.stringify(msg)}`, "utf8");
687
+ await b.handleFrame(frame({ v: 1, id: 1, op: "d1.exec", db: "DB", sql: "CREATE TABLE t (v TEXT)" }));
688
+
689
+ // The same id three times is what a reconnect produces: the transport retries
690
+ // the exact bytes, having no way to know whether the first attempt landed.
691
+ const insert = { v: 1, id: 42, op: "d1.query", db: "DB", sql: "INSERT INTO t (v) VALUES ('x')" };
692
+ await b.handleFrame(frame(insert));
693
+ await b.handleFrame(frame(insert));
694
+ await b.handleFrame(frame(insert));
695
+
696
+ const counted = await b.dispatch({ op: "d1.query", db: "DB", sql: "SELECT count(*) AS n FROM t" });
697
+ expect(obj(arr(counted.results)[0]).n).toBe(1);
698
+
699
+ // A different id is a different request and does apply.
700
+ await b.handleFrame(frame({ ...insert, id: 43 }));
701
+ const after = await b.dispatch({ op: "d1.query", db: "DB", sql: "SELECT count(*) AS n FROM t" });
702
+ expect(obj(arr(after.results)[0]).n).toBe(2);
703
+ });
704
+
705
+ test("replaying a read is allowed, since it changes nothing", async () => {
706
+ const b = make({ bindings: { kv: ["CACHE"] } });
707
+ await b.handleFrame(
708
+ Buffer.from(`\n${JSON.stringify({ v: 1, id: 1, op: "kv.put", ns: "CACHE", key: "k", value: "v" })}`, "utf8"),
709
+ );
710
+ const read = async () =>
711
+ b.handleFrame(Buffer.from(`\n${JSON.stringify({ v: 1, id: 2, op: "kv.get", ns: "CACHE", key: "k" })}`, "utf8"));
712
+ const a = await read();
713
+ const c = await read();
714
+ expect(a.subarray(4).toString()).toBe(c.subarray(4).toString());
715
+ });