@sproutboat/runtime 0.18.0 → 0.19.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/WEB_STREAMS.md +2 -2
- package/package.json +1 -1
- package/src/ingress-utf8.test.js +3 -2
- package/src/native-fetch-prelude.js +29 -9
- package/test/native-bodies.ts +7 -0
- package/test/native-request.ts +6 -2
- package/test/native-utf8.ts +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.19.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6676ead: `env.BUCKET.put(key, request.body)`, `env.KV.put(key, request.body)` and `fetch(url, { body: request.body })` store and send the request body again. Since 0.19.0, `body` is a `ReadableStream`, and these took its string form. Incoming request bodies are also read as the bytes they are, so `arrayBuffer()`, `blob()`, `body` and KV `put` no longer UTF-8 encode a non-ASCII upload twice. R2 `put` accepts bytes as well.
|
|
8
|
+
|
|
9
|
+
## 0.19.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 4274bef: `Response.body` and `Request.body` are `ReadableStream`s for every body type, as in the Fetch spec and workerd. Before, they held the value given (a string, bytes or a `Blob`). `res.body.getReader()`, `res.body.pipeThrough(...)` and `new Response(other.body)` now work on any response, including the incoming request. The value as given stays internal, so responses are still written with a fixed length unless the body really is a stream. A body whose stream has been locked can no longer be read with `text()` and the other readers, which throw a `TypeError`.
|
|
14
|
+
|
|
3
15
|
## 0.18.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/WEB_STREAMS.md
CHANGED
|
@@ -20,8 +20,8 @@ runtime links no zlib. Compression writes one fixed-Huffman block per chunk as i
|
|
|
20
20
|
(close to zlib on text: 252 bytes against 221 for 22.5 KB); decompression inflates all
|
|
21
21
|
block types once the input ends and checks the CRC-32 or Adler-32. Response and Request
|
|
22
22
|
bodies that are ReadableStreams are read by text(), json(), arrayBuffer() and blob(), and
|
|
23
|
-
their chunks must be bytes, as in workerd.
|
|
24
|
-
|
|
23
|
+
their chunks must be bytes, as in workerd. `body` is a ReadableStream for every body type,
|
|
24
|
+
as in Fetch: a stream body itself, otherwise a stream made once over the bytes.
|
|
25
25
|
BYOB buffer detachment requires transfer support from the underlying engine;
|
|
26
26
|
the polyfill cannot supply that engine primitive. Full Web Platform Tests
|
|
27
27
|
conformance has not been established for Porffor.
|
package/package.json
CHANGED
package/src/ingress-utf8.test.js
CHANGED
|
@@ -6,9 +6,10 @@ const ingress = src.slice(
|
|
|
6
6
|
src.indexOf("function __sbDecodeIngress("),
|
|
7
7
|
src.indexOf("globalThis.__sbEntry ="),
|
|
8
8
|
);
|
|
9
|
-
|
|
9
|
+
const rawBody = src.slice(src.indexOf("function __sbRawBody("), src.indexOf("function __sbHasStreamBody("));
|
|
10
|
+
if (!decoder || !ingress || !rawBody) throw new Error("Ingress decoder source not found");
|
|
10
11
|
// 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 load = new Function(`${decoder}\n${rawBody}\n${ingress}\nreturn __sbDecodeIngress;`);
|
|
12
13
|
const decode = load();
|
|
13
14
|
const raw = (text) =>
|
|
14
15
|
Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
|
|
@@ -3092,7 +3092,7 @@ function __sbCpuMs() {
|
|
|
3092
3092
|
function __sbTagCpu(res, t0) {
|
|
3093
3093
|
const cpu = __sbCpuMs() - t0;
|
|
3094
3094
|
try {
|
|
3095
|
-
const body = res
|
|
3095
|
+
const body = __sbRawBody(res);
|
|
3096
3096
|
if (__sbIsStr(body) && !res.headers.has("set-cookie")) {
|
|
3097
3097
|
const headers = {};
|
|
3098
3098
|
res.headers.forEach(function (value, name) {
|
|
@@ -4045,7 +4045,14 @@ function __sbFromUtf8(bytes) {
|
|
|
4045
4045
|
// #232 — a fetch() request body for the wire: text as-is, and an ArrayBuffer or
|
|
4046
4046
|
// any view as its bytes (one char per byte) flagged bodyBinary, instead of
|
|
4047
4047
|
// String(view), which sent "0,1,2,...".
|
|
4048
|
+
// A body stream the runtime made over a value (Response/Request body): that value. Bindings
|
|
4049
|
+
// that take a body at once (R2 and KV put, outbound fetch) read it without draining the
|
|
4050
|
+
// stream, so put(key, request.body) works as in Workers. Other values pass through.
|
|
4051
|
+
function __sbUnwrapBody(value) {
|
|
4052
|
+
return value != null && value._sbBody !== undefined ? value._sbBody : value;
|
|
4053
|
+
}
|
|
4048
4054
|
function __sbRequestBody(body) {
|
|
4055
|
+
body = __sbUnwrapBody(body);
|
|
4049
4056
|
if (body == null) return { body: null };
|
|
4050
4057
|
if (!__sbIsStr(body) && body.byteLength !== undefined) {
|
|
4051
4058
|
const bytes = body.buffer !== undefined ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : body;
|
|
@@ -4966,6 +4973,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
4966
4973
|
return type === "json" ? JSON.parse(text) : text;
|
|
4967
4974
|
},
|
|
4968
4975
|
put(key, value, options) {
|
|
4976
|
+
value = __sbUnwrapBody(value);
|
|
4969
4977
|
const msg = { ns, key: String(key), value: "" };
|
|
4970
4978
|
if (value != null && !__sbIsStr(value) && value.byteLength !== undefined) {
|
|
4971
4979
|
// ArrayBuffer or any ArrayBufferView: its bytes, not String(view) ("0,1,2,...").
|
|
@@ -5011,12 +5019,15 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5011
5019
|
const name = bindings.r2[i];
|
|
5012
5020
|
target[name] = {
|
|
5013
5021
|
put(key, value, options) {
|
|
5022
|
+
value = __sbUnwrapBody(value);
|
|
5014
5023
|
const o = options || {};
|
|
5015
5024
|
// #56 — the body goes out of band where the transport allows it.
|
|
5025
|
+
// Bytes go in as bytes: a byte view or the bytes of a body stream (request.body).
|
|
5026
|
+
const raw = value == null ? "" : !__sbIsStr(value) && value.byteLength !== undefined ? __sbToBytes(value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : value) : String(value);
|
|
5016
5027
|
return __sbR2Put(
|
|
5017
5028
|
name,
|
|
5018
5029
|
String(key),
|
|
5019
|
-
|
|
5030
|
+
raw,
|
|
5020
5031
|
o.httpMetadata || {},
|
|
5021
5032
|
o.customMetadata || {},
|
|
5022
5033
|
).object;
|
|
@@ -5189,7 +5200,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5189
5200
|
url,
|
|
5190
5201
|
method: opts.method || "GET",
|
|
5191
5202
|
headers,
|
|
5192
|
-
...__sbRequestBody(opts.body),
|
|
5203
|
+
...__sbRequestBody(opts instanceof Request ? __sbRawBody(opts) : opts.body),
|
|
5193
5204
|
});
|
|
5194
5205
|
const respHeaders = new Headers();
|
|
5195
5206
|
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
@@ -5221,7 +5232,7 @@ globalThis.__sbInstallFetch = function () {
|
|
|
5221
5232
|
if (__sbIsFn(source.forEach)) source.forEach((v, k) => headers.push([k, v]));
|
|
5222
5233
|
else for (const k in source) headers.push([k, source[k]]);
|
|
5223
5234
|
}
|
|
5224
|
-
const body = opts.body !== undefined ? opts.body : req && req
|
|
5235
|
+
const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
|
|
5225
5236
|
const method = opts.method || (req && req.method) || "GET";
|
|
5226
5237
|
const msg = { url, method, headers, ...__sbRequestBody(body) };
|
|
5227
5238
|
const respond = (r) => {
|
|
@@ -5647,8 +5658,14 @@ async function __sbCollectBody(stream) {
|
|
|
5647
5658
|
}
|
|
5648
5659
|
return out;
|
|
5649
5660
|
}
|
|
5661
|
+
// The body as given (a string, bytes or a ReadableStream), not the stream the Fetch-spec
|
|
5662
|
+
// body getter makes over it; plain objects keep their body as is.
|
|
5663
|
+
function __sbRawBody(message) {
|
|
5664
|
+
if (message == null) return null;
|
|
5665
|
+
return message._bodyInit !== undefined ? message._bodyInit : message.body;
|
|
5666
|
+
}
|
|
5650
5667
|
function __sbHasStreamBody(res) {
|
|
5651
|
-
return res != null && res
|
|
5668
|
+
return res != null && __sbRawBody(res) instanceof ReadableStream;
|
|
5652
5669
|
}
|
|
5653
5670
|
|
|
5654
5671
|
// B19: on a host that streams (a standalone sprout's native server defines sb_stream_*), a
|
|
@@ -5697,7 +5714,7 @@ function __sbStreamingSupported() {
|
|
|
5697
5714
|
return __sbStreaming === 1;
|
|
5698
5715
|
}
|
|
5699
5716
|
function __sbStreamResponse(response) {
|
|
5700
|
-
const stream = response
|
|
5717
|
+
const stream = __sbRawBody(response);
|
|
5701
5718
|
const id = __sbStreamOp("o", "0", "");
|
|
5702
5719
|
response.headers.set("x-sb-stream", id);
|
|
5703
5720
|
response.body = "";
|
|
@@ -5733,7 +5750,7 @@ async function __sbFetchWithDrain(res, tasks) {
|
|
|
5733
5750
|
r = res && __sbIsFn(res.then) ? await res : res;
|
|
5734
5751
|
if (__sbHasStreamBody(r)) {
|
|
5735
5752
|
if (__sbStreamingSupported()) __sbStreamResponse(r);
|
|
5736
|
-
else r.body = await __sbCollectBody(r
|
|
5753
|
+
else r.body = await __sbCollectBody(__sbRawBody(r));
|
|
5737
5754
|
}
|
|
5738
5755
|
} catch (error) {
|
|
5739
5756
|
// #179 — a rejected handler promise must not take the process down with it.
|
|
@@ -5794,8 +5811,10 @@ async function __sbQueueWithDrain(result, tasks) {
|
|
|
5794
5811
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
5795
5812
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
5796
5813
|
function __sbDecodeIngress(request) {
|
|
5797
|
-
const raw = request
|
|
5814
|
+
const raw = __sbRawBody(request);
|
|
5798
5815
|
if (raw == null) return;
|
|
5816
|
+
// Raw bytes, one character each: arrayBuffer(), blob() and body read them as bytes.
|
|
5817
|
+
request._bodyLatin1 = true;
|
|
5799
5818
|
let decoded = null;
|
|
5800
5819
|
request.text = function () {
|
|
5801
5820
|
if (decoded === null) decoded = __sbFromUtf8(raw);
|
|
@@ -5986,7 +6005,8 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
5986
6005
|
|
|
5987
6006
|
function __sbReadJson(request) {
|
|
5988
6007
|
try {
|
|
5989
|
-
|
|
6008
|
+
const body = __sbRawBody(request);
|
|
6009
|
+
return JSON.parse(body == null ? "{}" : String(body));
|
|
5990
6010
|
} catch {
|
|
5991
6011
|
return {};
|
|
5992
6012
|
}
|
package/test/native-bodies.ts
CHANGED
|
@@ -18,6 +18,13 @@ const handler = `export default {async fetch(request) {
|
|
|
18
18
|
if (path === "/stream-strings") return new Response(new ReadableStream({ start(c) { c.enqueue("text"); c.close(); } }));
|
|
19
19
|
if (path === "/form") { const f = new FormData(); f.append("a", "1"); f.append("f", new Blob(["xy"]), "n.txt"); return new Response(f); }
|
|
20
20
|
const cases = [
|
|
21
|
+
// body is a ReadableStream for every body type (Fetch), and readers still work.
|
|
22
|
+
() => [new Response("hi").body instanceof ReadableStream, new Response(new Uint8Array([1])).body instanceof ReadableStream, new Response(null).body, new Request("http://a.test", { method: "POST", body: "q" }).body instanceof ReadableStream].join(","),
|
|
23
|
+
async () => new Response(new Response("abc").body).text(),
|
|
24
|
+
async () => { const r = new Response("x"); const b = r.body; return (b === r.body) + ":" + (await r.text()); },
|
|
25
|
+
async () => { const r = new Response("x"); r.body.getReader(); try { await r.text(); return "no throw"; } catch (e) { return e.name; } },
|
|
26
|
+
async () => { const reader = new Response("chunk").body.getReader(); const { value, done } = await reader.read(); return new TextDecoder().decode(value) + ":" + done + ":" + (await reader.read()).done; },
|
|
27
|
+
async () => { const r = new Response("cloned"); return (await r.clone().text()) + "|" + (await r.text()); },
|
|
21
28
|
// HTMLRewriter, against Bun's (lol-html, as in workerd): the W2 cases and more.
|
|
22
29
|
() => typeof HTMLRewriter,
|
|
23
30
|
() => new HTMLRewriter().on("h1", { element(e) { e.setInnerContent("x"); } }).transform(new Response("<h1>a</h1><p>b</p>")).text(),
|
package/test/native-request.ts
CHANGED
|
@@ -18,7 +18,9 @@ const CASES = [
|
|
|
18
18
|
`new Request(new URL("http://a.test/u")).url`,
|
|
19
19
|
`new Request("http://a.test/x", { method: "post" }).method`,
|
|
20
20
|
];
|
|
21
|
-
const handler = `export default {async fetch() {
|
|
21
|
+
const handler = `export default {async fetch(request) {
|
|
22
|
+
// The incoming request: its body is a ReadableStream, and text() still reads it.
|
|
23
|
+
if (request.method === "POST") { const viaStream = await new Response(request.body).text(); return new Response((request.body instanceof ReadableStream) + ":" + viaStream); }
|
|
22
24
|
const cases = [
|
|
23
25
|
${CASES.map((c) => ` () => (${c}),`).join("\n")}
|
|
24
26
|
];
|
|
@@ -91,7 +93,9 @@ try {
|
|
|
91
93
|
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/`)).text()) as string[];
|
|
92
94
|
const diffs = CASES.flatMap((c, i) => (got[i] === want[i] ? [] : [`${c}\n want ${JSON.stringify(want[i])}\n got ${JSON.stringify(got[i])}`]));
|
|
93
95
|
assert.deepEqual(diffs, [], `Request copies differ from Bun in ${diffs.length} of ${CASES.length} cases:\n${diffs.join("\n")}`);
|
|
94
|
-
|
|
96
|
+
const posted = await (await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "posted é" })).text();
|
|
97
|
+
assert.equal(posted, "true:posted é", "the incoming request body is a stream and text() reads it");
|
|
98
|
+
console.log(`Native Request copies match Bun in all ${CASES.length} cases; the incoming body is a stream`);
|
|
95
99
|
} finally {
|
|
96
100
|
if (server) {
|
|
97
101
|
server.kill(9);
|
package/test/native-utf8.ts
CHANGED
|
@@ -17,7 +17,14 @@ const text = "/café/日本/😀/x";
|
|
|
17
17
|
const handler = `
|
|
18
18
|
const URL = "shadowed";
|
|
19
19
|
export default {
|
|
20
|
-
async fetch() {
|
|
20
|
+
async fetch(request) {
|
|
21
|
+
// put(key, request.body) and fetch(url, { body: request.body }) are the Workers idiom:
|
|
22
|
+
// body is a ReadableStream, and the bindings take the value it was made from.
|
|
23
|
+
if (request.method === "POST") {
|
|
24
|
+
env.UPLOADS.put("upload", request.body);
|
|
25
|
+
await env.KV.put("upload", request.body);
|
|
26
|
+
return Response.json({ r2: env.UPLOADS.get("upload").text(), kv: await env.KV.get("upload") });
|
|
27
|
+
}
|
|
21
28
|
await env.KV.put("k", ${JSON.stringify(text)});
|
|
22
29
|
const kv = await env.KV.get("k");
|
|
23
30
|
await env.DB.prepare("CREATE TABLE t (v TEXT)").run();
|
|
@@ -38,7 +45,7 @@ function freePort(): number {
|
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-utf8-"));
|
|
41
|
-
const bindings = { ...EMPTY_BINDINGS, kv: ["KV"], d1: ["DB"] };
|
|
48
|
+
const bindings = { ...EMPTY_BINDINGS, kv: ["KV"], d1: ["DB"], r2: ["UPLOADS"] };
|
|
42
49
|
const broker = createBroker({
|
|
43
50
|
db: ":memory:",
|
|
44
51
|
token: "native-utf8",
|
|
@@ -113,7 +120,9 @@ try {
|
|
|
113
120
|
);
|
|
114
121
|
assert.match(body.error, /tabellà/);
|
|
115
122
|
assert.equal(body.url, "shadowed");
|
|
116
|
-
|
|
123
|
+
const stored = await (await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "uploaded é" })).json();
|
|
124
|
+
assert.deepEqual(stored, { r2: "uploaded é", kv: "uploaded é" }, "put(key, request.body) stores the request body");
|
|
125
|
+
console.log("Non-ASCII KV, D1 and error text round-trip through the broker; a handler-level URL stays scoped; put(key, request.body) stores the body");
|
|
117
126
|
} finally {
|
|
118
127
|
if (server) {
|
|
119
128
|
server.kill(9);
|