@sproutboat/runtime 0.12.1 → 0.13.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 +12 -0
- package/package.json +1 -1
- package/src/ingress-utf8.test.js +41 -0
- package/src/native-fetch-prelude.js +48 -15
- package/src/source.ts +1 -1
- package/src/transport-broker.js +67 -0
- package/src/transport-embedded.js +58 -9
- package/src/workers-handler.test.js +59 -0
- package/test/native-utf8.ts +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ebee2ad: Non-ASCII text read back from bindings no longer comes back garbled (baronunread/sproutboat#189). D1 rows, KV values, cached bodies, R2 metadata and SQLite error messages returned "café" as "café" in both standalone builds and broker-backed deployments: the reply JSON is UTF-8, but it reached the handler one char per byte. Replies are now escaped to ASCII before parsing. Outbound `fetch()` bodies and BLOB columns keep their raw bytes.
|
|
8
|
+
|
|
9
|
+
## 0.13.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 34083e1: Pass `(request, env, ctx)` to handlers built with compatibility dates from 2026-09-28. Older builds retain `(request, ctx)`. Add `Response.redirect()` for native fetch and decode request text as UTF-8 while preserving raw body bytes. The matching compiler Unicode fix shipped in `@sproutboat/toolchain@0.4.14`.
|
|
14
|
+
|
|
3
15
|
## 0.12.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
const src = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
|
|
4
|
+
const decoder = src.slice(src.indexOf("function __sbFromUtf8("), src.indexOf("// sproutboat #181"));
|
|
5
|
+
const ingress = src.slice(
|
|
6
|
+
src.indexOf("function __sbDecodeIngress("),
|
|
7
|
+
src.indexOf("globalThis.__sbEntry ="),
|
|
8
|
+
);
|
|
9
|
+
if (!decoder || !ingress) throw new Error("Ingress decoder source not found");
|
|
10
|
+
// oxlint-disable-next-line no-function-constructor -- Exercise the text-only runtime prelude.
|
|
11
|
+
const load = new Function(`${decoder}\n${ingress}\nreturn __sbDecodeIngress;`);
|
|
12
|
+
const decode = load();
|
|
13
|
+
const raw = (text) =>
|
|
14
|
+
Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
|
|
15
|
+
|
|
16
|
+
test("native ingress text and JSON decode Unicode without changing raw bytes", async () => {
|
|
17
|
+
const body = raw('{"id":"caffè-🚤"}');
|
|
18
|
+
const request = { body };
|
|
19
|
+
decode(request);
|
|
20
|
+
expect(await request.text()).toBe('{"id":"caffè-🚤"}');
|
|
21
|
+
expect(await request.json()).toEqual({ id: "caffè-🚤" });
|
|
22
|
+
expect(await request.text()).toBe('{"id":"caffè-🚤"}');
|
|
23
|
+
expect(request.body).toBe(body);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("native ingress retains empty bodies and JSON parse errors", () => {
|
|
27
|
+
const empty = { body: "" };
|
|
28
|
+
decode(empty);
|
|
29
|
+
expect(empty.text()).toBe("");
|
|
30
|
+
expect(() => empty.json()).toThrow();
|
|
31
|
+
const invalid = { body: "{" };
|
|
32
|
+
decode(invalid);
|
|
33
|
+
expect(() => invalid.json()).toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a bodyless request keeps its existing methods", () => {
|
|
37
|
+
const text = () => "";
|
|
38
|
+
const request = { body: null, text };
|
|
39
|
+
decode(request);
|
|
40
|
+
expect(request.text).toBe(text);
|
|
41
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Prepended to every handler by tools/compile.ts, before Porffor's native-fetch
|
|
2
|
-
// bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-
|
|
3
|
-
// gives URL (href/origin/pathname/search only) and Response without
|
|
4
|
-
// json(). This adds, additively, the rest of the WHATWG surface Worker code
|
|
2
|
+
// bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-10)
|
|
3
|
+
// gives URL (href/origin/pathname/search only) and Response without static
|
|
4
|
+
// json() or redirect(). This adds, additively, the rest of the WHATWG surface Worker code
|
|
5
5
|
// expects: URLSearchParams (read + write), URL.prototype.searchParams and the
|
|
6
6
|
// protocol/host/hostname/port/hash accessors, static Response.json,
|
|
7
7
|
// crypto.randomUUID / crypto.getRandomValues, crypto.subtle (digest + HMAC
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
// Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
|
|
20
20
|
// these express the same spec-mandated checks and are verified under Porffor
|
|
21
|
-
// alpha-
|
|
21
|
+
// alpha-10 by examples/kitchen-sink/harness.ts).
|
|
22
22
|
function __sbIsStr(v) {
|
|
23
23
|
return Object(v) !== v && v === String(v);
|
|
24
24
|
}
|
|
@@ -68,7 +68,7 @@ function __sbCpuMs() {
|
|
|
68
68
|
return res === "" ? 0 : parseFloat(res);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-
|
|
71
|
+
// #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-10's
|
|
72
72
|
// native-fetch serializer only reads headers from the plain object passed to
|
|
73
73
|
// `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
|
|
74
74
|
// is ignored on the wire — so the metric is carried by rebuilding the Response
|
|
@@ -204,6 +204,16 @@ if (Response.json == null) {
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// Porffor alpha-10 has no static Response.redirect().
|
|
208
|
+
if (Response.redirect == null) {
|
|
209
|
+
Response.redirect = function (url, status = 302) {
|
|
210
|
+
if (status !== 301 && status !== 302 && status !== 303 && status !== 307 && status !== 308) {
|
|
211
|
+
throw new RangeError("invalid redirect status");
|
|
212
|
+
}
|
|
213
|
+
return new Response("", { status, headers: { location: String(url) } });
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
// #59 — caches.default / caches.open(name), backed by the broker's cache.*
|
|
208
218
|
// ops (a sibling SQLite table, key on method+URL for v1 — no Vary yet).
|
|
209
219
|
// Deliberately NOT async: every other buffered-body method on this runtime
|
|
@@ -1262,9 +1272,9 @@ function __sbMakeD1(dbName) {
|
|
|
1262
1272
|
return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
|
|
1263
1273
|
},
|
|
1264
1274
|
// Sproutboat extension (not in CF Workers D1): an online, integrity-checked
|
|
1265
|
-
// snapshot of this database.
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
1275
|
+
// snapshot of this database. Both transports implement it: standalone writes
|
|
1276
|
+
// under `<data-dir>/backups/`, the broker under the resource directory's
|
|
1277
|
+
// `backups/`. Omit `name` for a timestamped default. See #164.
|
|
1268
1278
|
backup(name) {
|
|
1269
1279
|
const r = __sbRpc("d1.backup", { db: dbName, name: name == null ? "" : String(name) });
|
|
1270
1280
|
return { path: r.path, bytes: r.bytes };
|
|
@@ -2015,7 +2025,26 @@ async function __sbQueueWithDrain(result, tasks) {
|
|
|
2015
2025
|
return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
|
|
2016
2026
|
}
|
|
2017
2027
|
|
|
2028
|
+
// Native HTTP ingress stores the body as one character per raw byte. Keep
|
|
2029
|
+
// that representation for binary consumers, but decode text lazily before
|
|
2030
|
+
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
2031
|
+
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
2032
|
+
function __sbDecodeIngress(request) {
|
|
2033
|
+
const raw = request.body;
|
|
2034
|
+
if (raw == null) return;
|
|
2035
|
+
let decoded = null;
|
|
2036
|
+
request.text = function () {
|
|
2037
|
+
if (decoded === null) decoded = __sbFromUtf8(raw);
|
|
2038
|
+
return decoded;
|
|
2039
|
+
};
|
|
2040
|
+
request.json = function () {
|
|
2041
|
+
if (decoded === null) decoded = __sbFromUtf8(raw);
|
|
2042
|
+
return JSON.parse(decoded);
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2018
2046
|
globalThis.__sbEntry = function (handlers, request) {
|
|
2047
|
+
__sbDecodeIngress(request);
|
|
2019
2048
|
const trigger = request.headers.get("x-sb-trigger");
|
|
2020
2049
|
if (!trigger) {
|
|
2021
2050
|
// #163/#128 — expose the request metadata the edge already has, the
|
|
@@ -2037,7 +2066,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
2037
2066
|
// concurrent in-process requests.
|
|
2038
2067
|
//
|
|
2039
2068
|
// Sync handlers only. An async handler's promise is handed straight back:
|
|
2040
|
-
// Porffor alpha-
|
|
2069
|
+
// Porffor alpha-10's native-fetch server resolves the promise the handler
|
|
2041
2070
|
// itself returned, but never one derived from `.then()`, so chaining the
|
|
2042
2071
|
// tag on hangs the request forever. cpuMs is documented as absent for
|
|
2043
2072
|
// async handlers (see LogEvent in services/edge) — that is this.
|
|
@@ -2050,7 +2079,9 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
2050
2079
|
};
|
|
2051
2080
|
let __res;
|
|
2052
2081
|
try {
|
|
2053
|
-
__res =
|
|
2082
|
+
__res = globalThis.__sbCompat >= "2026-09-28"
|
|
2083
|
+
? handlers.fetch(request, globalThis.env, ctx)
|
|
2084
|
+
: handlers.fetch(request, ctx);
|
|
2054
2085
|
} catch {
|
|
2055
2086
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
2056
2087
|
// whole process (and every other in-flight request) down with it.
|
|
@@ -2077,10 +2108,10 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
2077
2108
|
};
|
|
2078
2109
|
let __sres;
|
|
2079
2110
|
try {
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
ctx
|
|
2083
|
-
|
|
2111
|
+
const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
|
|
2112
|
+
__sres = globalThis.__sbCompat >= "2026-09-28"
|
|
2113
|
+
? handlers.scheduled(event, globalThis.env, ctx)
|
|
2114
|
+
: handlers.scheduled(event, ctx);
|
|
2084
2115
|
} catch {
|
|
2085
2116
|
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
2086
2117
|
return new Response("", { status: 204 });
|
|
@@ -2167,7 +2198,9 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
2167
2198
|
},
|
|
2168
2199
|
};
|
|
2169
2200
|
try {
|
|
2170
|
-
const __qres =
|
|
2201
|
+
const __qres = globalThis.__sbCompat >= "2026-09-28"
|
|
2202
|
+
? handlers.queue(batch, globalThis.env, ctx)
|
|
2203
|
+
: handlers.queue(batch, ctx);
|
|
2171
2204
|
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
2172
2205
|
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
2173
2206
|
// past the first one never counted.
|
package/src/source.ts
CHANGED
|
@@ -25,7 +25,7 @@ const alwaysForbidden: Array<[RegExp, string]> = [
|
|
|
25
25
|
// match would also flag it appearing in an ordinary string a dependency
|
|
26
26
|
// happens to construct (a doc link, a log message).
|
|
27
27
|
[/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
|
|
28
|
-
// Porffor
|
|
28
|
+
// The Porffor pin compiles `new Proxy(...)` and then ignores the handler: a
|
|
29
29
|
// trapped property reads back as `undefined`, with no throw. Rejecting it
|
|
30
30
|
// here is the difference between a build error and a 502 nobody can explain.
|
|
31
31
|
// It is why itty-router and other Proxy-based routers do not work yet.
|
package/src/transport-broker.js
CHANGED
|
@@ -147,6 +147,66 @@ static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out,
|
|
|
147
147
|
return rc;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// baronunread/sproutboat#189 -- the broker writes its reply JSON as UTF-8,
|
|
151
|
+
// but it reaches JS as a bytestring, one char per byte, so any non-ASCII text
|
|
152
|
+
// (a D1 row, a KV value, a cached body) came back as Latin-1 mojibake. Non-ASCII
|
|
153
|
+
// bytes only ever sit inside JSON strings, so rewriting each UTF-8 sequence as
|
|
154
|
+
// \uXXXX gives the same JSON in pure ASCII, and JSON.parse rebuilds the real
|
|
155
|
+
// strings. A byte that starts no valid sequence passes through as \u00XX.
|
|
156
|
+
// Returns a malloc'd copy, or 0 when the reply is already ASCII.
|
|
157
|
+
static char* sb_json_ascii(const char* s, size_t n, size_t* out_len) {
|
|
158
|
+
size_t high = 0;
|
|
159
|
+
for (size_t i = 0; i < n; i++) if ((unsigned char)s[i] >= 0x80) high++;
|
|
160
|
+
if (!high) return 0;
|
|
161
|
+
char* out = (char*)malloc(n + high * 12 + 1);
|
|
162
|
+
if (!out) return 0;
|
|
163
|
+
size_t o = 0;
|
|
164
|
+
for (size_t i = 0; i < n; i++) {
|
|
165
|
+
unsigned char c = (unsigned char)s[i];
|
|
166
|
+
if (c < 0x80) { out[o++] = (char)c; continue; }
|
|
167
|
+
unsigned int cp = 0, min = 0;
|
|
168
|
+
size_t len = 0;
|
|
169
|
+
if ((c & 0xe0) == 0xc0) { cp = c & 0x1f; len = 2; min = 0x80; }
|
|
170
|
+
else if ((c & 0xf0) == 0xe0) { cp = c & 0x0f; len = 3; min = 0x800; }
|
|
171
|
+
else if ((c & 0xf8) == 0xf0) { cp = c & 0x07; len = 4; min = 0x10000; }
|
|
172
|
+
int ok = len > 0 && i + len <= n;
|
|
173
|
+
for (size_t k = 1; ok && k < len; k++) {
|
|
174
|
+
unsigned char cc = (unsigned char)s[i + k];
|
|
175
|
+
if ((cc & 0xc0) != 0x80) ok = 0;
|
|
176
|
+
else cp = (cp << 6) | (cc & 0x3f);
|
|
177
|
+
}
|
|
178
|
+
if (ok && (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))) ok = 0;
|
|
179
|
+
if (!ok) { o += (size_t)sprintf(out + o, "\\u%04x", c); continue; }
|
|
180
|
+
if (cp >= 0x10000) {
|
|
181
|
+
cp -= 0x10000;
|
|
182
|
+
o += (size_t)sprintf(out + o, "\\u%04x\\u%04x", 0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
|
|
183
|
+
} else {
|
|
184
|
+
o += (size_t)sprintf(out + o, "\\u%04x", cp);
|
|
185
|
+
}
|
|
186
|
+
i += len - 1;
|
|
187
|
+
}
|
|
188
|
+
out[o] = 0;
|
|
189
|
+
*out_len = o;
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// fetch and service.fetch are the exception: their body is the upstream's
|
|
194
|
+
// bytes, which the prelude keeps one char per byte (x-sb-raw-body) and decodes
|
|
195
|
+
// itself. __sbRpc always writes {"v":1,"id":N,"op":... first, so the op is a
|
|
196
|
+
// fixed-position prefix, not a search through user data.
|
|
197
|
+
static int sb_req_is_fetch(const char* req, size_t n) {
|
|
198
|
+
const char* head = "{\"v\":1,\"id\":";
|
|
199
|
+
size_t hl = strlen(head);
|
|
200
|
+
if (n < hl || memcmp(req, head, hl) != 0) return 0;
|
|
201
|
+
size_t i = hl;
|
|
202
|
+
while (i < n && req[i] >= '0' && req[i] <= '9') i++;
|
|
203
|
+
const char* a = ",\"op\":\"fetch\"";
|
|
204
|
+
const char* b = ",\"op\":\"service.fetch\"";
|
|
205
|
+
if (n - i >= strlen(a) && memcmp(req + i, a, strlen(a)) == 0) return 1;
|
|
206
|
+
if (n - i >= strlen(b) && memcmp(req + i, b, strlen(b)) == 0) return 1;
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
150
210
|
// A v1 exchange: marker, json length, json, then the body bytes. The reply is
|
|
151
211
|
// split the same way, its JSON returned and its bytes stashed for __sbTakeBin.
|
|
152
212
|
static int sb_broker_roundtrip_v1(const char* json, size_t json_len, const char* body, size_t body_len,
|
|
@@ -215,8 +275,12 @@ function __sbCall(reqJson) {
|
|
|
215
275
|
porf_native_fetch_read_value(reqJson, &__req, &__reqlen, &__reqowned);
|
|
216
276
|
char* __resp = 0; size_t __resplen = 0;
|
|
217
277
|
int __rc = sb_broker_roundtrip(__req, __reqlen, &__resp, &__resplen);
|
|
278
|
+
int __raw = sb_req_is_fetch(__req, __reqlen);
|
|
218
279
|
if (__reqowned) free(__reqowned);
|
|
219
280
|
if (__rc == 0) {
|
|
281
|
+
size_t __al = 0;
|
|
282
|
+
char* __ascii = __raw ? 0 : sb_json_ascii(__resp, __resplen, &__al);
|
|
283
|
+
if (__ascii) { free(__resp); __resp = __ascii; __resplen = __al; }
|
|
220
284
|
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
|
|
221
285
|
free(__resp);
|
|
222
286
|
} else {
|
|
@@ -258,6 +322,9 @@ function __sbCallBin(reqJson, body) {
|
|
|
258
322
|
if (__jo) free(__jo);
|
|
259
323
|
if (__bo) free(__bo);
|
|
260
324
|
if (__rc == 0) {
|
|
325
|
+
size_t __al = 0;
|
|
326
|
+
char* __ascii = sb_json_ascii(__resp, __resplen, &__al);
|
|
327
|
+
if (__ascii) { free(__resp); __resp = __ascii; __resplen = __al; }
|
|
261
328
|
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
|
|
262
329
|
free(__resp);
|
|
263
330
|
} else {
|
|
@@ -184,6 +184,53 @@ static void sb_putjson(sb_buf* b, const char* s, size_t n) {
|
|
|
184
184
|
}
|
|
185
185
|
sb_put(b, "\"", 1);
|
|
186
186
|
}
|
|
187
|
+
// baronunread/sproutboat#189 -- sb_putjson for bytes that are UTF-8 text
|
|
188
|
+
// (SQLite TEXT, its error messages). The reply reaches JS as a bytestring, one
|
|
189
|
+
// char per byte, so a raw multi-byte sequence came back as Latin-1 mojibake
|
|
190
|
+
// ("café" as "café"). Escaping every code point >= 0x80 as \uXXXX keeps the
|
|
191
|
+
// reply pure ASCII and lets JSON.parse rebuild the real string. A byte that
|
|
192
|
+
// doesn't start a valid sequence passes through as \u00XX, the same rule as
|
|
193
|
+
// the prelude's __sbFromUtf8. Raw byte producers (the HTTP client's body,
|
|
194
|
+
// BLOB columns) keep sb_putjson.
|
|
195
|
+
static void sb_putjson_text(sb_buf* b, const char* s, size_t n) {
|
|
196
|
+
sb_put(b, "\"", 1);
|
|
197
|
+
for (size_t i = 0; i < n; i++) {
|
|
198
|
+
unsigned char c = (unsigned char)s[i];
|
|
199
|
+
if (c < 0x80) {
|
|
200
|
+
if (c == '"' || c == '\\') { char e[2] = { '\\', (char)c }; sb_put(b, e, 2); }
|
|
201
|
+
else if (c == '\n') sb_put(b, "\\n", 2);
|
|
202
|
+
else if (c == '\r') sb_put(b, "\\r", 2);
|
|
203
|
+
else if (c == '\t') sb_put(b, "\\t", 2);
|
|
204
|
+
else if (c < 0x20) { char e[8]; int k = snprintf(e, 8, "\\u%04x", c); sb_put(b, e, (size_t)k); }
|
|
205
|
+
else sb_put(b, (const char*)&c, 1);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
unsigned int cp = 0, min = 0;
|
|
209
|
+
size_t len = 0;
|
|
210
|
+
if ((c & 0xe0) == 0xc0) { cp = c & 0x1f; len = 2; min = 0x80; }
|
|
211
|
+
else if ((c & 0xf0) == 0xe0) { cp = c & 0x0f; len = 3; min = 0x800; }
|
|
212
|
+
else if ((c & 0xf8) == 0xf0) { cp = c & 0x07; len = 4; min = 0x10000; }
|
|
213
|
+
int ok = len > 0 && i + len <= n;
|
|
214
|
+
for (size_t k = 1; ok && k < len; k++) {
|
|
215
|
+
unsigned char cc = (unsigned char)s[i + k];
|
|
216
|
+
if ((cc & 0xc0) != 0x80) ok = 0;
|
|
217
|
+
else cp = (cp << 6) | (cc & 0x3f);
|
|
218
|
+
}
|
|
219
|
+
if (ok && (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))) ok = 0;
|
|
220
|
+
char e[16];
|
|
221
|
+
int k;
|
|
222
|
+
if (!ok) { k = snprintf(e, sizeof(e), "\\u%04x", c); sb_put(b, e, (size_t)k); continue; }
|
|
223
|
+
if (cp >= 0x10000) {
|
|
224
|
+
cp -= 0x10000;
|
|
225
|
+
k = snprintf(e, sizeof(e), "\\u%04x\\u%04x", 0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
|
|
226
|
+
} else {
|
|
227
|
+
k = snprintf(e, sizeof(e), "\\u%04x", cp);
|
|
228
|
+
}
|
|
229
|
+
sb_put(b, e, (size_t)k);
|
|
230
|
+
i += len - 1;
|
|
231
|
+
}
|
|
232
|
+
sb_put(b, "\"", 1);
|
|
233
|
+
}
|
|
187
234
|
|
|
188
235
|
// --- the narrow JSON reader: a flat array of null | number | string ----------
|
|
189
236
|
// Only ever fed __sbSql's own params, which JS builds; anything unexpected
|
|
@@ -495,7 +542,7 @@ static char* sb_r2_put_c(const char* path, const char* bucket, const char* key,
|
|
|
495
542
|
if (strcmp(previous, etag) != 0) unlink(blobpath);
|
|
496
543
|
sb_puts(&out, "{\"ok\":false,\"error\":");
|
|
497
544
|
const char* m = sqlite3_errmsg(db);
|
|
498
|
-
|
|
545
|
+
sb_putjson_text(&out, m, strlen(m));
|
|
499
546
|
sb_puts(&out, "}");
|
|
500
547
|
return out.p;
|
|
501
548
|
}
|
|
@@ -817,7 +864,7 @@ static char* sb_sql_script(const char* path, const char* sql) {
|
|
|
817
864
|
char* err = 0;
|
|
818
865
|
if (sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err) != 0) {
|
|
819
866
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
820
|
-
|
|
867
|
+
sb_putjson_text(&b, err ? err : "exec failed", err ? strlen(err) : 11);
|
|
821
868
|
sb_puts(&b, "}");
|
|
822
869
|
return b.p;
|
|
823
870
|
}
|
|
@@ -837,7 +884,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
837
884
|
if (!st) {
|
|
838
885
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
839
886
|
const char* m = sqlite3_errmsg(db);
|
|
840
|
-
|
|
887
|
+
sb_putjson_text(&b, m, strlen(m));
|
|
841
888
|
sb_puts(&b, "}");
|
|
842
889
|
return b.p;
|
|
843
890
|
}
|
|
@@ -848,7 +895,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
848
895
|
for (int i = 0; i < ncol; i++) {
|
|
849
896
|
if (i) sb_puts(&b, ",");
|
|
850
897
|
const char* name = sqlite3_column_name(st, i);
|
|
851
|
-
|
|
898
|
+
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
852
899
|
}
|
|
853
900
|
sb_puts(&b, "],\"rows\":[");
|
|
854
901
|
int rc, first = 1;
|
|
@@ -868,7 +915,9 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
868
915
|
}
|
|
869
916
|
const unsigned char* txt = sqlite3_column_text(st, i);
|
|
870
917
|
int n = sqlite3_column_bytes(st, i);
|
|
871
|
-
|
|
918
|
+
// TEXT is UTF-8 and decodes (#189); a BLOB stays one char per byte.
|
|
919
|
+
if (t == 4) sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
|
|
920
|
+
else sb_putjson_text(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
|
|
872
921
|
}
|
|
873
922
|
sb_puts(&b, "]");
|
|
874
923
|
}
|
|
@@ -883,7 +932,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
883
932
|
sb_buf e = { 0, 0, 0 };
|
|
884
933
|
sb_puts(&e, "{\"ok\":false,\"error\":");
|
|
885
934
|
const char* m = sqlite3_errmsg(db);
|
|
886
|
-
|
|
935
|
+
sb_putjson_text(&e, m, strlen(m));
|
|
887
936
|
sb_puts(&e, "}");
|
|
888
937
|
return e.p;
|
|
889
938
|
}
|
|
@@ -909,7 +958,7 @@ static char* sb_sql_backup(const char* src_path, const char* dest_path) {
|
|
|
909
958
|
if (sqlite3_prepare_v2(db, "VACUUM INTO ?1", -1, &st, 0) != 0 || !st) {
|
|
910
959
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
911
960
|
const char* m = sqlite3_errmsg(db);
|
|
912
|
-
|
|
961
|
+
sb_putjson_text(&b, m, strlen(m));
|
|
913
962
|
sb_puts(&b, "}");
|
|
914
963
|
return b.p;
|
|
915
964
|
}
|
|
@@ -919,7 +968,7 @@ static char* sb_sql_backup(const char* src_path, const char* dest_path) {
|
|
|
919
968
|
if (rc != SB_SQLITE_DONE) {
|
|
920
969
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
921
970
|
const char* m = sqlite3_errmsg(db);
|
|
922
|
-
|
|
971
|
+
sb_putjson_text(&b, m, strlen(m));
|
|
923
972
|
sb_puts(&b, "}");
|
|
924
973
|
return b.p;
|
|
925
974
|
}
|
|
@@ -1097,7 +1146,7 @@ static char* sb_error_json(const char* prefix, const char* detail) {
|
|
|
1097
1146
|
sb_buf msg = { 0, 0, 0 };
|
|
1098
1147
|
sb_puts(&msg, prefix);
|
|
1099
1148
|
if (detail) { sb_puts(&msg, detail); }
|
|
1100
|
-
|
|
1149
|
+
sb_putjson_text(&out, msg.p ? msg.p : "", msg.len);
|
|
1101
1150
|
sb_puts(&out, "}");
|
|
1102
1151
|
free(msg.p);
|
|
1103
1152
|
return out.p;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
|
|
5
|
+
// oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
|
|
6
|
+
const load = new Function(
|
|
7
|
+
"Porffor",
|
|
8
|
+
`${source}\n__sbTriggerAuthed = () => true; return { entry: __sbEntry, queue: __sbRunQueueBatch };`,
|
|
9
|
+
);
|
|
10
|
+
const { entry, queue } = load({ c: () => "" });
|
|
11
|
+
const request = () => ({ headers: { get: () => null }, body: null });
|
|
12
|
+
|
|
13
|
+
test("the Workers compatibility date passes env and ctx in their standard positions", async () => {
|
|
14
|
+
globalThis.__sbCompat = "2026-09-28";
|
|
15
|
+
globalThis.env = { GREETING: "hello" };
|
|
16
|
+
const response = entry({ fetch(_request, env, ctx) {
|
|
17
|
+
return new Response(env.GREETING + ":" + (ctx.waitUntil instanceof Function));
|
|
18
|
+
} }, request());
|
|
19
|
+
expect(await response.text()).toBe("hello:true");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("earlier compatibility dates retain ctx as the second argument", async () => {
|
|
23
|
+
globalThis.__sbCompat = "2026-09-07";
|
|
24
|
+
const response = entry({ fetch(_request, ctx) {
|
|
25
|
+
return new Response(String(ctx.waitUntil instanceof Function));
|
|
26
|
+
} }, request());
|
|
27
|
+
expect(await response.text()).toBe("true");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Response.redirect produces a Location header and empty body", async () => {
|
|
31
|
+
const response = Response.redirect("https://example.com/next", 301);
|
|
32
|
+
expect(response.status).toBe(301);
|
|
33
|
+
expect(response.headers.get("location")).toBe("https://example.com/next");
|
|
34
|
+
expect(await response.text()).toBe("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("queue handlers receive env and ctx with the Workers compatibility date", async () => {
|
|
38
|
+
globalThis.__sbCompat = "2026-09-28";
|
|
39
|
+
globalThis.env = { GREETING: "hello" };
|
|
40
|
+
let seen = "";
|
|
41
|
+
await queue({ queue(_batch, env, ctx) {
|
|
42
|
+
seen = env.GREETING + ":" + (ctx.waitUntil instanceof Function);
|
|
43
|
+
} }, { messages: [] }, { waitUntil() {} });
|
|
44
|
+
expect(seen).toBe("hello:true");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("scheduled handlers receive env and ctx with the Workers compatibility date", () => {
|
|
48
|
+
globalThis.__sbCompat = "2026-09-28";
|
|
49
|
+
globalThis.env = { GREETING: "hello" };
|
|
50
|
+
let seen = "";
|
|
51
|
+
const response = entry({ scheduled(_event, env, ctx) {
|
|
52
|
+
seen = env.GREETING + ":" + (ctx.waitUntil instanceof Function);
|
|
53
|
+
} }, {
|
|
54
|
+
headers: { get: (name) => name === "x-sb-trigger" ? "scheduled" : null },
|
|
55
|
+
body: "{}",
|
|
56
|
+
});
|
|
57
|
+
expect(response.status).toBe(204);
|
|
58
|
+
expect(seen).toBe("hello:true");
|
|
59
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { createBroker, listen } from "../../wire/src/broker";
|
|
7
|
+
import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
8
|
+
|
|
9
|
+
// baronunread/sproutboat#189: text read back through the broker came back as
|
|
10
|
+
// Latin-1 mojibake ("café" as "café"), because the reply JSON is UTF-8 and the
|
|
11
|
+
// sprout holds it one char per byte. Covers 2-, 3- and 4-byte sequences on the
|
|
12
|
+
// two hot paths (KV, D1) and a non-ASCII SQLite error message.
|
|
13
|
+
const text = "/café/日本/😀/x";
|
|
14
|
+
|
|
15
|
+
const handler = `
|
|
16
|
+
export default {
|
|
17
|
+
async fetch() {
|
|
18
|
+
await env.KV.put("k", ${JSON.stringify(text)});
|
|
19
|
+
const kv = await env.KV.get("k");
|
|
20
|
+
await env.DB.prepare("CREATE TABLE t (v TEXT)").run();
|
|
21
|
+
await env.DB.prepare("INSERT INTO t (v) VALUES (?)").bind(${JSON.stringify(text)}).run();
|
|
22
|
+
const d1 = (await env.DB.prepare("SELECT v FROM t").first()).v;
|
|
23
|
+
let error = "";
|
|
24
|
+
try { await env.DB.prepare("SELECT * FROM tabellà").first(); } catch (e) { error = String(e.message); }
|
|
25
|
+
return Response.json({ kv, kvLength: kv.length, d1, d1Length: d1.length, error });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function freePort(): number {
|
|
31
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
32
|
+
const port = listener.port;
|
|
33
|
+
listener.stop(true);
|
|
34
|
+
return port;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-utf8-"));
|
|
38
|
+
const bindings = { ...EMPTY_BINDINGS, kv: ["KV"], d1: ["DB"] };
|
|
39
|
+
const broker = createBroker({
|
|
40
|
+
db: ":memory:",
|
|
41
|
+
token: "native-utf8",
|
|
42
|
+
bindings: { ...bindings, resources: {} },
|
|
43
|
+
});
|
|
44
|
+
const brokerServer = listen(broker, "127.0.0.1", 0);
|
|
45
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
46
|
+
try {
|
|
47
|
+
const porffor = await ensurePorffor({ cacheRoot: workdir });
|
|
48
|
+
const node = Bun.which("node");
|
|
49
|
+
if (!node) throw new Error("native UTF-8 test needs node on PATH");
|
|
50
|
+
|
|
51
|
+
const [core, transport] = await Promise.all([
|
|
52
|
+
readFile(preludePath, "utf8"),
|
|
53
|
+
readFile(transportPath("broker"), "utf8"),
|
|
54
|
+
]);
|
|
55
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
56
|
+
const generated = join(workdir, "utf8.generated.js");
|
|
57
|
+
const binary = join(workdir, "utf8-native");
|
|
58
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, transport), {}, bindings));
|
|
59
|
+
|
|
60
|
+
const compile = Bun.spawn([node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"], {
|
|
61
|
+
cwd: workdir,
|
|
62
|
+
env: process.env,
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
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
|
+
|
|
73
|
+
const port = freePort();
|
|
74
|
+
server = Bun.spawn([binary], {
|
|
75
|
+
cwd: workdir,
|
|
76
|
+
env: {
|
|
77
|
+
...process.env,
|
|
78
|
+
PORT: String(port),
|
|
79
|
+
SB_BROKER_PORT: String(brokerServer.port),
|
|
80
|
+
SB_BROKER_TOKEN: "native-utf8",
|
|
81
|
+
},
|
|
82
|
+
stdout: "ignore",
|
|
83
|
+
stderr: "inherit",
|
|
84
|
+
});
|
|
85
|
+
let response: Response | undefined;
|
|
86
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
|
|
89
|
+
break;
|
|
90
|
+
} catch {
|
|
91
|
+
if (server.exitCode !== null) break;
|
|
92
|
+
await Bun.sleep(100);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
assert(response, "compiled UTF-8 handler did not start");
|
|
96
|
+
assert.equal(response.status, 200);
|
|
97
|
+
// SAFETY: the handler above is the only producer of this body and always
|
|
98
|
+
// returns exactly these five fields; deepEqual below checks every one.
|
|
99
|
+
const body = (await response.json()) as { kv: string; kvLength: number; d1: string; d1Length: number; error: string };
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
{ kv: body.kv, kvLength: body.kvLength, d1: body.d1, d1Length: body.d1Length },
|
|
102
|
+
{ kv: text, kvLength: text.length, d1: text, d1Length: text.length },
|
|
103
|
+
);
|
|
104
|
+
assert.match(body.error, /tabellà/);
|
|
105
|
+
console.log("Non-ASCII KV, D1 and error text round-trip through the broker");
|
|
106
|
+
} finally {
|
|
107
|
+
if (server) {
|
|
108
|
+
server.kill(9);
|
|
109
|
+
await server.exited;
|
|
110
|
+
}
|
|
111
|
+
brokerServer.stop();
|
|
112
|
+
broker.close();
|
|
113
|
+
await rm(workdir, { recursive: true, force: true });
|
|
114
|
+
}
|