@sproutboat/runtime 0.5.0 → 0.6.1
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 +82 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +140 -10
- package/src/source.ts +14 -1
- package/src/transport-embedded.js +35 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 806c21e: Fix (baronunread/sproutboat#176): #172's bytestring UTF-8 encoding fix broke
|
|
8
|
+
the assets binding, which reads a file's bytes off disk into a `bytestring`
|
|
9
|
+
specifically so they pass through the wire untouched — a `bytestring`
|
|
10
|
+
carrying real text a handler built and one carrying opaque file bytes are the
|
|
11
|
+
same type with no way to tell them apart, so #172's blanket encoding also
|
|
12
|
+
re-encoded already-finished bytes, corrupting any binary asset (and any
|
|
13
|
+
proxied `fetch()`/service-binding response body).
|
|
14
|
+
|
|
15
|
+
Adds a second, dedicated `porf_native_fetch_read_raw_bytes` (zero-copy,
|
|
16
|
+
bytestring-only, no encoding, ever) alongside the untouched original —
|
|
17
|
+
`porf_native_fetch_read_value` is called from ~30 places across sproutboat's
|
|
18
|
+
own inline C, all genuine text, so its signature and behavior stay exactly as
|
|
19
|
+
#172 left them. `write_response_value` picks between the two based on a new
|
|
20
|
+
reserved `x-sb-raw-body` response header (same pattern as #163's
|
|
21
|
+
`x-sb-remote-addr`), which the assets binding, outbound `fetch()`, and
|
|
22
|
+
service-binding `fetch()` now set — the three places sproutboat's own
|
|
23
|
+
prelude constructs a Response from wire bytes rather than a string a handler
|
|
24
|
+
built. Verified end-to-end: a 256-byte all-values fixture now round-trips
|
|
25
|
+
byte-for-byte through a real standalone build's assets binding, and #172's
|
|
26
|
+
original UTF-8 fix still holds for genuine text.
|
|
27
|
+
|
|
28
|
+
Not fixed here: `env.<R2>.get()`'s `.body` is exposed directly to handler
|
|
29
|
+
code, which then constructs its own `new Response(obj.body)` — no
|
|
30
|
+
sproutboat-owned call site to attach the marker to, so R2 binary objects
|
|
31
|
+
served this way carry the same corruption. Needs either a real binary body
|
|
32
|
+
type upstream in Porffor or a different API shape; tracked as a known gap on
|
|
33
|
+
#176, not resolved by this fix.
|
|
34
|
+
|
|
35
|
+
## 0.6.0
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- ae8c7a8: `ctx.waitUntil` (baronunread/sproutboat#57): `fetch(request, ctx)` now takes a
|
|
40
|
+
second argument with `waitUntil(promise)`. Registered promises are drained,
|
|
41
|
+
in-process and sequentially, after the handler returns and before the turn
|
|
42
|
+
completes, capped at 25s for the whole batch — a task still running past that
|
|
43
|
+
keeps running, but stops holding up the response. A rejected task is
|
|
44
|
+
swallowed rather than failing the response.
|
|
45
|
+
|
|
46
|
+
`DurableObjectState.waitUntil` was a silent no-op stub; it now actually queues
|
|
47
|
+
and drains the same way, scoped to the instance. `alarm()` also used to be
|
|
48
|
+
fire-and-forget (its promise and any `state.waitUntil()` it queued were
|
|
49
|
+
dropped the moment the 204 went out) — both are now awaited.
|
|
50
|
+
|
|
51
|
+
Additive: a handler that ignores the second `fetch` argument is unaffected.
|
|
52
|
+
- ae8c7a8: `ctx.waitUntil` for `scheduled` and `queue` (baronunread/sproutboat#171):
|
|
53
|
+
both now take the same second `ctx` argument as `fetch`, drained the same way.
|
|
54
|
+
|
|
55
|
+
Also fixes a real bug found while wiring it up: `scheduled` and `queue` were
|
|
56
|
+
fire-and-forget regardless of `ctx` — an async handler's own promise was
|
|
57
|
+
dropped the instant the reply went out. For `queue` specifically, the default
|
|
58
|
+
"unhandled messages are acked" pass ran synchronously right after the
|
|
59
|
+
(discarded) call to the handler, so `ack()`/`retry()` calls made after the
|
|
60
|
+
handler's first `await` never reached the response. Both are now properly
|
|
61
|
+
awaited, on every delivery path: broker-dispatched, and the embedded
|
|
62
|
+
standalone binary's own local cron/queue/DO-alarm timers (the latter had the
|
|
63
|
+
same gap for `DurableObjectState.waitUntil` from #57 — that local path
|
|
64
|
+
bypassed the fix there entirely).
|
|
65
|
+
|
|
66
|
+
Additive: a handler that ignores the second argument is unaffected.
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- 5b76f6b: Fix (baronunread/sproutboat#132): the banned-API capability check matched a bare
|
|
71
|
+
identifier (`\bprocess\b`), so a locally-declared `function process()` — zod v4
|
|
72
|
+
declares exactly that — failed the check as readily as a real read of the Node
|
|
73
|
+
global. Now requires a member access (`process.env`, no whitespace around the
|
|
74
|
+
`.`, to avoid matching a sentence like "...unique to this process. The...") or
|
|
75
|
+
`new Buffer(...)`; `node:` now only matches as the start of a quoted string
|
|
76
|
+
(specifier-shaped), not as a substring anywhere. A handler that imports zod (or
|
|
77
|
+
anything built on it, e.g. better-auth) and only uses APIs the compiler
|
|
78
|
+
otherwise supports now passes the capability check and builds.
|
|
79
|
+
|
|
80
|
+
Not fixed here: zod still throws an uncaught `TypeError` at runtime on
|
|
81
|
+
`.safeParse()` even for the simplest schema (`z.string()`) — a separate,
|
|
82
|
+
deeper Porffor compatibility gap this change does not touch. This fix removes
|
|
83
|
+
an incorrect early rejection; it does not make zod usable end to end.
|
|
84
|
+
|
|
3
85
|
## 0.5.0
|
|
4
86
|
|
|
5
87
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1140,6 +1140,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1140
1140
|
const headers = {};
|
|
1141
1141
|
if (r.type) headers["content-type"] = r.type;
|
|
1142
1142
|
if (r.found) headers["etag"] = '"' + r.hash + '"';
|
|
1143
|
+
// #176: these bytes came off disk already-finished (UTF-8 text or
|
|
1144
|
+
// binary, doesn't matter which) -- the reserved x-sb-raw-body header
|
|
1145
|
+
// tells the C write path to pass them through untouched instead of
|
|
1146
|
+
// re-encoding as if this were a JS string a handler built.
|
|
1147
|
+
if (r.body != null) headers["x-sb-raw-body"] = "1";
|
|
1143
1148
|
return new Response(r.body == null ? "" : r.body, { status: r.status || (r.found ? 200 : 404), headers });
|
|
1144
1149
|
},
|
|
1145
1150
|
};
|
|
@@ -1168,6 +1173,10 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1168
1173
|
});
|
|
1169
1174
|
const respHeaders = new Headers();
|
|
1170
1175
|
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
1176
|
+
// #176: wire bytes from another deployment, not a string this handler
|
|
1177
|
+
// built -- must not be re-encoded if the handler proxies it straight
|
|
1178
|
+
// through (see the assets binding for the same reasoning).
|
|
1179
|
+
if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
|
|
1171
1180
|
return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
|
|
1172
1181
|
},
|
|
1173
1182
|
};
|
|
@@ -1190,6 +1199,10 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1190
1199
|
});
|
|
1191
1200
|
const respHeaders = new Headers();
|
|
1192
1201
|
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
1202
|
+
// #176: bytes from an outbound HTTP response, not a string this handler
|
|
1203
|
+
// built -- must not be re-encoded if the handler proxies it straight
|
|
1204
|
+
// through (see the assets binding for the same reasoning).
|
|
1205
|
+
if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
|
|
1193
1206
|
return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
|
|
1194
1207
|
};
|
|
1195
1208
|
}
|
|
@@ -1248,7 +1261,10 @@ function __sbMakeDONamespace(binding, className) {
|
|
|
1248
1261
|
req = new Request(url, { method: opts.method || "GET", headers });
|
|
1249
1262
|
if (opts.body != null) req.body = String(opts.body);
|
|
1250
1263
|
}
|
|
1251
|
-
|
|
1264
|
+
const inst = __sbGetDOInstance(className, idStr);
|
|
1265
|
+
const res = inst.fetch(req);
|
|
1266
|
+
if (inst.__sbTasks.length || (res && __sbIsFn(res.then))) return __sbFetchWithDrain(res, inst.__sbTasks);
|
|
1267
|
+
return res;
|
|
1252
1268
|
},
|
|
1253
1269
|
};
|
|
1254
1270
|
},
|
|
@@ -1267,6 +1283,11 @@ function __sbGetDOInstance(cls, id) {
|
|
|
1267
1283
|
const cacheKey = cls + " " + id;
|
|
1268
1284
|
let inst = __sbDOInstances[cacheKey];
|
|
1269
1285
|
if (!inst) {
|
|
1286
|
+
// #57 — was a no-op: a DO calling `state.waitUntil(p)` had `p` vanish with
|
|
1287
|
+
// no error. The instance outlives any one call (cached in
|
|
1288
|
+
// `__sbDOInstances`), so the queue lives on `state` too and is drained by
|
|
1289
|
+
// whichever call site invoked `fetch`/`alarm` this turn.
|
|
1290
|
+
const __sbTasks = [];
|
|
1270
1291
|
const state = {
|
|
1271
1292
|
id: {
|
|
1272
1293
|
toString() {
|
|
@@ -1277,9 +1298,12 @@ function __sbGetDOInstance(cls, id) {
|
|
|
1277
1298
|
blockConcurrencyWhile(fn) {
|
|
1278
1299
|
return fn();
|
|
1279
1300
|
},
|
|
1280
|
-
waitUntil() {
|
|
1301
|
+
waitUntil(p) {
|
|
1302
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1303
|
+
},
|
|
1281
1304
|
};
|
|
1282
1305
|
inst = new Ctor(state, globalThis.env);
|
|
1306
|
+
inst.__sbTasks = __sbTasks;
|
|
1283
1307
|
__sbDOInstances[cacheKey] = inst;
|
|
1284
1308
|
}
|
|
1285
1309
|
return inst;
|
|
@@ -1488,6 +1512,71 @@ function __sbClientIp(request) {
|
|
|
1488
1512
|
return peer;
|
|
1489
1513
|
}
|
|
1490
1514
|
|
|
1515
|
+
// #57 — ctx.waitUntil: work that must run before the turn completes but must
|
|
1516
|
+
// not block the response the handler already built. `waitUntil(p)` just
|
|
1517
|
+
// records `p`; nothing awaits it until the handler itself has returned.
|
|
1518
|
+
//
|
|
1519
|
+
// The wall-clock cap #25 was meant to gate this on turned out not to apply:
|
|
1520
|
+
// #25 was per-tenant abuse control (rate limits, cgroup isolation against a
|
|
1521
|
+
// hostile *other* caller) and was closed as not-planned — this deployment
|
|
1522
|
+
// model is single-admin, nobody else's handler to isolate from. A drain that
|
|
1523
|
+
// never lets go of the worker slot is a local bug either way, so it gets its
|
|
1524
|
+
// own timeout rather than waiting on that. Kept under the edge's own
|
|
1525
|
+
// `SPROUTBOAT_REQUEST_TIMEOUT_MS` (30s default, services/edge/src/main.ts) so
|
|
1526
|
+
// the sprout still gets to answer before the edge gives up on it.
|
|
1527
|
+
//
|
|
1528
|
+
// ponytail: drained sequentially, one timeout for the whole batch (not one
|
|
1529
|
+
// per task — N tasks should not cost N timeouts). A task still running past
|
|
1530
|
+
// the cap keeps running; nothing here can cancel a promise it didn't create.
|
|
1531
|
+
// No Promise.all (unproven in this runtime, sequential is fine at this
|
|
1532
|
+
// volume). A rejected task is swallowed rather than failing the response:
|
|
1533
|
+
// dropping a background task's error beats dropping the response it doesn't
|
|
1534
|
+
// own.
|
|
1535
|
+
const __SB_WAITUNTIL_TIMEOUT_MS = 25000;
|
|
1536
|
+
|
|
1537
|
+
function __sbTimeout(ms) {
|
|
1538
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async function __sbDrainWaitUntil(tasks) {
|
|
1542
|
+
if (!tasks.length) return;
|
|
1543
|
+
const pending = tasks.splice(0, tasks.length);
|
|
1544
|
+
await Promise.race([__sbDrainAll(pending), __sbTimeout(__SB_WAITUNTIL_TIMEOUT_MS)]);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
async function __sbDrainAll(pending) {
|
|
1548
|
+
for (let i = 0; i < pending.length; i++) {
|
|
1549
|
+
try {
|
|
1550
|
+
await pending[i];
|
|
1551
|
+
} catch {
|
|
1552
|
+
/* a background task's rejection must not fail the response */
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Tail-called from `__sbEntry`/DO dispatch so the promise this returns is the
|
|
1558
|
+
// one *this* async function creates, not a `.then()` derived from someone
|
|
1559
|
+
// else's — see the note below on why that distinction matters here.
|
|
1560
|
+
async function __sbFetchWithDrain(res, tasks) {
|
|
1561
|
+
const r = res && __sbIsFn(res.then) ? await res : res;
|
|
1562
|
+
await __sbDrainWaitUntil(tasks);
|
|
1563
|
+
return r;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Shared by alarm and scheduled: both reply with an empty 204 once their
|
|
1567
|
+
// handler (and anything it queued via `waitUntil`) has settled.
|
|
1568
|
+
async function __sb204WithDrain(res, tasks) {
|
|
1569
|
+
if (res && __sbIsFn(res.then)) await res;
|
|
1570
|
+
await __sbDrainWaitUntil(tasks);
|
|
1571
|
+
return new Response("", { status: 204 });
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async function __sbQueueWithDrain(result, tasks) {
|
|
1575
|
+
const r = result && __sbIsFn(result.then) ? await result : result;
|
|
1576
|
+
await __sbDrainWaitUntil(tasks);
|
|
1577
|
+
return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1491
1580
|
globalThis.__sbEntry = function (handlers, request) {
|
|
1492
1581
|
const trigger = request.headers.get("x-sb-trigger");
|
|
1493
1582
|
if (!trigger) {
|
|
@@ -1506,8 +1595,16 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1506
1595
|
// tag on hangs the request forever. cpuMs is documented as absent for
|
|
1507
1596
|
// async handlers (see LogEvent in services/edge) — that is this.
|
|
1508
1597
|
const __t0 = __sbCpuMs();
|
|
1509
|
-
const
|
|
1510
|
-
|
|
1598
|
+
const __sbTasks = [];
|
|
1599
|
+
const ctx = {
|
|
1600
|
+
waitUntil(p) {
|
|
1601
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
const __res = handlers.fetch(request, ctx);
|
|
1605
|
+
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
1606
|
+
// own return, which is why it's tail-called rather than chained on here.
|
|
1607
|
+
if (__sbTasks.length || (__res && __sbIsFn(__res.then))) return __sbFetchWithDrain(__res, __sbTasks);
|
|
1511
1608
|
return __sbTagCpu(__res, __t0);
|
|
1512
1609
|
}
|
|
1513
1610
|
if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
|
|
@@ -1515,21 +1612,50 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1515
1612
|
if (trigger === "scheduled") {
|
|
1516
1613
|
if (!__sbIsFn(handlers.scheduled)) return new Response("no scheduled handler", { status: 404 });
|
|
1517
1614
|
const body = __sbReadJson(request);
|
|
1518
|
-
|
|
1615
|
+
// #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
|
|
1616
|
+
// before this: an async `scheduled()`'s own promise was dropped the
|
|
1617
|
+
// moment 204 went out, same bug alarm() had.
|
|
1618
|
+
const __sbTasks = [];
|
|
1619
|
+
const ctx = {
|
|
1620
|
+
waitUntil(p) {
|
|
1621
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1622
|
+
},
|
|
1623
|
+
};
|
|
1624
|
+
const __sres = handlers.scheduled(
|
|
1625
|
+
{ cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
|
|
1626
|
+
ctx,
|
|
1627
|
+
);
|
|
1628
|
+
if (__sbTasks.length || (__sres && __sbIsFn(__sres.then))) return __sb204WithDrain(__sres, __sbTasks);
|
|
1519
1629
|
return new Response("", { status: 204 });
|
|
1520
1630
|
}
|
|
1521
1631
|
|
|
1522
1632
|
if (trigger === "queue") {
|
|
1523
1633
|
if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
|
|
1524
|
-
|
|
1525
|
-
|
|
1634
|
+
// #171 — same ctx.waitUntil shape. `__sbRunQueueBatch` was also
|
|
1635
|
+
// fire-and-forget for an async `queue()`: it computed the default
|
|
1636
|
+
// ack/retry and answered the broker before the handler's own awaits ran,
|
|
1637
|
+
// so any ack()/retry() past the first `await` never made it into the
|
|
1638
|
+
// response. It now awaits the handler itself when it returns a promise.
|
|
1639
|
+
const __sbTasks = [];
|
|
1640
|
+
const ctx = {
|
|
1641
|
+
waitUntil(p) {
|
|
1642
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1643
|
+
},
|
|
1644
|
+
};
|
|
1645
|
+
// Always a promise now (`__sbRunQueueBatch` is async, to await the
|
|
1646
|
+
// handler above), so always tail-call the drain wrapper — same
|
|
1647
|
+
// promise-identity reasoning as `__sbFetchWithDrain`.
|
|
1648
|
+
return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
|
|
1526
1649
|
}
|
|
1527
1650
|
|
|
1528
1651
|
if (trigger === "alarm") {
|
|
1529
1652
|
const body = __sbReadJson(request);
|
|
1530
1653
|
const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
|
|
1531
1654
|
if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
|
|
1532
|
-
inst.alarm();
|
|
1655
|
+
const __ares = inst.alarm();
|
|
1656
|
+
// Was fire-and-forget before: an async `alarm()` and any `state.waitUntil()`
|
|
1657
|
+
// it queued were both dropped the moment 204 went out. Await both.
|
|
1658
|
+
if (inst.__sbTasks.length || (__ares && __sbIsFn(__ares.then))) return __sb204WithDrain(__ares, inst.__sbTasks);
|
|
1533
1659
|
return new Response("", { status: 204 });
|
|
1534
1660
|
}
|
|
1535
1661
|
|
|
@@ -1543,7 +1669,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1543
1669
|
* (deployed, and the phase-0 standalone launcher), or straight from the local
|
|
1544
1670
|
* timer in an embedded binary that has no broker to be delivered from.
|
|
1545
1671
|
*/
|
|
1546
|
-
function __sbRunQueueBatch(handlers, body) {
|
|
1672
|
+
async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
1547
1673
|
const acked = [];
|
|
1548
1674
|
const retried = [];
|
|
1549
1675
|
const raw = body.messages || [];
|
|
@@ -1574,7 +1700,11 @@ function __sbRunQueueBatch(handlers, body) {
|
|
|
1574
1700
|
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
1575
1701
|
},
|
|
1576
1702
|
};
|
|
1577
|
-
handlers.queue(batch);
|
|
1703
|
+
const __qres = handlers.queue(batch, ctx);
|
|
1704
|
+
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
1705
|
+
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
1706
|
+
// past the first one never counted.
|
|
1707
|
+
if (__qres && __sbIsFn(__qres.then)) await __qres;
|
|
1578
1708
|
// default: any message neither acked nor retried is treated as acked
|
|
1579
1709
|
for (let i = 0; i < messages.length; i++) {
|
|
1580
1710
|
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
package/src/source.ts
CHANGED
|
@@ -11,7 +11,20 @@ const alwaysForbidden: Array<[RegExp, string]> = [
|
|
|
11
11
|
[/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
|
|
12
12
|
[/\brequire\s*\(/, "CommonJS require is not supported"],
|
|
13
13
|
[/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
|
|
14
|
-
|
|
14
|
+
// baronunread/sproutboat#132 — a bare identifier match flags a *local*
|
|
15
|
+
// `function process()` as readily as the global: zod v4 declares exactly
|
|
16
|
+
// that (its internal `process(schema, ctx)`), so importing zod alone used
|
|
17
|
+
// to fail this check pointing at code the handler author never wrote. A
|
|
18
|
+
// Node/Bun/Deno API is always reached through a member access or `new`;
|
|
19
|
+
// requiring that shape lets a local binding of the same name through.
|
|
20
|
+
// No whitespace is allowed around the `.`: real member access never has
|
|
21
|
+
// any (`process.env`, always contiguous), while prose mentioning the word
|
|
22
|
+
// does (a bundled comment ending "...unique to this process. The id...").
|
|
23
|
+
[/\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/, "Node, Bun, and Deno APIs are not supported"],
|
|
24
|
+
// `node:` only means something as an import specifier; a bare substring
|
|
25
|
+
// match would also flag it appearing in an ordinary string a dependency
|
|
26
|
+
// happens to construct (a doc link, a log message).
|
|
27
|
+
[/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
|
|
15
28
|
// Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
|
|
16
29
|
// trapped property reads back as `undefined`, with no throw. Rejecting it
|
|
17
30
|
// here is the difference between a build error and a 502 nobody can explain.
|
|
@@ -1668,7 +1668,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
1668
1668
|
const store = __sbStore();
|
|
1669
1669
|
|
|
1670
1670
|
if (crons.length > 0 && __sbIsFn(handlers.scheduled)) {
|
|
1671
|
-
setInterval(function () {
|
|
1671
|
+
setInterval(async function () {
|
|
1672
1672
|
const now = new Date();
|
|
1673
1673
|
const stamp =
|
|
1674
1674
|
now.getUTCFullYear() +
|
|
@@ -1684,14 +1684,24 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
1684
1684
|
__sbLastCronTick = stamp;
|
|
1685
1685
|
for (let i = 0; i < crons.length; i++) {
|
|
1686
1686
|
if (__sbCronMatches(crons[i], now)) {
|
|
1687
|
-
|
|
1687
|
+
// #171 — ctx.waitUntil, and stop dropping the handler's own promise
|
|
1688
|
+
// (was fire-and-forget, same bug the broker-dispatched path had).
|
|
1689
|
+
const __sbTasks = [];
|
|
1690
|
+
const ctx = {
|
|
1691
|
+
waitUntil(p) {
|
|
1692
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1693
|
+
},
|
|
1694
|
+
};
|
|
1695
|
+
const res = handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} }, ctx);
|
|
1696
|
+
if (res && __sbIsFn(res.then)) await res;
|
|
1697
|
+
if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
|
|
1688
1698
|
}
|
|
1689
1699
|
}
|
|
1690
1700
|
}, 15000);
|
|
1691
1701
|
}
|
|
1692
1702
|
|
|
1693
1703
|
if (queues.length > 0 && __sbIsFn(handlers.queue)) {
|
|
1694
|
-
setInterval(function () {
|
|
1704
|
+
setInterval(async function () {
|
|
1695
1705
|
__sbEnsureSchema();
|
|
1696
1706
|
const now = Date.now();
|
|
1697
1707
|
for (let q = 0; q < queues.length; q++) {
|
|
@@ -1709,7 +1719,19 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
1709
1719
|
__sbSql(store, "UPDATE mq SET visible_at = ? WHERE id = ?", [now + 30000, row[0]]);
|
|
1710
1720
|
messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
|
|
1711
1721
|
}
|
|
1712
|
-
|
|
1722
|
+
// #171 — same ctx.waitUntil as the broker-delivered path, and the same
|
|
1723
|
+
// fix for the handler's own promise: `__sbRunQueueBatch` used to be
|
|
1724
|
+
// called fire-and-forget here too, so an async `queue()`'s ack/retry
|
|
1725
|
+
// calls made after its first `await` never reached these SQL writes.
|
|
1726
|
+
const __sbTasks = [];
|
|
1727
|
+
const ctx = {
|
|
1728
|
+
waitUntil(p) {
|
|
1729
|
+
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1730
|
+
},
|
|
1731
|
+
};
|
|
1732
|
+
let result = __sbRunQueueBatch(handlers, { queue: name, messages }, ctx);
|
|
1733
|
+
if (result && __sbIsFn(result.then)) result = await result;
|
|
1734
|
+
if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
|
|
1713
1735
|
for (let i = 0; i < result.ack.length; i++) {
|
|
1714
1736
|
__sbSql(store, "DELETE FROM mq WHERE id = ?", [result.ack[i]]);
|
|
1715
1737
|
}
|
|
@@ -1725,7 +1747,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
1725
1747
|
}
|
|
1726
1748
|
|
|
1727
1749
|
if (dos.length > 0) {
|
|
1728
|
-
setInterval(function () {
|
|
1750
|
+
setInterval(async function () {
|
|
1729
1751
|
__sbEnsureSchema();
|
|
1730
1752
|
const now = Date.now();
|
|
1731
1753
|
const due = __sbSql(store, "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10", [
|
|
@@ -1738,7 +1760,14 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
1738
1760
|
// afterwards would erase it. Same rule as the broker (#125).
|
|
1739
1761
|
__sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [cls, id]);
|
|
1740
1762
|
const instance = __sbGetDOInstance(cls, id);
|
|
1741
|
-
if (__sbIsFn(instance.alarm))
|
|
1763
|
+
if (__sbIsFn(instance.alarm)) {
|
|
1764
|
+
// #57 gap: this local path bypasses `__sbEntry`'s alarm dispatch
|
|
1765
|
+
// entirely, so it kept the old fire-and-forget behaviour after that
|
|
1766
|
+
// fix landed. Same treatment: await alarm() and drain its waitUntil.
|
|
1767
|
+
const res = instance.alarm();
|
|
1768
|
+
if (res && __sbIsFn(res.then)) await res;
|
|
1769
|
+
if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
|
|
1770
|
+
}
|
|
1742
1771
|
}
|
|
1743
1772
|
}, 500);
|
|
1744
1773
|
}
|