@sproutboat/runtime 0.6.0 → 0.6.2
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 +66 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +94 -3
- package/src/utf8-decode.test.js +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.6.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bd06ce7: Fix (baronunread/sproutboat#181): a handler calling `.text()`/`.json()` on a
|
|
8
|
+
Response built from raw wire bytes — the assets binding, outbound `fetch()`,
|
|
9
|
+
or a service-binding `fetch()`, the three places that set the `x-sb-raw-body`
|
|
10
|
+
marker for #176 — got the bytes back undecoded instead of as real text. Those
|
|
11
|
+
Response bodies are Porffor `bytestring`s (one raw byte per code unit), and
|
|
12
|
+
`Response.prototype.text()` returns that verbatim rather than UTF-8-decoding
|
|
13
|
+
it, so any multi-byte UTF-8 sequence came back as multiple wrong Latin-1
|
|
14
|
+
characters. A handler that transforms and re-emits that text (sproutboat-
|
|
15
|
+
site's homepage does this to inline its live counter into the fetched
|
|
16
|
+
`index.html`) then re-encodes the corruption as ordinary UTF-8 on the way
|
|
17
|
+
out — this is what turned `·` into `·` live on sproutboat.com.
|
|
18
|
+
|
|
19
|
+
Fixed at the sproutboat glue layer, not in Porffor: the three `x-sb-raw-body`
|
|
20
|
+
producers in `native-fetch-prelude.js` now build their Response through a new
|
|
21
|
+
`__sbRawBodyResponse()`, which overrides `.text()`/`.json()` on just that
|
|
22
|
+
instance with a proper UTF-8 decode (`__sbFromUtf8`, validates each
|
|
23
|
+
continuation byte before consuming it). This is deliberately *not* a patch to
|
|
24
|
+
Porffor's generic `Response`/`Request` classes — Porffor represents any
|
|
25
|
+
string whose characters are all <= 0xFF as this same `bytestring` shape,
|
|
26
|
+
whether it's opaque bytes or a handler's own Latin-1-range text (e.g.
|
|
27
|
+
`"café"`), and the type alone can't tell those apart. Decoding generically
|
|
28
|
+
would corrupt the second case; scoping the fix to the three call sites that
|
|
29
|
+
know for certain their body is bytes avoids that entirely (verified: a
|
|
30
|
+
genuine `"héllo"`/`"café"` string passes through `__sbFromUtf8` unchanged).
|
|
31
|
+
|
|
32
|
+
Verified against a real compiled native-fetch binary (`porf native`, not just
|
|
33
|
+
plain Bun): the override pattern works as expected, the decode round-trips
|
|
34
|
+
real multi-byte and 4-byte UTF-8 correctly, and genuine Latin-1 text is left
|
|
35
|
+
alone. Also verified end to end on sproutboat-site's own deployment.
|
|
36
|
+
|
|
37
|
+
## 0.6.1
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- 806c21e: Fix (baronunread/sproutboat#176): #172's bytestring UTF-8 encoding fix broke
|
|
42
|
+
the assets binding, which reads a file's bytes off disk into a `bytestring`
|
|
43
|
+
specifically so they pass through the wire untouched — a `bytestring`
|
|
44
|
+
carrying real text a handler built and one carrying opaque file bytes are the
|
|
45
|
+
same type with no way to tell them apart, so #172's blanket encoding also
|
|
46
|
+
re-encoded already-finished bytes, corrupting any binary asset (and any
|
|
47
|
+
proxied `fetch()`/service-binding response body).
|
|
48
|
+
|
|
49
|
+
Adds a second, dedicated `porf_native_fetch_read_raw_bytes` (zero-copy,
|
|
50
|
+
bytestring-only, no encoding, ever) alongside the untouched original —
|
|
51
|
+
`porf_native_fetch_read_value` is called from ~30 places across sproutboat's
|
|
52
|
+
own inline C, all genuine text, so its signature and behavior stay exactly as
|
|
53
|
+
#172 left them. `write_response_value` picks between the two based on a new
|
|
54
|
+
reserved `x-sb-raw-body` response header (same pattern as #163's
|
|
55
|
+
`x-sb-remote-addr`), which the assets binding, outbound `fetch()`, and
|
|
56
|
+
service-binding `fetch()` now set — the three places sproutboat's own
|
|
57
|
+
prelude constructs a Response from wire bytes rather than a string a handler
|
|
58
|
+
built. Verified end-to-end: a 256-byte all-values fixture now round-trips
|
|
59
|
+
byte-for-byte through a real standalone build's assets binding, and #172's
|
|
60
|
+
original UTF-8 fix still holds for genuine text.
|
|
61
|
+
|
|
62
|
+
Not fixed here: `env.<R2>.get()`'s `.body` is exposed directly to handler
|
|
63
|
+
code, which then constructs its own `new Response(obj.body)` — no
|
|
64
|
+
sproutboat-owned call site to attach the marker to, so R2 binary objects
|
|
65
|
+
served this way carry the same corruption. Needs either a real binary body
|
|
66
|
+
type upstream in Porffor or a different API shape; tracked as a known gap on
|
|
67
|
+
#176, not resolved by this fix.
|
|
68
|
+
|
|
3
69
|
## 0.6.0
|
|
4
70
|
|
|
5
71
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -326,6 +326,84 @@ function __sbBufFrom(latin1) {
|
|
|
326
326
|
for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
|
|
327
327
|
return b.buffer;
|
|
328
328
|
}
|
|
329
|
+
// The reverse of __sbToBytes: a latin1 string holding raw UTF-8 bytes (one
|
|
330
|
+
// byte per char) -> a real JS string, decoding multi-byte sequences into
|
|
331
|
+
// single characters. Validates each continuation byte (10xxxxxx) before
|
|
332
|
+
// consuming it, so a lead byte with no valid continuation -- the common case
|
|
333
|
+
// for genuine Latin-1 text, e.g. a lone "é" -- passes through unchanged
|
|
334
|
+
// rather than swallowing following characters into a garbage code point.
|
|
335
|
+
// Used only where sproutboat's own glue knows for certain the string is
|
|
336
|
+
// opaque bytes off the wire (the `x-sb-raw-body` producers below), never on
|
|
337
|
+
// a generic string a handler might have built -- Porffor represents any
|
|
338
|
+
// string whose chars are all <= 0xff as this same "bytestring" shape
|
|
339
|
+
// regardless of whether it holds real Latin-1 text or raw bytes, so this
|
|
340
|
+
// decode is only safe where the caller already knows which one it has.
|
|
341
|
+
function __sbFromUtf8(bytes) {
|
|
342
|
+
let out = "";
|
|
343
|
+
const len = bytes.length;
|
|
344
|
+
const cont = (j) => {
|
|
345
|
+
const b = bytes.charCodeAt(j) & 0xff;
|
|
346
|
+
return (b & 0xc0) == 0x80 ? b & 0x3f : -1;
|
|
347
|
+
};
|
|
348
|
+
for (let i = 0; i < len; i++) {
|
|
349
|
+
const b0 = bytes.charCodeAt(i) & 0xff;
|
|
350
|
+
if (b0 < 0x80) {
|
|
351
|
+
out += String.fromCharCode(b0);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if ((b0 & 0xe0) == 0xc0 && i + 1 < len) {
|
|
355
|
+
const c1 = cont(i + 1);
|
|
356
|
+
if (c1 >= 0) {
|
|
357
|
+
out += String.fromCharCode(((b0 & 0x1f) << 6) | c1);
|
|
358
|
+
i += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
} else if ((b0 & 0xf0) == 0xe0 && i + 2 < len) {
|
|
362
|
+
const c1 = cont(i + 1);
|
|
363
|
+
const c2 = cont(i + 2);
|
|
364
|
+
if (c1 >= 0 && c2 >= 0) {
|
|
365
|
+
out += String.fromCharCode(((b0 & 0x0f) << 12) | (c1 << 6) | c2);
|
|
366
|
+
i += 2;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
} else if ((b0 & 0xf8) == 0xf0 && i + 3 < len) {
|
|
370
|
+
const c1 = cont(i + 1);
|
|
371
|
+
const c2 = cont(i + 2);
|
|
372
|
+
const c3 = cont(i + 3);
|
|
373
|
+
if (c1 >= 0 && c2 >= 0 && c3 >= 0) {
|
|
374
|
+
let cp = ((b0 & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
|
|
375
|
+
cp -= 0x10000;
|
|
376
|
+
out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
|
|
377
|
+
i += 3;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// no lead byte matched, or its continuation bytes weren't 10xxxxxx:
|
|
382
|
+
// pass the single byte through rather than guessing
|
|
383
|
+
out += String.fromCharCode(b0);
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
// sproutboat #181 — wrap a Response built from raw wire bytes (the assets
|
|
388
|
+
// binding, outbound fetch(), service-binding fetch(): the three `x-sb-raw-
|
|
389
|
+
// body` producers) so a handler calling `.text()`/`.json()` on it gets the
|
|
390
|
+
// bytes properly UTF-8-decoded. The wire write path already handles the
|
|
391
|
+
// C-level byte-for-byte passthrough via that same header (#176) -- this is
|
|
392
|
+
// the separate, missing half: Porffor's own `Response.prototype.text()`
|
|
393
|
+
// returns a `bytestring` body verbatim instead of decoding it (it can't tell
|
|
394
|
+
// "opaque bytes" from "a handler's own Latin-1-range string" apart, so it
|
|
395
|
+
// safely can't decode by default). Overriding just these three instances,
|
|
396
|
+
// which sproutboat itself builds and knows are bytes, fixes the read side
|
|
397
|
+
// without touching Porffor's generic Response/Request classes at all.
|
|
398
|
+
function __sbRawBodyResponse(body, init) {
|
|
399
|
+
const resp = new Response(body == null ? "" : body, init);
|
|
400
|
+
if (body != null) {
|
|
401
|
+
const decoded = __sbFromUtf8(body);
|
|
402
|
+
resp.text = () => decoded;
|
|
403
|
+
resp.json = () => JSON.parse(decoded);
|
|
404
|
+
}
|
|
405
|
+
return resp;
|
|
406
|
+
}
|
|
329
407
|
// Constant-time compare of two latin1 byte strings.
|
|
330
408
|
function __sbEqCt(a, b) {
|
|
331
409
|
if (a.length !== b.length) return false;
|
|
@@ -1140,7 +1218,12 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1140
1218
|
const headers = {};
|
|
1141
1219
|
if (r.type) headers["content-type"] = r.type;
|
|
1142
1220
|
if (r.found) headers["etag"] = '"' + r.hash + '"';
|
|
1143
|
-
|
|
1221
|
+
// #176: these bytes came off disk already-finished (UTF-8 text or
|
|
1222
|
+
// binary, doesn't matter which) -- the reserved x-sb-raw-body header
|
|
1223
|
+
// tells the C write path to pass them through untouched instead of
|
|
1224
|
+
// re-encoding as if this were a JS string a handler built.
|
|
1225
|
+
if (r.body != null) headers["x-sb-raw-body"] = "1";
|
|
1226
|
+
return __sbRawBodyResponse(r.body, { status: r.status || (r.found ? 200 : 404), headers });
|
|
1144
1227
|
},
|
|
1145
1228
|
};
|
|
1146
1229
|
}
|
|
@@ -1168,7 +1251,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1168
1251
|
});
|
|
1169
1252
|
const respHeaders = new Headers();
|
|
1170
1253
|
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
1171
|
-
|
|
1254
|
+
// #176: wire bytes from another deployment, not a string this handler
|
|
1255
|
+
// built -- must not be re-encoded if the handler proxies it straight
|
|
1256
|
+
// through (see the assets binding for the same reasoning).
|
|
1257
|
+
if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
|
|
1258
|
+
return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
|
|
1172
1259
|
},
|
|
1173
1260
|
};
|
|
1174
1261
|
}
|
|
@@ -1190,7 +1277,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
1190
1277
|
});
|
|
1191
1278
|
const respHeaders = new Headers();
|
|
1192
1279
|
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
1193
|
-
|
|
1280
|
+
// #176: bytes from an outbound HTTP response, not a string this handler
|
|
1281
|
+
// built -- must not be re-encoded if the handler proxies it straight
|
|
1282
|
+
// through (see the assets binding for the same reasoning).
|
|
1283
|
+
if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
|
|
1284
|
+
return __sbRawBodyResponse(r.body, { status: r.status || 502, headers: respHeaders });
|
|
1194
1285
|
};
|
|
1195
1286
|
}
|
|
1196
1287
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// sproutboat #181 — __sbFromUtf8 lives inline in native-fetch-prelude.js (the
|
|
2
|
+
// prelude is prepended as text and cannot import), so this test lifts that one
|
|
3
|
+
// pure-JS function out and exercises it directly. If it moves or its boundary
|
|
4
|
+
// markers change, this fails loudly rather than silently testing nothing.
|
|
5
|
+
import { expect, test } from "bun:test";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
|
|
10
|
+
const start = src.indexOf("function __sbFromUtf8(");
|
|
11
|
+
const end = src.indexOf("// sproutboat #181 — wrap a Response");
|
|
12
|
+
if (start === -1 || end === -1 || end < start) throw new Error("__sbFromUtf8 block not found in prelude");
|
|
13
|
+
|
|
14
|
+
// oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
|
|
15
|
+
const load = new Function(`${src.slice(start, end)}\nreturn { __sbFromUtf8 };`);
|
|
16
|
+
const { __sbFromUtf8 } = load();
|
|
17
|
+
|
|
18
|
+
/** Build a bytestring (one char per raw byte) the way __sbToBytes/porf_native_fetch_alloc_bytestring would, from real UTF-8 bytes. */
|
|
19
|
+
function toBytestring(text) {
|
|
20
|
+
const bytes = new TextEncoder().encode(text);
|
|
21
|
+
let out = "";
|
|
22
|
+
for (const b of bytes) out += String.fromCharCode(b);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("decodes real UTF-8 bytes back to the original text", () => {
|
|
27
|
+
const text = "porffor · src/index.js → 1.25 MB · x86_64-musl ✓";
|
|
28
|
+
expect(__sbFromUtf8(toBytestring(text))).toBe(text);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("multi-byte and 4-byte (surrogate-pair) sequences round-trip", () => {
|
|
32
|
+
expect(__sbFromUtf8(toBytestring("café"))).toBe("café");
|
|
33
|
+
expect(__sbFromUtf8(toBytestring("🌱"))).toBe("🌱");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a genuine Latin-1-range string with no real UTF-8 sequences passes through unchanged", () => {
|
|
37
|
+
// "héllo" here is a real JS string (one code point per character), not
|
|
38
|
+
// re-encoded bytes -- exactly what a handler-built bytestring looks like.
|
|
39
|
+
// This must NOT be decoded, or it corrupts ordinary accented text.
|
|
40
|
+
expect(__sbFromUtf8("héllo")).toBe("héllo");
|
|
41
|
+
expect(__sbFromUtf8("café")).toBe("café");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a lead byte with an invalid or missing continuation passes through raw", () => {
|
|
45
|
+
expect(__sbFromUtf8(String.fromCharCode(0xe9))).toBe(String.fromCharCode(0xe9));
|
|
46
|
+
expect(__sbFromUtf8(String.fromCharCode(0xc2))).toBe(String.fromCharCode(0xc2)); // truncated 2-byte lead
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("empty and pure-ASCII strings are untouched", () => {
|
|
50
|
+
expect(__sbFromUtf8("")).toBe("");
|
|
51
|
+
expect(__sbFromUtf8("hello world")).toBe("hello world");
|
|
52
|
+
});
|