@sproutboat/runtime 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +696 -29
- package/src/queues.test.js +159 -0
- package/src/transport-embedded.js +106 -25
- package/src/wrap.ts +32 -1
- package/test/native-websockets.ts +178 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// Queues from compatibility date 2026-10-31 (producer checks and content types, the
|
|
5
|
+
// consumer's message and batch, first-call-wins, retry settings), against a fake transport.
|
|
6
|
+
const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
|
|
7
|
+
// oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
|
|
8
|
+
const load = new Function(
|
|
9
|
+
"Porffor",
|
|
10
|
+
`var __sbCall = (text) => globalThis.__sbTestCall(text);\n${source}\nreturn { install: __sbInstallBindings, run: __sbRunQueueBatch };`,
|
|
11
|
+
);
|
|
12
|
+
const { install, run } = load({ c: () => "" });
|
|
13
|
+
|
|
14
|
+
let sent = [];
|
|
15
|
+
globalThis.__sbTestCall = (text) => {
|
|
16
|
+
const req = JSON.parse(text);
|
|
17
|
+
sent.push(req);
|
|
18
|
+
return JSON.stringify({ ok: true, metrics: { backlogCount: 1, backlogBytes: 7, oldestMessageTimestamp: 1000 } });
|
|
19
|
+
};
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
sent = [];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const bindings = {
|
|
25
|
+
queues: ["QUEUE", "DLQ"],
|
|
26
|
+
queueConfig: [
|
|
27
|
+
{ binding: "QUEUE", queue: "q", maxRetries: 3, deadLetterQueue: "dlq" },
|
|
28
|
+
{ binding: "DLQ", queue: "dlq" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const env = (compat) => {
|
|
32
|
+
globalThis.__sbCompat = compat;
|
|
33
|
+
const target = {};
|
|
34
|
+
install(target, bindings);
|
|
35
|
+
return target;
|
|
36
|
+
};
|
|
37
|
+
const ctx = { waitUntil() {} };
|
|
38
|
+
|
|
39
|
+
test("2026-10-31: send() returns a promise of the backlog and keeps the content type", async () => {
|
|
40
|
+
const { QUEUE } = env("2026-10-31");
|
|
41
|
+
const result = QUEUE.send("123");
|
|
42
|
+
expect(result).toBeInstanceOf(Promise);
|
|
43
|
+
expect(await result).toEqual({
|
|
44
|
+
metadata: { metrics: { backlogCount: 1, backlogBytes: 7, oldestMessageTimestamp: 1000 } },
|
|
45
|
+
});
|
|
46
|
+
expect(sent[0].body).toBe('#sbq1:json:"123"');
|
|
47
|
+
await QUEUE.send("plain", { contentType: "text" });
|
|
48
|
+
await QUEUE.send(new Uint8Array([7, 8]), { contentType: "bytes" });
|
|
49
|
+
await QUEUE.send(new Map([[1, 2]]), { contentType: "v8" });
|
|
50
|
+
expect(sent.map((r) => r.body.split(":")[1])).toEqual(["json", "text", "bytes", "v8"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("2026-10-31: send() and sendBatch() reject what workerd rejects", async () => {
|
|
54
|
+
const { QUEUE } = env("2026-10-31");
|
|
55
|
+
const error = (p) =>
|
|
56
|
+
p.then(
|
|
57
|
+
() => "accepted",
|
|
58
|
+
(e) => e.name + ": " + e.message,
|
|
59
|
+
);
|
|
60
|
+
expect(await error(QUEUE.send(undefined))).toBe("TypeError: Message body cannot be undefined");
|
|
61
|
+
expect(await error(QUEUE.send({ b: 1n }))).toBe("TypeError: Do not know how to serialize a BigInt");
|
|
62
|
+
expect(await error(QUEUE.send({}, { contentType: "text" }))).toBe(
|
|
63
|
+
'TypeError: Content Type "text" requires a value of type string, but received: object',
|
|
64
|
+
);
|
|
65
|
+
expect(await error(QUEUE.send("x", { contentType: "xml" }))).toBe(
|
|
66
|
+
"TypeError: Unsupported queue message content type: xml",
|
|
67
|
+
);
|
|
68
|
+
expect(await error(QUEUE.send("x".repeat(128 * 1024 + 1)))).toStartWith("Error: ");
|
|
69
|
+
expect(await error(QUEUE.sendBatch([]))).toBe("TypeError: sendBatch() requires at least one message");
|
|
70
|
+
expect(await error(QUEUE.sendBatch({ body: 1 }))).toStartWith("TypeError: Failed to execute 'sendBatch'");
|
|
71
|
+
expect(await error(QUEUE.sendBatch(Array.from({ length: 101 }, () => ({ body: 1 }))))).toStartWith("Error: ");
|
|
72
|
+
expect(sent).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("before 2026-10-31 send() is unchanged", () => {
|
|
76
|
+
const { QUEUE } = env("2026-10-29");
|
|
77
|
+
expect(QUEUE.send("123")).toBeUndefined();
|
|
78
|
+
expect(sent[0].body).toBe("123");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const message = (id, body, attempts = 1) => ({ id, body, timestamp: 5000, attempts });
|
|
82
|
+
|
|
83
|
+
test("2026-10-31: message and batch shapes, decoded bodies, and the queue's name", async () => {
|
|
84
|
+
globalThis.__sbCompat = "2026-10-31";
|
|
85
|
+
let seen;
|
|
86
|
+
await run(
|
|
87
|
+
{
|
|
88
|
+
queue(batch) {
|
|
89
|
+
seen = batch;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
queue: "QUEUE",
|
|
94
|
+
messages: [message("a", '#sbq1:text:{"a":1}'), message("b", "#sbq1:bytes:0708")],
|
|
95
|
+
metadata: { metrics: { backlogCount: 2 } },
|
|
96
|
+
},
|
|
97
|
+
ctx,
|
|
98
|
+
);
|
|
99
|
+
expect(seen.queue).toBe("q");
|
|
100
|
+
expect(Object.keys(seen).sort()).toEqual(["messages", "metadata", "queue"]);
|
|
101
|
+
expect(Object.keys(seen.messages[0]).sort()).toEqual(["attempts", "body", "id", "timestamp"]);
|
|
102
|
+
expect(seen.messages[0].timestamp).toEqual(new Date(5000));
|
|
103
|
+
expect(seen.messages[0].body).toBe('{"a":1}');
|
|
104
|
+
expect(new Uint8Array(seen.messages[1].body)).toEqual(new Uint8Array([7, 8]));
|
|
105
|
+
expect(seen.metadata.metrics.backlogCount).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("2026-10-31: the first of ack() and retry() wins, and a message's own call beats the batch's", async () => {
|
|
109
|
+
globalThis.__sbCompat = "2026-10-31";
|
|
110
|
+
const out = await run(
|
|
111
|
+
{
|
|
112
|
+
queue(batch) {
|
|
113
|
+
batch.messages[0].ack();
|
|
114
|
+
batch.messages[0].retry();
|
|
115
|
+
batch.messages[1].retry({ delaySeconds: 7 });
|
|
116
|
+
batch.retryAll();
|
|
117
|
+
throw new Error("after the decisions");
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ queue: "QUEUE", messages: [message("a", "1"), message("b", "2"), message("c", "3")] },
|
|
121
|
+
ctx,
|
|
122
|
+
);
|
|
123
|
+
expect(out.ack).toEqual(["a"]);
|
|
124
|
+
expect(out.retry).toEqual(["b", "c"]);
|
|
125
|
+
expect(out.delays).toEqual({ b: 7, c: 0 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("max_retries: a retry past it goes to the dead letter queue's binding", async () => {
|
|
129
|
+
globalThis.__sbCompat = "2026-10-31";
|
|
130
|
+
const out = await run(
|
|
131
|
+
{
|
|
132
|
+
queue(batch) {
|
|
133
|
+
batch.retryAll();
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{ queue: "QUEUE", messages: [message("a", "1", 3), message("b", "2", 4)] },
|
|
137
|
+
ctx,
|
|
138
|
+
);
|
|
139
|
+
expect(out.retry).toEqual(["a", "b"]);
|
|
140
|
+
expect(out.dead).toEqual(["b"]);
|
|
141
|
+
expect(out.deadLetter).toBe("DLQ");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("before 2026-10-31 without settings, the outcome has only ack and retry", async () => {
|
|
145
|
+
globalThis.__sbCompat = "2026-10-29";
|
|
146
|
+
let legacy;
|
|
147
|
+
const out = await run(
|
|
148
|
+
{
|
|
149
|
+
queue(batch) {
|
|
150
|
+
legacy = [batch.queue, Object.keys(batch.messages[0]).includes("ack"), batch.messages[0].timestamp];
|
|
151
|
+
throw new Error("retry all");
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{ queue: "DLQ", messages: [message("a", '{"a":1}')] },
|
|
155
|
+
ctx,
|
|
156
|
+
);
|
|
157
|
+
expect(legacy).toEqual(["dlq", true, 5000]);
|
|
158
|
+
expect(out).toEqual({ ack: [], retry: ["a"] });
|
|
159
|
+
});
|
|
@@ -3700,8 +3700,14 @@ function __sbEnsureSchema() {
|
|
|
3700
3700
|
__sbSql(s, "CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)", []);
|
|
3701
3701
|
const mqColumns = __sbSql(s, "PRAGMA table_info(mq)", []).rows;
|
|
3702
3702
|
let mqClaim = false;
|
|
3703
|
-
|
|
3703
|
+
let mqSent = false;
|
|
3704
|
+
for (let i = 0; i < mqColumns.length; i++) {
|
|
3705
|
+
if (mqColumns[i][1] === "claim_token") mqClaim = true;
|
|
3706
|
+
if (mqColumns[i][1] === "sent_at") mqSent = true;
|
|
3707
|
+
}
|
|
3704
3708
|
if (!mqClaim) __sbSql(s, "ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''", []);
|
|
3709
|
+
// When the message was sent (message.timestamp); 0 for messages queued before the column.
|
|
3710
|
+
if (!mqSent) __sbSql(s, "ALTER TABLE mq ADD COLUMN sent_at INTEGER NOT NULL DEFAULT 0", []);
|
|
3705
3711
|
__sbSql(
|
|
3706
3712
|
s,
|
|
3707
3713
|
"CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (cls, id, key))",
|
|
@@ -4278,22 +4284,25 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4278
4284
|
// (data loss), and delaySeconds was ignored on both calls.
|
|
4279
4285
|
const items = op === "queue.send" ? [{ body: msg.body, delaySeconds: msg.delaySeconds }] : msg.messages || [];
|
|
4280
4286
|
let first = -1;
|
|
4287
|
+
const now = Date.now();
|
|
4281
4288
|
for (let i = 0; i < items.length; i++) {
|
|
4282
4289
|
const item = items[i] || {};
|
|
4283
4290
|
const delay = Math.max(0, Number(item.delaySeconds) || 0) * 1000;
|
|
4284
|
-
const visible =
|
|
4291
|
+
const visible = now + delay;
|
|
4285
4292
|
if (first < 0 || visible < first) first = visible;
|
|
4286
4293
|
__sbSql(
|
|
4287
4294
|
store,
|
|
4288
|
-
"INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
|
|
4289
|
-
[msg.queue, __sbHex(12), String(item.body), visible],
|
|
4295
|
+
"INSERT INTO mq (queue, id, body, visible_at, attempts, dead, sent_at) VALUES (?1,?2,?3,?4,0,0,?5)",
|
|
4296
|
+
[msg.queue, __sbHex(12), String(item.body), visible, now],
|
|
4290
4297
|
);
|
|
4291
4298
|
}
|
|
4292
4299
|
// B22: on sproutd, the host delivers the batch (waking the app if it sleeps by then).
|
|
4293
4300
|
if (first >= 0 && __sbLocal !== null && __sbLocal.queues.indexOf(msg.queue) !== -1) __sbHostDue(first);
|
|
4294
|
-
return { ok: true };
|
|
4301
|
+
return msg.metrics ? { ok: true, metrics: __sbQueueBacklog(store, msg.queue) } : { ok: true };
|
|
4295
4302
|
}
|
|
4296
4303
|
|
|
4304
|
+
if (op === "queue.metrics") return { ok: true, metrics: __sbQueueBacklog(store, msg.queue) };
|
|
4305
|
+
|
|
4297
4306
|
if (op === "do.storage.get") {
|
|
4298
4307
|
const r = __sbSql(store, "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
|
|
4299
4308
|
msg.cls,
|
|
@@ -4739,27 +4748,57 @@ function __sbCronMatches(expr, when) {
|
|
|
4739
4748
|
|
|
4740
4749
|
var __sbLastCronTick = "";
|
|
4741
4750
|
|
|
4742
|
-
//
|
|
4743
|
-
//
|
|
4751
|
+
// A queue's backlog as Queues reports it (send() results, batch.metadata): its live
|
|
4752
|
+
// messages, their bytes and the oldest one's send time.
|
|
4753
|
+
function __sbQueueBacklog(store, queue) {
|
|
4754
|
+
const r = __sbSql(
|
|
4755
|
+
store,
|
|
4756
|
+
"SELECT COUNT(*), COALESCE(SUM(LENGTH(CAST(body AS BLOB))),0), MIN(NULLIF(sent_at,0)) FROM mq WHERE queue=? AND dead=0",
|
|
4757
|
+
[queue],
|
|
4758
|
+
).rows[0];
|
|
4759
|
+
return { backlogCount: Number(r[0]) || 0, backlogBytes: Number(r[1]) || 0, oldestMessageTimestamp: Number(r[2]) || 0 };
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
function __sbQueueAttemptCap(settings) {
|
|
4763
|
+
return Math.max(5, (settings.maxRetries !== undefined ? Number(settings.maxRetries) : 3) + 1);
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
// How long a queue's due messages wait to fill a batch (max_batch_timeout), in ms.
|
|
4767
|
+
function __sbQueueWaitMs(binding) {
|
|
4768
|
+
return Math.max(0, Number(__sbQueueSettings(binding).maxBatchTimeout) || 0) * 1000;
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
// One delivery pass over the queues this worker consumes: claims up to max_batch_size (10)
|
|
4772
|
+
// due messages per queue, hands them to queue() and applies its acks and retries. With a
|
|
4773
|
+
// max_batch_timeout, a batch smaller than max_batch_size waits until its oldest message
|
|
4774
|
+
// has been due that long.
|
|
4744
4775
|
async function __sbQueuePass(handlers, queues) {
|
|
4745
4776
|
__sbEnsureSchema();
|
|
4746
4777
|
const store = __sbStore();
|
|
4747
4778
|
const now = Date.now();
|
|
4748
4779
|
for (let q = 0; q < queues.length; q++) {
|
|
4749
4780
|
const name = queues[q];
|
|
4781
|
+
const settings = __sbQueueSettings(name);
|
|
4782
|
+
const size = Math.min(100, Math.max(1, Number(settings.maxBatchSize) || 10));
|
|
4783
|
+
const wait = __sbQueueWaitMs(name);
|
|
4750
4784
|
const claim = String(now) + ":" + String(Math.random());
|
|
4751
4785
|
__sbSql(store, "BEGIN IMMEDIATE", []);
|
|
4752
4786
|
let due;
|
|
4753
4787
|
try {
|
|
4754
|
-
|
|
4788
|
+
// A message whose deliveries never came back (the worker died or timed out each time)
|
|
4789
|
+
// stops after 5 attempts, or max_retries + 1 when that is more.
|
|
4790
|
+
__sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=?", [
|
|
4755
4791
|
name,
|
|
4756
4792
|
now,
|
|
4793
|
+
__sbQueueAttemptCap(settings),
|
|
4757
4794
|
]);
|
|
4758
4795
|
due = __sbSql(
|
|
4759
4796
|
store,
|
|
4760
|
-
"SELECT id,body,attempts FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT
|
|
4761
|
-
[name, now],
|
|
4797
|
+
"SELECT id,body,attempts,sent_at,visible_at FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT ?",
|
|
4798
|
+
[name, now, size],
|
|
4762
4799
|
);
|
|
4800
|
+
if (wait > 0 && due.rows.length > 0 && due.rows.length < size && Number(due.rows[0][4]) + wait > now)
|
|
4801
|
+
due = { rows: [] };
|
|
4763
4802
|
for (let i = 0; i < due.rows.length; i++)
|
|
4764
4803
|
__sbSql(
|
|
4765
4804
|
store,
|
|
@@ -4776,9 +4815,9 @@ async function __sbQueuePass(handlers, queues) {
|
|
|
4776
4815
|
const messages = [];
|
|
4777
4816
|
for (let i = 0; i < due.rows.length; i++) {
|
|
4778
4817
|
const row = due.rows[i];
|
|
4779
|
-
|
|
4780
|
-
messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
|
|
4818
|
+
messages.push({ id: row[0], body: row[1], timestamp: Number(row[3]) || now, attempts: Number(row[2]) + 1 });
|
|
4781
4819
|
}
|
|
4820
|
+
const metadata = { metrics: __sbQueueBacklog(store, name) };
|
|
4782
4821
|
// #171 — same ctx.waitUntil as the broker-delivered path, and the same
|
|
4783
4822
|
// fix for the handler's own promise: `__sbRunQueueBatch` used to be
|
|
4784
4823
|
// called fire-and-forget here too, so an async `queue()`'s ack/retry
|
|
@@ -4789,24 +4828,55 @@ async function __sbQueuePass(handlers, queues) {
|
|
|
4789
4828
|
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
4790
4829
|
},
|
|
4791
4830
|
};
|
|
4792
|
-
let result = __sbRunQueueBatch(handlers, { queue: name, messages }, ctx);
|
|
4831
|
+
let result = __sbRunQueueBatch(handlers, { queue: name, messages, metadata }, ctx);
|
|
4793
4832
|
if (result && __sbIsFn(result.then)) result = await result;
|
|
4794
4833
|
if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4834
|
+
__sbQueueSettle(store, name, claim, result);
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
// Applies a batch's outcome to the claimed rows. `dead` (past max_retries) move to the dead
|
|
4839
|
+
// letter queue `deadLetter` as new messages, or are marked dead; `delays` gives each retry's
|
|
4840
|
+
// delay in seconds (5 when the runtime sends none). A row whose claim expired meanwhile was
|
|
4841
|
+
// handed out again and is left alone.
|
|
4842
|
+
function __sbQueueSettle(store, name, claim, result) {
|
|
4843
|
+
const now = Date.now();
|
|
4844
|
+
const dead = result.dead || [];
|
|
4845
|
+
const delays = result.delays || null;
|
|
4846
|
+
for (let i = 0; i < dead.length; i++) {
|
|
4847
|
+
if (result.deadLetter)
|
|
4803
4848
|
__sbSql(
|
|
4804
4849
|
store,
|
|
4805
|
-
"UPDATE mq SET visible_at=?,claim_token='',dead=
|
|
4806
|
-
[
|
|
4850
|
+
"UPDATE mq SET queue=?,visible_at=?,attempts=0,claim_token='',dead=0 WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
|
|
4851
|
+
[String(result.deadLetter), now, name, dead[i], claim, now],
|
|
4807
4852
|
);
|
|
4808
|
-
|
|
4853
|
+
else
|
|
4854
|
+
__sbSql(store, "UPDATE mq SET dead=1,claim_token='' WHERE queue=? AND id=? AND claim_token=? AND visible_at>?", [
|
|
4855
|
+
name,
|
|
4856
|
+
dead[i],
|
|
4857
|
+
claim,
|
|
4858
|
+
now,
|
|
4859
|
+
]);
|
|
4809
4860
|
}
|
|
4861
|
+
for (let i = 0; i < result.ack.length; i++) {
|
|
4862
|
+
__sbSql(
|
|
4863
|
+
store,
|
|
4864
|
+
"DELETE FROM mq WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
|
|
4865
|
+
[name, result.ack[i], claim, now],
|
|
4866
|
+
);
|
|
4867
|
+
}
|
|
4868
|
+
for (let i = 0; i < result.retry.length; i++) {
|
|
4869
|
+
const id = result.retry[i];
|
|
4870
|
+
if (dead.indexOf(id) !== -1) continue;
|
|
4871
|
+
const delay = delays !== null && delays[id] != null ? Math.max(0, Number(delays[id]) || 0) * 1000 : 5000;
|
|
4872
|
+
__sbSql(
|
|
4873
|
+
store,
|
|
4874
|
+
"UPDATE mq SET visible_at=?,claim_token='',dead=CASE WHEN attempts>=5 AND ?=0 THEN 1 ELSE 0 END WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
|
|
4875
|
+
[now + delay, delays !== null ? 1 : 0, name, id, claim, now],
|
|
4876
|
+
);
|
|
4877
|
+
}
|
|
4878
|
+
if (result.deadLetter && dead.length && __sbLocal !== null && __sbLocal.queues.indexOf(String(result.deadLetter)) !== -1)
|
|
4879
|
+
__sbHostDue(now);
|
|
4810
4880
|
}
|
|
4811
4881
|
|
|
4812
4882
|
// One pass over the Durable Object alarms that are due: claims up to 10 and runs alarm().
|
|
@@ -4898,6 +4968,17 @@ function __sbHostDue(at) {
|
|
|
4898
4968
|
if (__sbLocal !== null && __sbLocal.consumes) __sbHostNote("due " + String(Math.max(0, Math.trunc(Number(at) || 0))));
|
|
4899
4969
|
}
|
|
4900
4970
|
|
|
4971
|
+
// When a queue next has a batch to deliver: its earliest visible message, plus the batch
|
|
4972
|
+
// timeout unless a whole batch is already there.
|
|
4973
|
+
function __sbQueueNextDue(store, queue) {
|
|
4974
|
+
const r = __sbSql(store, "SELECT MIN(visible_at) FROM mq WHERE queue=? AND dead=0", [queue]);
|
|
4975
|
+
const wait = __sbQueueWaitMs(queue);
|
|
4976
|
+
if (wait === 0 || !r.rows.length || r.rows[0][0] == null) return r;
|
|
4977
|
+
const size = Math.min(100, Math.max(1, Number(__sbQueueSettings(queue).maxBatchSize) || 10));
|
|
4978
|
+
const full = __sbSql(store, "SELECT COUNT(*) FROM mq WHERE queue=? AND dead=0 AND visible_at<=?", [queue, Date.now()]);
|
|
4979
|
+
return Number(full.rows[0][0]) >= size ? r : { rows: [[Number(r.rows[0][0]) + wait]] };
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4901
4982
|
// The earliest time something this worker consumes falls due: a message becomes visible
|
|
4902
4983
|
// (or its claim expires), an alarm is set for, or a failed alarm is retried. -1 for none.
|
|
4903
4984
|
function __sbNextDue() {
|
|
@@ -4911,7 +4992,7 @@ function __sbNextDue() {
|
|
|
4911
4992
|
};
|
|
4912
4993
|
const queues = __sbLocal.queues;
|
|
4913
4994
|
for (let q = 0; q < queues.length; q++)
|
|
4914
|
-
take(
|
|
4995
|
+
take(__sbQueueNextDue(store, queues[q]));
|
|
4915
4996
|
if (__sbLocal.alarms) {
|
|
4916
4997
|
take(__sbSql(store, "SELECT MIN(at) FROM do_alarm", []));
|
|
4917
4998
|
take(__sbSql(store, "SELECT MIN(expires) FROM do_alarm_delivery WHERE attempts<5", []));
|
package/src/wrap.ts
CHANGED
|
@@ -58,6 +58,10 @@ export type Bindings = {
|
|
|
58
58
|
d1: string[];
|
|
59
59
|
r2: string[];
|
|
60
60
|
queues: string[];
|
|
61
|
+
/** Each queue binding's queue name and consumer settings (`queueSettings` in
|
|
62
|
+
* @sproutboat/config). Absent from older artifacts: every queue is then named by its
|
|
63
|
+
* binding, with the default settings. */
|
|
64
|
+
queueConfig?: QueueSettings[];
|
|
61
65
|
analytics: string[];
|
|
62
66
|
do: Array<{ binding: string; className: string }>;
|
|
63
67
|
/** #48 — worker-to-worker: binding name -> the project it calls. The hostname
|
|
@@ -71,6 +75,17 @@ export type Bindings = {
|
|
|
71
75
|
assets: string;
|
|
72
76
|
};
|
|
73
77
|
|
|
78
|
+
/** One queue binding's name and consumer settings; unset settings are absent. */
|
|
79
|
+
export type QueueSettings = {
|
|
80
|
+
binding: string;
|
|
81
|
+
queue: string;
|
|
82
|
+
maxBatchSize?: number;
|
|
83
|
+
maxBatchTimeout?: number;
|
|
84
|
+
maxRetries?: number;
|
|
85
|
+
retryDelay?: number;
|
|
86
|
+
deadLetterQueue?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
74
89
|
export const EMPTY_BINDINGS: Bindings = {
|
|
75
90
|
kv: [],
|
|
76
91
|
secrets: [],
|
|
@@ -335,7 +350,21 @@ export function readBindingsFromEnv(): Bindings {
|
|
|
335
350
|
}
|
|
336
351
|
}
|
|
337
352
|
}
|
|
338
|
-
|
|
353
|
+
const queueConfig: QueueSettings[] = [];
|
|
354
|
+
if (Array.isArray(parsed.queueConfig)) {
|
|
355
|
+
const num = (v: VarsJson | undefined): v is number => Number(v) === v && Number.isFinite(v) && v >= 0;
|
|
356
|
+
for (const entry of parsed.queueConfig) {
|
|
357
|
+
if (!isVarsObject(entry) || !isVarsString(entry.binding) || !isVarsString(entry.queue)) continue;
|
|
358
|
+
const q: QueueSettings = { binding: entry.binding, queue: entry.queue };
|
|
359
|
+
if (num(entry.maxBatchSize)) q.maxBatchSize = entry.maxBatchSize;
|
|
360
|
+
if (num(entry.maxBatchTimeout)) q.maxBatchTimeout = entry.maxBatchTimeout;
|
|
361
|
+
if (num(entry.maxRetries)) q.maxRetries = entry.maxRetries;
|
|
362
|
+
if (num(entry.retryDelay)) q.retryDelay = entry.retryDelay;
|
|
363
|
+
if (isVarsString(entry.deadLetterQueue)) q.deadLetterQueue = entry.deadLetterQueue;
|
|
364
|
+
queueConfig.push(q);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const out: Bindings = {
|
|
339
368
|
kv: strings(parsed.kv),
|
|
340
369
|
secrets: strings(parsed.secrets),
|
|
341
370
|
d1: strings(parsed.d1),
|
|
@@ -348,4 +377,6 @@ export function readBindingsFromEnv(): Bindings {
|
|
|
348
377
|
ratelimiters,
|
|
349
378
|
assets: isVarsString(parsed.assets) ? parsed.assets : "",
|
|
350
379
|
};
|
|
380
|
+
if (queueConfig.length) out.queueConfig = queueConfig;
|
|
381
|
+
return out;
|
|
351
382
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// WebSockets from compatibility date 2026-10-30: a WebSocketPair whose ends both stay in
|
|
9
|
+
// the worker is plain JavaScript and works on every host; a 101 response needs a host that
|
|
10
|
+
// carries client sockets (sproutd), so a standalone sprout answers it with a 501. Before the
|
|
11
|
+
// date nothing is defined. The answers below are workerd's (1.20260730.1) for the same code.
|
|
12
|
+
const handler = `export default {async fetch(request) {
|
|
13
|
+
const path = new URL(request.url).pathname;
|
|
14
|
+
const tick = () => new Promise((r) => setTimeout(r, 10));
|
|
15
|
+
if (path === "/shape") return Response.json([typeof WebSocketPair, typeof WebSocket, typeof WebSocketRequestResponsePair, new Response("x").webSocket]);
|
|
16
|
+
if (path === "/pair") {
|
|
17
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
18
|
+
const log = [];
|
|
19
|
+
server.accept();
|
|
20
|
+
client.accept();
|
|
21
|
+
server.binaryType = "arraybuffer";
|
|
22
|
+
client.binaryType = "arraybuffer";
|
|
23
|
+
server.addEventListener("message", (e) => {
|
|
24
|
+
log.push("s:" + (typeof e.data === "string" ? e.data : "bytes " + new Uint8Array(e.data).join(",")));
|
|
25
|
+
server.send(typeof e.data === "string" ? "echo:" + e.data : e.data);
|
|
26
|
+
});
|
|
27
|
+
client.addEventListener("message", (e) => log.push("c:" + (typeof e.data === "string" ? e.data : "bytes " + new Uint8Array(e.data).join(","))));
|
|
28
|
+
client.addEventListener("close", (e) => log.push("client close " + e.code + " " + e.reason + " " + e.wasClean));
|
|
29
|
+
server.addEventListener("close", (e) => log.push("server close " + e.code + " " + e.reason + " " + e.wasClean));
|
|
30
|
+
client.send("hi");
|
|
31
|
+
client.send(new Uint8Array([1, 2, 3]));
|
|
32
|
+
await tick();
|
|
33
|
+
let badCode = "";
|
|
34
|
+
try { server.close(999); } catch (e) { badCode = e.name; }
|
|
35
|
+
client.close(4001, "bye");
|
|
36
|
+
await tick();
|
|
37
|
+
let after = "";
|
|
38
|
+
try { client.send("x"); } catch (e) { after = e.message; }
|
|
39
|
+
return Response.json({ log, badCode, after, states: [client.readyState, server.readyState] });
|
|
40
|
+
}
|
|
41
|
+
if (path === "/blob") {
|
|
42
|
+
// Binary messages are Blobs unless binaryType says otherwise.
|
|
43
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
44
|
+
server.accept();
|
|
45
|
+
client.accept();
|
|
46
|
+
const got = [server.binaryType];
|
|
47
|
+
server.addEventListener("message", async (e) => got.push(e.data instanceof Blob, Array.from(new Uint8Array(await e.data.arrayBuffer()))));
|
|
48
|
+
client.send(new Uint8Array([7, 8]));
|
|
49
|
+
await tick();
|
|
50
|
+
let reason = "";
|
|
51
|
+
try { server.close(1000, "x".repeat(124)); } catch (e) { reason = e.name; }
|
|
52
|
+
return Response.json([...got, reason]);
|
|
53
|
+
}
|
|
54
|
+
if (path === "/queued") {
|
|
55
|
+
// Messages sent before the other end accepts wait for it.
|
|
56
|
+
const pair = new WebSocketPair();
|
|
57
|
+
const server = pair[1];
|
|
58
|
+
const client = pair[0];
|
|
59
|
+
client.accept();
|
|
60
|
+
client.send("early");
|
|
61
|
+
await tick();
|
|
62
|
+
const got = [];
|
|
63
|
+
server.addEventListener("message", (e) => got.push(e.data));
|
|
64
|
+
server.accept();
|
|
65
|
+
await tick();
|
|
66
|
+
return Response.json(got);
|
|
67
|
+
}
|
|
68
|
+
if (path === "/unaccepted") {
|
|
69
|
+
const pair = new WebSocketPair();
|
|
70
|
+
try { pair[0].send("x"); return new Response("sent"); } catch (e) { return new Response(e.name + ": " + e.message); }
|
|
71
|
+
}
|
|
72
|
+
if (path === "/upgrade") {
|
|
73
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
74
|
+
server.accept();
|
|
75
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
76
|
+
}
|
|
77
|
+
if (path === "/bad101") {
|
|
78
|
+
try { new Response(null, { status: 101 }); return new Response("made"); } catch (e) { return new Response(e.name); }
|
|
79
|
+
}
|
|
80
|
+
return new Response("no route " + path, { status: 404 });
|
|
81
|
+
}};`;
|
|
82
|
+
|
|
83
|
+
function freePort(): number {
|
|
84
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
85
|
+
const port = listener.port;
|
|
86
|
+
listener.stop(true);
|
|
87
|
+
return port;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-websockets-"));
|
|
91
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
92
|
+
try {
|
|
93
|
+
const porffor = await ensurePorffor();
|
|
94
|
+
const node = Bun.which("node");
|
|
95
|
+
if (!node) throw new Error("native WebSocket test needs node on PATH");
|
|
96
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
97
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
98
|
+
const start = async (date: string) => {
|
|
99
|
+
const generated = join(workdir, `ws-${date}.generated.js`);
|
|
100
|
+
const binary = join(workdir, `ws-${date}`);
|
|
101
|
+
await writeFile(
|
|
102
|
+
generated,
|
|
103
|
+
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), {}, undefined, undefined, date),
|
|
104
|
+
);
|
|
105
|
+
const compile = Bun.spawn(
|
|
106
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
107
|
+
{
|
|
108
|
+
cwd: workdir,
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "pipe",
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
114
|
+
compile.exited,
|
|
115
|
+
new Response(compile.stdout).text(),
|
|
116
|
+
new Response(compile.stderr).text(),
|
|
117
|
+
]);
|
|
118
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
119
|
+
const port = freePort();
|
|
120
|
+
const server = Bun.spawn([binary], {
|
|
121
|
+
cwd: workdir,
|
|
122
|
+
env: { ...process.env, PORT: String(port) },
|
|
123
|
+
stdout: "ignore",
|
|
124
|
+
stderr: "inherit",
|
|
125
|
+
});
|
|
126
|
+
servers.push(server);
|
|
127
|
+
const base = `http://127.0.0.1:${port}`;
|
|
128
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
129
|
+
try {
|
|
130
|
+
await fetch(`${base}/bad101`, { signal: AbortSignal.timeout(500) });
|
|
131
|
+
return base;
|
|
132
|
+
} catch {
|
|
133
|
+
if (server.exitCode !== null) break;
|
|
134
|
+
await Bun.sleep(100);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw new Error("compiled handler did not start");
|
|
138
|
+
};
|
|
139
|
+
const [now, before] = await Promise.all([start("2026-10-30"), start("2026-10-29")]);
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(await (await fetch(`${now}/shape`)).json(), ["function", "function", "function", null]);
|
|
142
|
+
assert.deepEqual(await (await fetch(`${before}/shape`)).json(), ["undefined", "undefined", "undefined", null]);
|
|
143
|
+
console.log("WebSocketPair, WebSocket and WebSocketRequestResponsePair exist from 2026-10-30 only");
|
|
144
|
+
|
|
145
|
+
assert.deepEqual(await (await fetch(`${now}/pair`)).json(), {
|
|
146
|
+
log: [
|
|
147
|
+
"s:hi",
|
|
148
|
+
"c:echo:hi",
|
|
149
|
+
"s:bytes 1,2,3",
|
|
150
|
+
"c:bytes 1,2,3",
|
|
151
|
+
"server close 4001 bye true",
|
|
152
|
+
"client close 4001 bye true",
|
|
153
|
+
],
|
|
154
|
+
badCode: "InvalidAccessError",
|
|
155
|
+
after: "Can't call WebSocket send() after close().",
|
|
156
|
+
states: [3, 3],
|
|
157
|
+
});
|
|
158
|
+
assert.deepEqual(await (await fetch(`${now}/queued`)).json(), ["early"]);
|
|
159
|
+
assert.deepEqual(await (await fetch(`${now}/blob`)).json(), ["blob", true, [7, 8], "SyntaxError"]);
|
|
160
|
+
assert.equal(
|
|
161
|
+
await (await fetch(`${now}/unaccepted`)).text(),
|
|
162
|
+
"TypeError: You must call one of accept() or state.acceptWebSocket() on this WebSocket before sending messages.",
|
|
163
|
+
);
|
|
164
|
+
console.log("a pair passes text, bytes and close codes, and queues messages until accept()");
|
|
165
|
+
|
|
166
|
+
const upgrade = await fetch(`${now}/upgrade`);
|
|
167
|
+
assert.equal(upgrade.status, 501);
|
|
168
|
+
assert.equal(await upgrade.text(), "WebSockets are not supported by this host yet");
|
|
169
|
+
assert.equal(await (await fetch(`${now}/bad101`)).text(), "RangeError");
|
|
170
|
+
assert.equal(await (await fetch(`${before}/bad101`)).text(), "RangeError");
|
|
171
|
+
console.log("a standalone sprout answers a 101 response with 501; status 101 needs a WebSocket");
|
|
172
|
+
} finally {
|
|
173
|
+
for (const server of servers) {
|
|
174
|
+
server.kill(9);
|
|
175
|
+
await server.exited;
|
|
176
|
+
}
|
|
177
|
+
await rm(workdir, { recursive: true, force: true });
|
|
178
|
+
}
|