@sproutboat/runtime 0.34.5 → 0.36.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 +23 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +120 -16
- package/src/native-fetch-prelude.js +313 -86
- package/src/transport-embedded.js +128 -31
- package/test/concurrent-driver.ts +291 -0
- package/test/fixtures/als-app.js +136 -0
- package/test/fixtures/concurrent-app.js +229 -0
- package/test/native-async-local-storage.ts +97 -0
- package/test/native-concurrent-requests.ts +87 -0
- package/test/native-cpu-requests.ts +156 -0
- package/test/native-d1.ts +36 -6
- package/test/native-headers.ts +182 -0
- package/test/native-sqlite-lock.ts +94 -0
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
// Per-request CPU (design 02): x-sb-cpu-ms counts the thread CPU time of the slices in which one
|
|
9
|
+
// request ran. A spinner interleaved with requests that wait is charged for its own work, the
|
|
10
|
+
// waiters for theirs, whatever order they run in. An async handler reports it from compatibility
|
|
11
|
+
// date 2026-11-02; before that only a handler that answers synchronously does.
|
|
12
|
+
const handler = `export default {async fetch(request) {
|
|
13
|
+
const url = new URL(request.url);
|
|
14
|
+
const ms = Number(url.searchParams.get("ms") || 0);
|
|
15
|
+
const sleep = (n) => new Promise((resolve) => setTimeout(resolve, n));
|
|
16
|
+
// Work, not wall time: a loaded machine deschedules the process, and CPU time must not follow wall time.
|
|
17
|
+
const spin = (n) => { let x = 1; for (let i = 0; i < n * 40000; i++) x = (x * 1.0000001 + i) % 1000003; return x; };
|
|
18
|
+
if (url.pathname === "/spin") {
|
|
19
|
+
await sleep(5);
|
|
20
|
+
spin(ms / 2);
|
|
21
|
+
await sleep(5);
|
|
22
|
+
spin(ms / 2);
|
|
23
|
+
return new Response("spun");
|
|
24
|
+
}
|
|
25
|
+
if (url.pathname === "/wait") {
|
|
26
|
+
await sleep(ms);
|
|
27
|
+
return new Response("waited");
|
|
28
|
+
}
|
|
29
|
+
if (url.pathname === "/sync") {
|
|
30
|
+
spin(ms);
|
|
31
|
+
return new Response("sync");
|
|
32
|
+
}
|
|
33
|
+
return new Response("none", { status: 404 });
|
|
34
|
+
}};`;
|
|
35
|
+
|
|
36
|
+
function freePort(): number {
|
|
37
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
38
|
+
const port = listener.port;
|
|
39
|
+
listener.stop(true);
|
|
40
|
+
return port;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-cpu-requests-"));
|
|
44
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
45
|
+
async function build(date: string): Promise<number> {
|
|
46
|
+
const porffor = await ensurePorffor();
|
|
47
|
+
const node = Bun.which("node");
|
|
48
|
+
if (!node) throw new Error("native cpu test needs node on PATH");
|
|
49
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
50
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
51
|
+
const generated = join(workdir, `cpu-${date}.generated.js`);
|
|
52
|
+
const binary = join(workdir, `cpu-${date}`);
|
|
53
|
+
await writeFile(
|
|
54
|
+
generated,
|
|
55
|
+
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
|
|
56
|
+
);
|
|
57
|
+
const compile = Bun.spawn(
|
|
58
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
59
|
+
{
|
|
60
|
+
cwd: workdir,
|
|
61
|
+
env: process.env,
|
|
62
|
+
stdout: "pipe",
|
|
63
|
+
stderr: "pipe",
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
67
|
+
compile.exited,
|
|
68
|
+
new Response(compile.stdout).text(),
|
|
69
|
+
new Response(compile.stderr).text(),
|
|
70
|
+
]);
|
|
71
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
72
|
+
const port = freePort();
|
|
73
|
+
const server = Bun.spawn([binary], {
|
|
74
|
+
cwd: workdir,
|
|
75
|
+
env: { ...process.env, PORT: String(port) },
|
|
76
|
+
stdout: "ignore",
|
|
77
|
+
stderr: "inherit",
|
|
78
|
+
});
|
|
79
|
+
servers.push(server);
|
|
80
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
await fetch(`http://127.0.0.1:${port}/none`, { signal: AbortSignal.timeout(500) });
|
|
83
|
+
return port;
|
|
84
|
+
} catch {
|
|
85
|
+
if (server.exitCode !== null) break;
|
|
86
|
+
await Bun.sleep(100);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new Error("compiled handler did not start");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const port = await build("2026-11-02");
|
|
94
|
+
const cpu = async (path: string) => {
|
|
95
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
96
|
+
await response.text();
|
|
97
|
+
const header = response.headers.get("x-sb-cpu-ms");
|
|
98
|
+
assert(header !== null, `${path} has no x-sb-cpu-ms`);
|
|
99
|
+
return Number(header);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const alone = await cpu("/spin?ms=200");
|
|
103
|
+
assert(alone > 10, `a spinner alone reports only ${alone} ms`);
|
|
104
|
+
const sync = await cpu("/sync?ms=200");
|
|
105
|
+
assert(
|
|
106
|
+
sync > alone * 0.5 && sync < alone * 2,
|
|
107
|
+
`a synchronous spinner of the same work reports ${sync} ms, the async one ${alone}`,
|
|
108
|
+
);
|
|
109
|
+
const idle = await cpu("/wait?ms=50");
|
|
110
|
+
assert(idle < alone / 4 && idle < 30, `a request that only waits reports ${idle} ms (a spinner: ${alone})`);
|
|
111
|
+
|
|
112
|
+
// Together: the spinner next to eight waiters, repeated so the slices interleave differently.
|
|
113
|
+
for (let round = 0; round < 3; round++) {
|
|
114
|
+
const results = await Promise.all([
|
|
115
|
+
cpu("/spin?ms=200"),
|
|
116
|
+
...Array.from({ length: 8 }, (_, i) => cpu(`/wait?ms=${150 + i * 20}`)),
|
|
117
|
+
]);
|
|
118
|
+
const [spinner, ...waiters] = results;
|
|
119
|
+
assert(
|
|
120
|
+
spinner > alone * 0.5 && spinner < alone * 2,
|
|
121
|
+
`round ${round}: the spinner reports ${spinner} ms, alone it did ${alone}`,
|
|
122
|
+
);
|
|
123
|
+
for (const waiter of waiters)
|
|
124
|
+
assert(
|
|
125
|
+
waiter < alone / 4 && waiter < 60,
|
|
126
|
+
`round ${round}: a waiter was charged ${waiter} ms (the spinner: ${spinner})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// Two spinners at once are each charged their own work, not both.
|
|
130
|
+
const [a, b] = await Promise.all([cpu("/spin?ms=200"), cpu("/spin?ms=200")]);
|
|
131
|
+
assert(
|
|
132
|
+
a > alone * 0.5 && a < alone * 1.6 && b > alone * 0.5 && b < alone * 1.6,
|
|
133
|
+
`two spinners report ${a} and ${b} ms, one alone ${alone}`,
|
|
134
|
+
);
|
|
135
|
+
console.log(`per-request CPU holds under interleaving (a spinner ${alone.toFixed(0)} ms, waiters a few ms)`);
|
|
136
|
+
|
|
137
|
+
// Before 2026-11-02 an async handler still reports nothing.
|
|
138
|
+
const oldPort = await build("2026-11-01");
|
|
139
|
+
const old = await fetch(`http://127.0.0.1:${oldPort}/spin?ms=20`);
|
|
140
|
+
await old.text();
|
|
141
|
+
assert.equal(old.headers.get("x-sb-cpu-ms"), null, "an async handler is stamped before 2026-11-02");
|
|
142
|
+
const oldSync = await fetch(`http://127.0.0.1:${oldPort}/sync?ms=20`);
|
|
143
|
+
await oldSync.text();
|
|
144
|
+
assert.equal(
|
|
145
|
+
oldSync.headers.get("x-sb-cpu-ms"),
|
|
146
|
+
null,
|
|
147
|
+
"this handler is async, so it is not stamped before 2026-11-02",
|
|
148
|
+
);
|
|
149
|
+
console.log("before 2026-11-02 an async handler is not stamped");
|
|
150
|
+
} finally {
|
|
151
|
+
for (const server of servers) {
|
|
152
|
+
server.kill(9);
|
|
153
|
+
await server.exited;
|
|
154
|
+
}
|
|
155
|
+
await rm(workdir, { recursive: true, force: true });
|
|
156
|
+
}
|
package/test/native-d1.ts
CHANGED
|
@@ -56,12 +56,18 @@ int main(void) {
|
|
|
56
56
|
if (!strncmp(line, ":cells ", 7)) {
|
|
57
57
|
sb_cells cs = { 0, 0, 0, 0, 0 };
|
|
58
58
|
char* head = sb_sql_run_x(line + 7, sql, params, &cs);
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// A result has no JSON header: its column names are the first cells and its counts are in cs.
|
|
60
|
+
if (head) printf("{\\"head\\":%s,\\"rows\\":[", head);
|
|
61
|
+
else {
|
|
62
|
+
printf("{\\"head\\":{\\"ok\\":true,\\"cols\\":[");
|
|
63
|
+
for (int c = 0; c < cs.hdr; c++) printf(c ? ",\\"%.*s\\"" : "\\"%.*s\\"", cs.c[c].n, cs.a + cs.c[c].off);
|
|
64
|
+
printf("],\\"changes\\":%lld,\\"rowid\\":%lld},\\"rows\\":[", cs.changes, cs.rowid);
|
|
65
|
+
}
|
|
66
|
+
size_t nrow = cs.ncol ? (cs.n - (size_t)cs.hdr) / (size_t)cs.ncol : 0;
|
|
61
67
|
for (size_t r = 0; r < nrow; r++) {
|
|
62
68
|
printf(r ? ",[" : "[");
|
|
63
69
|
for (int c = 0; c < cs.ncol; c++) {
|
|
64
|
-
sb_cell* x = &cs.c[r * (size_t)cs.ncol + (size_t)c];
|
|
70
|
+
sb_cell* x = &cs.c[(size_t)cs.hdr + r * (size_t)cs.ncol + (size_t)c];
|
|
65
71
|
if (c) printf(",");
|
|
66
72
|
if (x->t == 0) printf("null");
|
|
67
73
|
else if (x->t == 1) printf("%.17g", x->d);
|
|
@@ -162,6 +168,7 @@ int main(void) {
|
|
|
162
168
|
assert.deepEqual(cells.rows, json.rows, `cells equal the JSON rows for ${t}`);
|
|
163
169
|
assert.deepEqual(cells.head.cols, json.cols);
|
|
164
170
|
assert.equal(cells.head.changes, json.changes);
|
|
171
|
+
assert.equal(cells.head.rowid, json.rowid);
|
|
165
172
|
}
|
|
166
173
|
const empty = cellRun("SELECT a FROM t WHERE 0");
|
|
167
174
|
assert.deepEqual(empty.cells.rows, []);
|
|
@@ -252,15 +259,16 @@ int main(void) {
|
|
|
252
259
|
};
|
|
253
260
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
254
261
|
const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
|
|
255
|
-
// __sbSqlRowsRaw as the transport's C block gives it: [header JSON
|
|
262
|
+
// __sbSqlRowsRaw as the transport's C block gives it: an error is [header JSON], a result is
|
|
263
|
+
// ["", rows, columns, changes, rowid], BLOBs as byte arrays.
|
|
256
264
|
const rowsOf =
|
|
257
265
|
(raw: (path: string, text: string, params: string) => string) => (path: string, text: string, params: string) => {
|
|
258
266
|
const r = JSON.parse(raw(":cells " + path, text, params));
|
|
259
|
-
if (r.head.ok === false) return [JSON.stringify(r.head)
|
|
267
|
+
if (r.head.ok === false) return [JSON.stringify(r.head)];
|
|
260
268
|
const rows = r.rows.map((row: unknown[]) =>
|
|
261
269
|
row.map((c) => (c instanceof Object && "$blob" in c ? [...Buffer.from(String(c.$blob), "hex")] : c)),
|
|
262
270
|
);
|
|
263
|
-
return [
|
|
271
|
+
return ["", rows, r.head.cols, r.head.changes, r.head.rowid];
|
|
264
272
|
};
|
|
265
273
|
const ops = new Function(
|
|
266
274
|
"__sbSqlRaw",
|
|
@@ -279,6 +287,7 @@ int main(void) {
|
|
|
279
287
|
"__sbDir",
|
|
280
288
|
"__sbDirBytesRaw",
|
|
281
289
|
"__sbD1DbRaw",
|
|
290
|
+
"__sbD1Captured_",
|
|
282
291
|
"__sbSql",
|
|
283
292
|
"__sbSqlRaw",
|
|
284
293
|
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull, expire() { __sbQuotaAt = -1; } };`,
|
|
@@ -287,6 +296,7 @@ int main(void) {
|
|
|
287
296
|
() => dir,
|
|
288
297
|
(d: string) => String(JSON.parse(sqlRaw(":bytes " + d, "", "")).n),
|
|
289
298
|
(path: string, what: string) => String(JSON.parse(sqlRaw(":db " + path, what, "")).n),
|
|
299
|
+
(path: string) => JSON.parse(sqlRaw(":db " + path, "c", "")).n,
|
|
290
300
|
sql,
|
|
291
301
|
sqlRaw,
|
|
292
302
|
);
|
|
@@ -334,6 +344,7 @@ int main(void) {
|
|
|
334
344
|
"__sbDir",
|
|
335
345
|
"__sbDirBytesRaw",
|
|
336
346
|
"__sbD1DbRaw",
|
|
347
|
+
"__sbD1Captured_",
|
|
337
348
|
"__sbSql",
|
|
338
349
|
"__sbSqlRaw",
|
|
339
350
|
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull };`,
|
|
@@ -342,6 +353,7 @@ int main(void) {
|
|
|
342
353
|
() => dir,
|
|
343
354
|
(d: string) => String(JSON.parse(raw(":bytes " + d, "", "")).n),
|
|
344
355
|
(path: string, what: string) => String(JSON.parse(raw(":db " + path, what, "")).n),
|
|
356
|
+
(path: string) => JSON.parse(raw(":db " + path, "c", "")).n,
|
|
345
357
|
run,
|
|
346
358
|
raw,
|
|
347
359
|
);
|
|
@@ -629,6 +641,24 @@ int main(void) {
|
|
|
629
641
|
assert.match(failedOver.error, /^LTX capture failed/);
|
|
630
642
|
third.close();
|
|
631
643
|
await third.exited;
|
|
644
|
+
// Locking mode. This driver is the transport on its own, as sproutd and the broker embed it (no
|
|
645
|
+
// standalone shim): unset it must stay shared, so those hosts behave as before. SB_SQLITE_EXCLUSIVE=1 is
|
|
646
|
+
// exclusive for a plain database; anything else, 0 included, is shared. A database under LTX replication
|
|
647
|
+
// is never exclusive.
|
|
648
|
+
const lockingMode = (env: Record<string, string>, path: string) => {
|
|
649
|
+
const e = Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "SB_SQLITE_EXCLUSIVE"));
|
|
650
|
+
const p = Bun.spawnSync([binary], {
|
|
651
|
+
stdin: Buffer.from(`${path}\tPRAGMA locking_mode\t[]\n`),
|
|
652
|
+
env: { ...e, ...env },
|
|
653
|
+
});
|
|
654
|
+
assert.equal(p.exitCode, 0, p.stderr.toString());
|
|
655
|
+
return JSON.parse(p.stdout.toString().trim()).rows[0][0];
|
|
656
|
+
};
|
|
657
|
+
const lockDb = join(work, "data/d1/LOCK.sqlite");
|
|
658
|
+
assert.equal(lockingMode({}, lockDb), "normal", "the bare transport keeps shared locking unless asked");
|
|
659
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "0" }, lockDb), "normal");
|
|
660
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "1" }, lockDb), "exclusive");
|
|
661
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "1" }, join(work, "data/d1/LOCK2.sqlite")), "exclusive");
|
|
632
662
|
console.log(
|
|
633
663
|
"native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
|
|
634
664
|
);
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
// Headers validates a name and trims a value in C before it falls back to the regular expression and
|
|
9
|
+
// the JS loops. Every kind of name and value must come out as the Fetch spec says: valid names
|
|
10
|
+
// lowercased, invalid ones a TypeError, values trimmed, forbidden characters refused, and a name
|
|
11
|
+
// that is not a plain bytestring (a wide character) still judged by the expression.
|
|
12
|
+
const handler = `export default {fetch(request) {
|
|
13
|
+
const out = {};
|
|
14
|
+
const names = ["a", "Content-Type", "X-Sb-Test", "x_y", "a.b", "a~b", "a|b", "a^b", "a+b", "a*b", "a!b", "a#b", "a$b", "a%b", "a&b", "a'b", "a-b", "9", "A9z", "",
|
|
15
|
+
"a b", "a:b", "a/b", "a(b", "a@b", "a[b", "a\\u0000b", "a\\nb", "\\u00e9", "caf\\u00e9", "a\\u0100b", "a,b", "a;b", "a=b", "a?b", "a\\"b", "a\\\\b", "a<b", "a>b", "a{b", "a}b"];
|
|
16
|
+
const h = new Headers();
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
try { h.append(name, "v"); out["n:" + name] = h.has(name.toLowerCase()) ? "ok" : "stored?"; }
|
|
19
|
+
catch (e) { out["n:" + name] = e instanceof TypeError ? "TypeError" : String(e); }
|
|
20
|
+
}
|
|
21
|
+
const values = ["plain", "", " lead", "trail ", "\\t both \\t", "\\r\\nx", "a\\u0000b", "a\\nb", "a\\rb", "in ner", "caf\\u00e9", "\\u4e2d", " \\u4e2d ", "x\\ty"];
|
|
22
|
+
for (const value of values) {
|
|
23
|
+
const g = new Headers();
|
|
24
|
+
try { g.set("x", value); out["v:" + JSON.stringify(value)] = g.get("x"); }
|
|
25
|
+
catch (e) { out["v:" + JSON.stringify(value)] = e instanceof TypeError ? "TypeError" : String(e); }
|
|
26
|
+
}
|
|
27
|
+
const dup = new Headers();
|
|
28
|
+
dup.append("Accept", "a"); dup.append("accept", "b");
|
|
29
|
+
out.dup = dup.get("ACCEPT");
|
|
30
|
+
out.fromObject = new Headers({ "Content-Type": "text/plain", "X-A": " 1 " }).get("x-a");
|
|
31
|
+
out.getCase = new Headers({ "x-a": "1" }).get("X-A");
|
|
32
|
+
out.trigger = request.headers.get("X-Sb-Trigger");
|
|
33
|
+
out.custom = request.headers.get("X-Custom");
|
|
34
|
+
return new Response(JSON.stringify(out), { headers: { "Content-Type": "application/json", "X-Case": " kept " } });
|
|
35
|
+
}};`;
|
|
36
|
+
|
|
37
|
+
function freePort(): number {
|
|
38
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
39
|
+
const port = listener.port;
|
|
40
|
+
listener.stop(true);
|
|
41
|
+
return port;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What the Fetch spec (and workerd) say, from the names and values above.
|
|
45
|
+
function expectedName(name: string): string {
|
|
46
|
+
const valid = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
|
47
|
+
return valid ? "ok" : "TypeError";
|
|
48
|
+
}
|
|
49
|
+
function expectedValue(value: string): string {
|
|
50
|
+
const trimmed = value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
|
|
51
|
+
return /[\0\n\r]/.test(trimmed) ? "TypeError" : trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-headers-"));
|
|
55
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
56
|
+
try {
|
|
57
|
+
const porffor = await ensurePorffor();
|
|
58
|
+
const node = Bun.which("node");
|
|
59
|
+
if (!node) throw new Error("native headers test needs node on PATH");
|
|
60
|
+
|
|
61
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
62
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
63
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
64
|
+
const generated = join(workdir, "headers.generated.js");
|
|
65
|
+
const binary = join(workdir, "headers-native");
|
|
66
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
67
|
+
await rm(binary, { force: true });
|
|
68
|
+
|
|
69
|
+
const compile = Bun.spawn(
|
|
70
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
71
|
+
{
|
|
72
|
+
cwd: workdir,
|
|
73
|
+
env: process.env,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
79
|
+
compile.exited,
|
|
80
|
+
new Response(compile.stdout).text(),
|
|
81
|
+
new Response(compile.stderr).text(),
|
|
82
|
+
]);
|
|
83
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
84
|
+
|
|
85
|
+
const port = freePort();
|
|
86
|
+
server = Bun.spawn([binary], {
|
|
87
|
+
cwd: workdir,
|
|
88
|
+
env: { ...process.env, PORT: String(port) },
|
|
89
|
+
stdout: "ignore",
|
|
90
|
+
stderr: "inherit",
|
|
91
|
+
});
|
|
92
|
+
let response: Response | undefined;
|
|
93
|
+
for (let attempt = 0; attempt < 50 && !response; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
96
|
+
headers: { "X-Custom": "custom" },
|
|
97
|
+
signal: AbortSignal.timeout(2000),
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
if (server.exitCode !== null) break;
|
|
101
|
+
await Bun.sleep(100);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
assert(response, "compiled handler did not start");
|
|
105
|
+
// SAFETY: the handler above answers a flat JSON object of strings (and null), read key by key below.
|
|
106
|
+
const out = (await response.json()) as Record<string, string>;
|
|
107
|
+
const names = [
|
|
108
|
+
"a",
|
|
109
|
+
"Content-Type",
|
|
110
|
+
"X-Sb-Test",
|
|
111
|
+
"x_y",
|
|
112
|
+
"a.b",
|
|
113
|
+
"a~b",
|
|
114
|
+
"a|b",
|
|
115
|
+
"a^b",
|
|
116
|
+
"a+b",
|
|
117
|
+
"a*b",
|
|
118
|
+
"a!b",
|
|
119
|
+
"a#b",
|
|
120
|
+
"a$b",
|
|
121
|
+
"a%b",
|
|
122
|
+
"a&b",
|
|
123
|
+
"a'b",
|
|
124
|
+
"a-b",
|
|
125
|
+
"9",
|
|
126
|
+
"A9z",
|
|
127
|
+
"",
|
|
128
|
+
"a b",
|
|
129
|
+
"a:b",
|
|
130
|
+
"a/b",
|
|
131
|
+
"a(b",
|
|
132
|
+
"a@b",
|
|
133
|
+
"a[b",
|
|
134
|
+
"a\u0000b",
|
|
135
|
+
"a\nb",
|
|
136
|
+
"é",
|
|
137
|
+
"café",
|
|
138
|
+
"aĀb",
|
|
139
|
+
"a,b",
|
|
140
|
+
"a;b",
|
|
141
|
+
"a=b",
|
|
142
|
+
"a?b",
|
|
143
|
+
'a"b',
|
|
144
|
+
"a\\b",
|
|
145
|
+
"a<b",
|
|
146
|
+
"a>b",
|
|
147
|
+
"a{b",
|
|
148
|
+
"a}b",
|
|
149
|
+
];
|
|
150
|
+
for (const name of names) assert.equal(out["n:" + name], expectedName(name), `header name ${JSON.stringify(name)}`);
|
|
151
|
+
const values = [
|
|
152
|
+
"plain",
|
|
153
|
+
"",
|
|
154
|
+
" lead",
|
|
155
|
+
"trail ",
|
|
156
|
+
"\t both \t",
|
|
157
|
+
"\r\nx",
|
|
158
|
+
"a\u0000b",
|
|
159
|
+
"a\nb",
|
|
160
|
+
"a\rb",
|
|
161
|
+
"in ner",
|
|
162
|
+
"café",
|
|
163
|
+
"中",
|
|
164
|
+
" 中 ",
|
|
165
|
+
"x\ty",
|
|
166
|
+
];
|
|
167
|
+
for (const value of values)
|
|
168
|
+
assert.equal(out["v:" + JSON.stringify(value)], expectedValue(value), `header value ${JSON.stringify(value)}`);
|
|
169
|
+
assert.equal(out.dup, "a, b");
|
|
170
|
+
assert.equal(out.fromObject, "1");
|
|
171
|
+
assert.equal(out.getCase, "1");
|
|
172
|
+
assert.equal(out.trigger, null);
|
|
173
|
+
assert.equal(out.custom, "custom");
|
|
174
|
+
assert.equal(response.headers.get("x-case"), "kept");
|
|
175
|
+
console.log("Native Headers: names and values validated in C match the Fetch spec for every class of input");
|
|
176
|
+
} finally {
|
|
177
|
+
if (server) {
|
|
178
|
+
server.kill(9);
|
|
179
|
+
await server.exited;
|
|
180
|
+
}
|
|
181
|
+
await rm(workdir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// A standalone binary (the embedded transport, built by the CLI) opens its SQLite databases in
|
|
8
|
+
// EXCLUSIVE locking mode by default; SB_SQLITE_EXCLUSIVE=0 opts out and =1 is the default. The
|
|
9
|
+
// transport on its own, as sproutd and the broker embed it, stays shared unless the variable is
|
|
10
|
+
// set: native-d1.ts covers that. Needs SB_CLI, a sproutboat-cli checkout linked to this one.
|
|
11
|
+
const handler = `export default {
|
|
12
|
+
async fetch() {
|
|
13
|
+
await env.DB.prepare("CREATE TABLE IF NOT EXISTS t (v INTEGER)").run();
|
|
14
|
+
const row = await env.DB.prepare("PRAGMA locking_mode").first();
|
|
15
|
+
return Response.json({ mode: row.locking_mode });
|
|
16
|
+
},
|
|
17
|
+
};`;
|
|
18
|
+
|
|
19
|
+
const cli = process.env.SB_CLI;
|
|
20
|
+
if (!cli) {
|
|
21
|
+
console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprout)");
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function freePort(): number {
|
|
26
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
27
|
+
const port = listener.port;
|
|
28
|
+
listener.stop(true);
|
|
29
|
+
return port;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const workdir = mkdtempSync(join(tmpdir(), "sb-native-sqlite-lock-"));
|
|
33
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
34
|
+
try {
|
|
35
|
+
const project = join(workdir, "lock");
|
|
36
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
37
|
+
writeFileSync(join(project, "src/index.js"), handler);
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(project, "sproutboat.jsonc"),
|
|
40
|
+
JSON.stringify({ name: "lock", main: "src/index.js", compatibility_date: "2026-10-28", d1_databases: ["DB"] }),
|
|
41
|
+
);
|
|
42
|
+
const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
|
|
43
|
+
cwd: project,
|
|
44
|
+
stdout: "inherit",
|
|
45
|
+
stderr: "inherit",
|
|
46
|
+
});
|
|
47
|
+
assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
|
|
48
|
+
|
|
49
|
+
const run = async (label: string, setting: string | undefined, expected: string) => {
|
|
50
|
+
const data = join(workdir, `data-${label}`);
|
|
51
|
+
const base = Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "SB_SQLITE_EXCLUSIVE"));
|
|
52
|
+
const env = setting === undefined ? base : { ...base, SB_SQLITE_EXCLUSIVE: setting };
|
|
53
|
+
const port = freePort();
|
|
54
|
+
const server = Bun.spawn([join(project, "dist/lock")], {
|
|
55
|
+
cwd: project,
|
|
56
|
+
env: { ...env, SB_DATA_DIR: data, PORT: String(port) },
|
|
57
|
+
stdout: "ignore",
|
|
58
|
+
stderr: "inherit",
|
|
59
|
+
});
|
|
60
|
+
servers.push(server);
|
|
61
|
+
let response: Response | undefined;
|
|
62
|
+
for (let attempt = 0; attempt < 100 && !response; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
|
|
65
|
+
} catch {
|
|
66
|
+
if (server.exitCode !== null) break;
|
|
67
|
+
await Bun.sleep(100);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
assert(response, `${label}: the compiled handler did not start`);
|
|
71
|
+
assert.deepEqual(await response.json(), { mode: expected }, label);
|
|
72
|
+
// Another process can open the database while the binary runs only in shared mode.
|
|
73
|
+
let other = "";
|
|
74
|
+
try {
|
|
75
|
+
const db = new Database(join(data, "d1/DB.sqlite"), { readonly: true });
|
|
76
|
+
other = String(db.query("SELECT count(*) AS n FROM t").get()?.n);
|
|
77
|
+
db.close();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
other = String(error);
|
|
80
|
+
}
|
|
81
|
+
if (expected === "exclusive")
|
|
82
|
+
assert.match(other, /locked|busy/i, `${label}: a second opener is locked out (${other})`);
|
|
83
|
+
else assert.equal(other, "0", `${label}: a second opener can read`);
|
|
84
|
+
server.kill(9);
|
|
85
|
+
await server.exited;
|
|
86
|
+
};
|
|
87
|
+
await run("default", undefined, "exclusive");
|
|
88
|
+
await run("one", "1", "exclusive");
|
|
89
|
+
await run("zero", "0", "normal");
|
|
90
|
+
console.log("standalone SQLite: EXCLUSIVE by default, SB_SQLITE_EXCLUSIVE=0 opts out");
|
|
91
|
+
} finally {
|
|
92
|
+
for (const server of servers) server.kill(9);
|
|
93
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
94
|
+
}
|