@sproutboat/runtime 0.16.0 → 0.17.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 +68 -0
- package/WEB_STREAMS.md +7 -3
- package/package.json +1 -1
- package/src/asset-rules.test.js +125 -0
- package/src/egress.test.ts +248 -5
- package/src/gen-streams.ts +4 -5
- package/src/native-fetch-prelude.js +776 -112
- package/src/streams-entry.js +17 -3
- package/src/transport-broker.js +12 -0
- package/src/transport-embedded.js +403 -71
- package/test/native-bodies.ts +136 -0
- package/test/native-errors.ts +101 -0
- package/test/native-events.ts +102 -0
- package/test/native-iterators.ts +6 -1
- package/test/native-json.ts +102 -0
- package/test/native-raw-body.ts +118 -0
- package/test/native-request.ts +101 -0
- package/test/native-response-defaults.ts +98 -0
- package/test/native-streaming.ts +117 -0
- package/test/native-url.ts +145 -0
- package/test/native-waituntil.ts +107 -0
- package/test/native-web-apis.ts +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3dabc97: Scheduled, queue and Durable Object alarm triggers answer `204` with no body again. The prelude built that reply as `new Response("", { status: 204 })`, which the Fetch-spec `Response` of 0.17.0 rejects (an empty string is still a body), so the first cron or alarm failed, and on the alarm path the error took the server down.
|
|
8
|
+
|
|
9
|
+
## 0.17.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- a96a443: Apply static asset redirects and header rules in dev and standalone asset bindings, preserving binary response bodies.
|
|
14
|
+
- 6e88627: Standalone sprouts stream `ReadableStream` response bodies. The status and headers go out as soon as the handler returns, and each chunk reaches the client as it is produced (chunked encoding), so Server-Sent Events, progress output and long responses work. A client that disconnects cancels the stream. Hosts without the streaming server (sproutd's workers, for now) still read the stream to the end and send it buffered.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 5d4d40b: A host that runs an event loop can let `fetch()` run without blocking the sprout: after `sb_async_enable()`, outbound requests run on up to 8 threads and resolve their promises when the host calls `sb_async_drain()`. Nothing changes for standalone sprouts, where `fetch()` stays synchronous.
|
|
19
|
+
- 6880f60: Add an opt-in bounded multi-session broker worker with private supervisor control.
|
|
20
|
+
Fence queue acknowledgements to durable delivery claims and retain recoverable alarm deliveries across process crashes in broker and embedded transports.
|
|
21
|
+
|
|
22
|
+
Resume native R2 downloads until completion or renewed backpressure instead of leaving large responses stalled.
|
|
23
|
+
|
|
24
|
+
Retain partially written TCP replies until drain and serialize pipelined replies so large broker-backed R2 reads cannot truncate or interleave frames.
|
|
25
|
+
|
|
26
|
+
Honor wildcard, weak and list-based If-None-Match conditions on broker direct downloads.
|
|
27
|
+
|
|
28
|
+
Move D1 queries, batches, exec and backups off the shared broker event loop into two lazy SQL threads per broker process. Preserve database affinity, transaction rollback and session isolation, and bound queued work with per-session admission and queue deadlines. Running SQLite statements remain non-preemptible; keep shared mode opt-in.
|
|
29
|
+
|
|
30
|
+
Enforce shared D1 execution budgets through a capability-protected native SQLite progress hook. Interrupted batches roll back and lanes remain usable. A broker watchdog revokes all sessions if native work cannot be interrupted; uncertain SQL outcomes are never automatically retried. Build the small SQLite extension during host/image preparation.
|
|
31
|
+
- 846519f: Fixes found by the new differential conformance suite against workerd:
|
|
32
|
+
|
|
33
|
+
- `JSON.stringify` of a cyclic object throws `TypeError` instead of crashing the process (it recursed until the native stack overflowed), so one request can no longer take a worker down.
|
|
34
|
+
- `structuredClone` follows the structured-clone algorithm: cycles, `Map`, `Set`, `Date`, `RegExp`, typed arrays, `DataView` and errors survive the copy, and functions and symbols throw `DataCloneError`. It was a JSON round-trip that lost every non-JSON type and also crashed on cycles.
|
|
35
|
+
- `Event`, `CustomEvent`, `EventTarget`, `AbortSignal` (`abort`, `timeout`, `any`, `throwIfAborted`) and `AbortController` exist.
|
|
36
|
+
- `Object.prototype.toString` names typed arrays (`[object Uint8Array]`, not `[object Object]`).
|
|
37
|
+
- b47aad4: Outbound `fetch()` sends the port in `Host` when it isn't the scheme's default (`Host: api.example.com:8080`), as HTTP/1.1 requires. A sandboxed host can now open outbound connections on a sprout's behalf: with `SB_EGRESS_FD` set, `fetch()` asks that socket for a connected socket instead of resolving and connecting itself.
|
|
38
|
+
- f19ca0d: `error.stack` is a string, V8's first line (`TypeError: message`), instead of `undefined`, for every error type including errors the runtime throws. Porffor keeps no frames to list after it.
|
|
39
|
+
- ddb0f9d: Response, Request and Blob follow the Fetch spec, and binary bodies reach the client intact:
|
|
40
|
+
|
|
41
|
+
- A `Uint8Array` or `ArrayBuffer` response body is sent as its bytes. It was sent as text: `new Response(new Uint8Array([104, 105]))` reached the client as `104,105`, and an `ArrayBuffer` as `[object ArrayBuffer]`.
|
|
42
|
+
- `Response` has `statusText`, `type`, `bodyUsed` and `Response.error()`. A status outside 200–599 throws `RangeError`, a body with 101/204/205/304 throws `TypeError`, and `arrayBuffer()`/`text()` handle byte bodies (UTF-8 both ways).
|
|
43
|
+
- `Request` normalizes its URL and rejects relative or invalid ones, has `redirect` and `bodyUsed`, rejects a body on GET/HEAD, and sets the default content type for string and `URLSearchParams` bodies.
|
|
44
|
+
- A body can be read once: a second `text()`/`json()`/`arrayBuffer()`/`blob()` throws `TypeError`.
|
|
45
|
+
- `Blob` holds bytes, with `size`, `type`, `slice` (negative indices too), and promise-returning `text`/`arrayBuffer`/`bytes`. `File` exists. `FormData` stores a `Blob` value as a `File` named by its filename, and `new Response(formData)` sends multipart/form-data.
|
|
46
|
+
- f201b58: `fetch(request)` sends the Request's method, headers and body (it sent a plain GET), and `init` overrides them field by field as in workerd. `fetch(new URL(...))` works. An uncaught exception's 500 response has no content type again, as in workerd.
|
|
47
|
+
- 08d0192: Builds are fast again: the URLSearchParams serializer added in the previous release doubled native compile time (a hello-world handler went from 6.6 s to 13.4 s); it now encodes in one pass and compiles in 6.8 s.
|
|
48
|
+
- 6d6ae43: `Headers` is iterable (`new Map(headers)`, `[...headers]`, `Object.fromEntries(headers)`), and `new Headers([["name", "value"]])` reads the pairs instead of producing headers named "0", "1"...
|
|
49
|
+
- 9fdf3fa: Upgrade the verified vendored Porffor pin to Alpha 18 (`deda494`). Alpha 18 brings spec BigInt arithmetic and bitwise operators, iterator helpers, Set methods, `Array.fromAsync`, Atomics, resizable ArrayBuffers, explicit resource management, a rewritten Temporal, an on-demand Intl polyfill, and many RegExp, Promise and Object spec fixes. Three downstream workarounds are dropped because upstream now covers them: the typed-array read and write bounds checks (#238, #241), the `toJSON` step in `JSON.stringify` (Alpha 18 also adds the replacer argument), and the Web Streams adapter for dynamic array iterator lookup. The remaining patches are unchanged.
|
|
50
|
+
- 4c78d83: Match the Fetch spec and workerd for common responses: string bodies default to `text/plain;charset=UTF-8`, `Response.json` sets `application/json`, and `Response.redirect` serializes its URL (`https://example.com` becomes `https://example.com/`) with a null body.
|
|
51
|
+
- 088cad0: Shared-object (split) builds no longer crash on start or lose the Web Streams classes.
|
|
52
|
+
|
|
53
|
+
- **Toolchain:** split output prefixes module globals with `pg_`. A global named like a libc symbol (the prelude's `clone`, or an app's `hash` or `search`) bound to libc instead, so the runtime wrote into libc's code and every worker crashed. Internal `__` globals keep their names. Standalone builds are unchanged, because their globals are `static`.
|
|
54
|
+
- **Runtime:** the Web Streams polyfill installs each class with its own `globalThis.X =` assignment. Where the prelude is a separate unit, Porffor resolved `new ReadableStream()` in app code at compile time and, seeing only a dynamic install, compiled it to a ReferenceError. `gen-streams.ts` now fails if an export of the polyfill is not installed.
|
|
55
|
+
- 6e42051: A `ReadableStream` response body is sent. The stream is read to the end and the body sent buffered (at most 32 MiB). Before, the client received the text `[object ReadableStream]`. As in workerd, chunks must be bytes, and a stream of strings answers 500. Streamed (progressive) delivery is a separate step.
|
|
56
|
+
- 9dc109d: `TextEncoderStream` and `TextDecoderStream` exist. The decoder runs `TextDecoder` in streaming mode, so a character split across chunks decodes correctly, and the encoder holds a lone high surrogate until the next chunk.
|
|
57
|
+
- 28b0c39: `URL` behaves like the WHATWG URL: `new URL(path, base)`, setters for every part (`url.hostname = ...`, `port`, `protocol`, `host`, `search`, `hash`, `href`), default ports dropped, dot segments resolved, percent-encoding, `URL.canParse`, and `searchParams` changes written back into the URL. `JSON.stringify` calls `toJSON`, so a `Date` serializes as its ISO string (it was `{}`) and a `URL` as its href.
|
|
58
|
+
- 6b64985: A fetch handler's response no longer waits for its `ctx.waitUntil` work: the response goes out as soon as the handler returns and the background work keeps running, still capped at 25 s, as in workerd. A handler that scheduled 1.5 s of background work used to answer 1.5 s late. The cap's timer is also cleared once the work finishes, so a sprout no longer keeps a timer pending for 25 s after every `waitUntil`. `scheduled` and `queue` handlers still finish their `waitUntil` work before they reply.
|
|
59
|
+
- eec94f1: Web API fixes found by the conformance suite against workerd:
|
|
60
|
+
|
|
61
|
+
- `Headers` follows the Fetch spec: `get` joins repeated values with `", "`, iteration is sorted by name with values combined (each `Set-Cookie` listed separately), `getSetCookie()` exists, values are trimmed, and invalid names or values throw `TypeError`. Each appended header still reaches the wire as its own line.
|
|
62
|
+
- `TextDecoder` is the WHATWG UTF-8 decoder. Characters outside the BMP (emoji) decode correctly, invalid bytes become U+FFFD (or throw with `fatal`), the BOM is stripped unless `ignoreBOM`, and `{ stream: true }` keeps an incomplete sequence for the next chunk.
|
|
63
|
+
- `toLowerCase`/`toUpperCase` map non-ASCII letters (Latin, Greek, Cyrillic and the rest of the BMP's simple mappings). They mapped ASCII only.
|
|
64
|
+
- `new URLSearchParams(init)` accepts a record, a sequence of pairs or another `URLSearchParams`. Only strings worked; `{ a: "1" }` gave `%5Bobject+Object%5D=`.
|
|
65
|
+
- `atob` ignores ASCII whitespace and throws `InvalidCharacterError` on invalid input, and `btoa` throws on characters above U+00FF.
|
|
66
|
+
- `queueMicrotask` exists.
|
|
67
|
+
- URL hosts are converted to ASCII (Punycode): `new URL("http://münchen.test").host` is `xn--mnchen-3ya.test`.
|
|
68
|
+
- `crypto.subtle.digest` supports SHA-1 and MD5, and HMAC works with SHA-1. `exportKey("raw")`, `generateKey` for HMAC and workerd's `crypto.subtle.timingSafeEqual` exist, and imported keys report `algorithm.length`. Errors carry WebCrypto's names (`NotSupportedError`, `InvalidAccessError`), and `getRandomValues` over 65,536 bytes throws `QuotaExceededError`.
|
|
69
|
+
- `setTimeout(fn, 0)` and `setInterval(fn, 0)` fire on Linux. uSockets arms timers with `timerfd_settime`, and a zero expiry disarms the timer, so a zero-delay timer never ran (macOS fired it). Timers are armed at 1 ms at least.
|
|
70
|
+
|
|
3
71
|
## 0.16.0
|
|
4
72
|
|
|
5
73
|
### Minor Changes
|
package/WEB_STREAMS.md
CHANGED
|
@@ -9,9 +9,13 @@ Native regression tests cover default readers, locking, pending reads,
|
|
|
9
9
|
cancellation, source errors, array conversion, writable sinks, pipeTo,
|
|
10
10
|
pipeThrough, transforms, tee, pull sources, byte streams and BYOB reads.
|
|
11
11
|
This is API support for streams in memory, not progressive HTTP transport.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
A ReadableStream response body is streamed by a standalone sprout's native server:
|
|
13
|
+
the status and headers go out at once and each chunk is written as it is produced
|
|
14
|
+
(chunked encoding), so Server-Sent Events and long responses work. Chunks must be bytes.
|
|
15
|
+
Hosts that do not stream yet (sproutd's workers) read the stream to the end and send it
|
|
16
|
+
buffered, at most 32 MiB. Request bodies and outbound fetch remain buffered.
|
|
17
|
+
TextEncoderStream and TextDecoderStream are included; CompressionStream and
|
|
18
|
+
DecompressionStream are not.
|
|
15
19
|
BYOB buffer detachment requires transfer support from the underlying engine;
|
|
16
20
|
the polyfill cannot supply that engine primitive. Full Web Platform Tests
|
|
17
21
|
conformance has not been established for Porffor.
|
package/package.json
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
matchRedirect,
|
|
5
|
+
matchHeaders,
|
|
6
|
+
parseHeaders,
|
|
7
|
+
parseRedirects,
|
|
8
|
+
} from "../../assets/src/assets.ts";
|
|
9
|
+
|
|
10
|
+
const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
|
|
11
|
+
const start = source.indexOf("function __sbAssetRedirect(");
|
|
12
|
+
const end = source.indexOf("// Installed only when the project declares bindings.", start);
|
|
13
|
+
if (start < 0 || end < start) throw new Error("asset rule helpers not found");
|
|
14
|
+
// oxlint-disable-next-line no-function-constructor -- test the text-only native prelude
|
|
15
|
+
const load = new Function(
|
|
16
|
+
"__sbRawBodyResponse",
|
|
17
|
+
source.slice(start, end) +
|
|
18
|
+
"\nreturn { redirect: __sbAssetRedirect, matches: __sbAssetHeaderMatches, response: __sbAssetResponse };",
|
|
19
|
+
);
|
|
20
|
+
const { redirect, matches, response } = load((body, init) => new Response(body, init));
|
|
21
|
+
|
|
22
|
+
test("native redirect matching agrees with shared asset rules", () => {
|
|
23
|
+
const rules = parseRedirects(
|
|
24
|
+
"/old /new 301\n/blog/:slug /posts/:slug\n/docs/* https://example.test/:splat 307",
|
|
25
|
+
);
|
|
26
|
+
for (const path of [
|
|
27
|
+
"/old",
|
|
28
|
+
"/old/",
|
|
29
|
+
"/blog/a",
|
|
30
|
+
"/blog/a/b",
|
|
31
|
+
"/docs",
|
|
32
|
+
"/docs/",
|
|
33
|
+
"/docs/a/b",
|
|
34
|
+
"/missing",
|
|
35
|
+
]) {
|
|
36
|
+
for (const rule of rules) expect(redirect(rule, path)).toBe(matchRedirect(rule, path));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("native header matching agrees with shared asset rules", () => {
|
|
41
|
+
const rules = parseHeaders(
|
|
42
|
+
"/*\n X-Test: yes\n/assets/*\n Cache-Control: public\n/exact\n ! ETag",
|
|
43
|
+
);
|
|
44
|
+
for (const path of ["/", "/assets", "/assets/a/b", "/exact", "/exact/deep", "/missing"]) {
|
|
45
|
+
expect(rules.filter((rule) => matches(rule.pattern, path))).toEqual(matchHeaders(rules, path));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("redirects override files and SPA fallback, with the first matching rule winning", () => {
|
|
50
|
+
const rules = { redirects: parseRedirects("/old /first 301\n/old /second 302") };
|
|
51
|
+
for (const found of [true, false]) {
|
|
52
|
+
const result = response("/old", {
|
|
53
|
+
found,
|
|
54
|
+
body: "asset",
|
|
55
|
+
type: "text/html",
|
|
56
|
+
hash: "abc",
|
|
57
|
+
rules,
|
|
58
|
+
});
|
|
59
|
+
expect(result.status).toBe(301);
|
|
60
|
+
expect(result.headers.get("location")).toBe("/first");
|
|
61
|
+
expect(result.headers.has("etag")).toBe(false);
|
|
62
|
+
expect(result.headers.has("x-sb-raw-body")).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("header rules replace and remove defaults in order without losing the raw-byte marker", async () => {
|
|
67
|
+
const rules = {
|
|
68
|
+
headers: parseHeaders(
|
|
69
|
+
"/*\n X-Test: first\n Cache-Control: public\n/assets/*\n X-Test: second\n ! ETag\n ! x-sb-raw-body",
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
const result = response("/assets/deep/file.bin", {
|
|
73
|
+
found: true,
|
|
74
|
+
status: 200,
|
|
75
|
+
body: "binary",
|
|
76
|
+
type: "application/octet-stream",
|
|
77
|
+
hash: "abc",
|
|
78
|
+
rules,
|
|
79
|
+
});
|
|
80
|
+
expect(result.headers.get("x-test")).toBe("second");
|
|
81
|
+
expect(result.headers.get("cache-control")).toBe("public");
|
|
82
|
+
expect(result.headers.has("etag")).toBe(false);
|
|
83
|
+
expect(result.headers.get("x-sb-raw-body")).toBe("1");
|
|
84
|
+
expect(await result.text()).toBe("binary");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("SPA/custom-404 assets receive headers, generated missing responses do not", () => {
|
|
88
|
+
const rules = { headers: parseHeaders("/*\n X-Test: asset") };
|
|
89
|
+
expect(
|
|
90
|
+
response("/missing", {
|
|
91
|
+
status: 200,
|
|
92
|
+
found: true,
|
|
93
|
+
type: "text/html",
|
|
94
|
+
body: "shell",
|
|
95
|
+
rules,
|
|
96
|
+
}).headers.get("x-test"),
|
|
97
|
+
).toBe("asset");
|
|
98
|
+
const custom = response("/missing", {
|
|
99
|
+
status: 404,
|
|
100
|
+
found: false,
|
|
101
|
+
type: "text/html",
|
|
102
|
+
body: "404 page",
|
|
103
|
+
rules,
|
|
104
|
+
});
|
|
105
|
+
expect(custom.status).toBe(404);
|
|
106
|
+
expect(custom.headers.get("x-test")).toBe("asset");
|
|
107
|
+
expect(
|
|
108
|
+
response("/missing", { status: 404, found: false, body: "Not Found", rules }).headers.has(
|
|
109
|
+
"x-test",
|
|
110
|
+
),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a set wins over an unset in the same header block, matching edge behavior", () => {
|
|
115
|
+
const rules = { headers: parseHeaders("/*\n ! ETag\n ETag: custom") };
|
|
116
|
+
expect(
|
|
117
|
+
response("/file", {
|
|
118
|
+
found: true,
|
|
119
|
+
type: "text/plain",
|
|
120
|
+
body: "x",
|
|
121
|
+
hash: "abc",
|
|
122
|
+
rules,
|
|
123
|
+
}).headers.get("etag"),
|
|
124
|
+
).toBe("custom");
|
|
125
|
+
});
|
package/src/egress.test.ts
CHANGED
|
@@ -60,7 +60,8 @@ const ALLOW = "8.8.4.4, 10.9.9.9";
|
|
|
60
60
|
test("private and reserved addresses are blocked, public ones are not", () => {
|
|
61
61
|
for (const address of BLOCKED)
|
|
62
62
|
expect([address, blockedRange(address)]).not.toEqual([address, null]);
|
|
63
|
-
for (const address of PUBLIC)
|
|
63
|
+
for (const address of PUBLIC)
|
|
64
|
+
expect([address, blockedRange(address)]).toEqual([address, null]);
|
|
64
65
|
expect(blockedRange("::ffff:127.0.0.1")).toBe("127.0.0.0/8");
|
|
65
66
|
expect(blockedRange("64:ff9b::a00:1")).toBe("10.0.0.0/8");
|
|
66
67
|
});
|
|
@@ -98,7 +99,10 @@ test("the C table in transport-embedded.js is generated from this one (bun run g
|
|
|
98
99
|
|
|
99
100
|
test("the C check gives the same answer and message as the TS one", () => {
|
|
100
101
|
const source = readFileSync(transportFile, "utf8");
|
|
101
|
-
const block = source.slice(
|
|
102
|
+
const block = source.slice(
|
|
103
|
+
source.indexOf("// EGRESS:BEGIN"),
|
|
104
|
+
source.indexOf("// EGRESS:END"),
|
|
105
|
+
);
|
|
102
106
|
const dir = mkdtempSync(join(tmpdir(), "sb-egress-"));
|
|
103
107
|
try {
|
|
104
108
|
writeFileSync(
|
|
@@ -140,7 +144,9 @@ int main(int argc, char** argv) {
|
|
|
140
144
|
env: { ...process.env, SB_EGRESS_ALLOW: ALLOW },
|
|
141
145
|
});
|
|
142
146
|
const fromC = run.stdout.toString().trimEnd().split("\n");
|
|
143
|
-
const fromTs = addresses.map(
|
|
147
|
+
const fromTs = addresses.map(
|
|
148
|
+
(a) => egressRefusal("h", a, egressAllowList(ALLOW)) ?? "ok",
|
|
149
|
+
);
|
|
144
150
|
expect(fromC).toEqual(fromTs);
|
|
145
151
|
} finally {
|
|
146
152
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -150,6 +156,243 @@ int main(int argc, char** argv) {
|
|
|
150
156
|
test("#174: fetch() needs no config, so every sprout gets it", () => {
|
|
151
157
|
const handler = `export default { async fetch() { return await fetch("https://api.example.com/"); } };`;
|
|
152
158
|
expect(validateHttpSyncSource(handler)).toEqual({ ok: true });
|
|
153
|
-
const prelude = readFileSync(
|
|
154
|
-
|
|
159
|
+
const prelude = readFileSync(
|
|
160
|
+
new URL("./native-fetch-prelude.js", import.meta.url),
|
|
161
|
+
"utf8",
|
|
162
|
+
);
|
|
163
|
+
expect(wrapNativeFetchHandler(handler, prelude)).toContain(
|
|
164
|
+
"__sbInstallFetch();",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("SB_EGRESS_FD: the host connects, the sprout gets the socket", () => {
|
|
169
|
+
const source = readFileSync(transportFile, "utf8");
|
|
170
|
+
const block = source.slice(
|
|
171
|
+
source.indexOf("// EGRESS-FD:BEGIN"),
|
|
172
|
+
source.indexOf("// EGRESS-FD:END"),
|
|
173
|
+
);
|
|
174
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-egress-fd-"));
|
|
175
|
+
try {
|
|
176
|
+
// A fake host on one end of a socketpair: "up" gets a socket whose peer says
|
|
177
|
+
// "hello", "down" a refusal, and the third question is never answered.
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(dir, "check.c"),
|
|
180
|
+
`#include <errno.h>
|
|
181
|
+
#include <pthread.h>
|
|
182
|
+
#include <stdio.h>
|
|
183
|
+
#include <stdlib.h>
|
|
184
|
+
#include <string.h>
|
|
185
|
+
#include <unistd.h>
|
|
186
|
+
#include <sys/socket.h>
|
|
187
|
+
#include <sys/time.h>
|
|
188
|
+
#include <sys/wait.h>
|
|
189
|
+
${block}
|
|
190
|
+
static void answer(int via, const char* text, int fd) {
|
|
191
|
+
union { struct cmsghdr align; char space[CMSG_SPACE(sizeof(int))]; } control;
|
|
192
|
+
struct iovec io = { (void*)text, strlen(text) };
|
|
193
|
+
struct msghdr msg;
|
|
194
|
+
memset(&msg, 0, sizeof(msg));
|
|
195
|
+
msg.msg_iov = &io; msg.msg_iovlen = 1;
|
|
196
|
+
if (fd >= 0) {
|
|
197
|
+
msg.msg_control = control.space; msg.msg_controllen = sizeof(control.space);
|
|
198
|
+
struct cmsghdr* c = CMSG_FIRSTHDR(&msg);
|
|
199
|
+
c->cmsg_level = SOL_SOCKET; c->cmsg_type = SCM_RIGHTS; c->cmsg_len = CMSG_LEN(sizeof(int));
|
|
200
|
+
memcpy(CMSG_DATA(c), &fd, sizeof(fd));
|
|
201
|
+
}
|
|
202
|
+
sendmsg(via, &msg, 0);
|
|
203
|
+
}
|
|
204
|
+
int main(void) {
|
|
205
|
+
int pair[2];
|
|
206
|
+
socketpair(AF_UNIX, SOCK_STREAM, 0, pair);
|
|
207
|
+
if (fork() == 0) {
|
|
208
|
+
close(pair[0]);
|
|
209
|
+
char line[512];
|
|
210
|
+
ssize_t n = read(pair[1], line, sizeof(line) - 1); line[n > 0 ? n : 0] = 0;
|
|
211
|
+
printf("asked %s", line); fflush(stdout);
|
|
212
|
+
int remote[2];
|
|
213
|
+
socketpair(AF_UNIX, SOCK_STREAM, 0, remote);
|
|
214
|
+
write(remote[1], "hello", 5);
|
|
215
|
+
answer(pair[1], "ok\\n", remote[0]);
|
|
216
|
+
n = read(pair[1], line, sizeof(line) - 1); line[n > 0 ? n : 0] = 0;
|
|
217
|
+
printf("asked %s", line); fflush(stdout);
|
|
218
|
+
answer(pair[1], "no fetch() refused: down.test is not allowed\\n", -1);
|
|
219
|
+
n = read(pair[1], line, sizeof(line) - 1);
|
|
220
|
+
_exit(0); // closes without answering
|
|
221
|
+
}
|
|
222
|
+
close(pair[1]);
|
|
223
|
+
char refused[512] = "";
|
|
224
|
+
int fd = sb_egress_fd_connect(pair[0], "up.test", 443, refused, sizeof(refused));
|
|
225
|
+
char got[6] = "";
|
|
226
|
+
if (fd >= 0) read(fd, got, 5);
|
|
227
|
+
printf("fd %s %s\\n", fd >= 0 ? "yes" : "no", got);
|
|
228
|
+
fd = sb_egress_fd_connect(pair[0], "down.test", 80, refused, sizeof(refused));
|
|
229
|
+
printf("fd %s %s\\n", fd >= 0 ? "yes" : "no", refused);
|
|
230
|
+
refused[0] = 0;
|
|
231
|
+
printf("fd %s\\n", sb_egress_fd_connect(pair[0], "gone.test", 80, refused, sizeof(refused)) >= 0 ? "yes" : "no");
|
|
232
|
+
printf("fd %s\\n", sb_egress_fd_connect(pair[0], "after.test", 80, refused, sizeof(refused)) >= 0 ? "yes" : "no");
|
|
233
|
+
wait(0);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
`,
|
|
237
|
+
);
|
|
238
|
+
const cc = Bun.spawnSync([
|
|
239
|
+
"cc",
|
|
240
|
+
"-Wall",
|
|
241
|
+
"-Werror",
|
|
242
|
+
"-Wno-unused-result",
|
|
243
|
+
"-o",
|
|
244
|
+
join(dir, "check"),
|
|
245
|
+
join(dir, "check.c"),
|
|
246
|
+
]);
|
|
247
|
+
expect(cc.stderr.toString()).toBe("");
|
|
248
|
+
const run = Bun.spawnSync([join(dir, "check")]);
|
|
249
|
+
expect(run.stdout.toString().trimEnd().split("\n").sort()).toEqual(
|
|
250
|
+
[
|
|
251
|
+
"asked up.test 443",
|
|
252
|
+
"fd yes hello",
|
|
253
|
+
"asked down.test 80",
|
|
254
|
+
"fd no fetch() refused: down.test is not allowed",
|
|
255
|
+
"fd no",
|
|
256
|
+
"fd no",
|
|
257
|
+
].sort(),
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("the Host header carries a non-default port", () => {
|
|
265
|
+
const source = readFileSync(transportFile, "utf8");
|
|
266
|
+
const between = (from: string, to: string) =>
|
|
267
|
+
source.slice(source.indexOf(from), source.indexOf(to));
|
|
268
|
+
const buffer = between(
|
|
269
|
+
"typedef struct { char* p; size_t len; size_t cap; } sb_buf;",
|
|
270
|
+
"static void sb_putjson(",
|
|
271
|
+
);
|
|
272
|
+
const builder = between(
|
|
273
|
+
"// body_len, not strlen(body)",
|
|
274
|
+
"// Turn a whole raw response",
|
|
275
|
+
);
|
|
276
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-host-"));
|
|
277
|
+
try {
|
|
278
|
+
writeFileSync(
|
|
279
|
+
join(dir, "check.c"),
|
|
280
|
+
`#include <stdio.h>
|
|
281
|
+
#include <stdlib.h>
|
|
282
|
+
#include <string.h>
|
|
283
|
+
${buffer}
|
|
284
|
+
${builder}
|
|
285
|
+
static void show(int port, int default_port) {
|
|
286
|
+
sb_buf req = { 0, 0, 0 };
|
|
287
|
+
sb_build_request(&req, "example.com", port, default_port, "/", "GET", "", 0, 0);
|
|
288
|
+
printf("%s", strstr(req.p, "Host: "));
|
|
289
|
+
free(req.p);
|
|
290
|
+
}
|
|
291
|
+
int main(void) { show(80, 80); show(8080, 80); show(443, 443); show(8443, 443); return 0; }
|
|
292
|
+
`,
|
|
293
|
+
);
|
|
294
|
+
const cc = Bun.spawnSync([
|
|
295
|
+
"cc",
|
|
296
|
+
"-Wall",
|
|
297
|
+
"-Werror",
|
|
298
|
+
"-Wno-unused-function",
|
|
299
|
+
"-o",
|
|
300
|
+
join(dir, "check"),
|
|
301
|
+
join(dir, "check.c"),
|
|
302
|
+
]);
|
|
303
|
+
expect(cc.stderr.toString()).toBe("");
|
|
304
|
+
const hosts = Bun.spawnSync([join(dir, "check")])
|
|
305
|
+
.stdout.toString()
|
|
306
|
+
.split("\r\n")
|
|
307
|
+
.filter((l) => l.startsWith("Host: "));
|
|
308
|
+
expect(hosts).toEqual([
|
|
309
|
+
"Host: example.com",
|
|
310
|
+
"Host: example.com:8080",
|
|
311
|
+
"Host: example.com",
|
|
312
|
+
"Host: example.com:8443",
|
|
313
|
+
]);
|
|
314
|
+
} finally {
|
|
315
|
+
rmSync(dir, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("async fetch: requests run on threads and come back through the host", () => {
|
|
320
|
+
const source = readFileSync(transportFile, "utf8");
|
|
321
|
+
const block = source.slice(
|
|
322
|
+
source.indexOf("// ASYNC:BEGIN"),
|
|
323
|
+
source.indexOf("// ASYNC:END"),
|
|
324
|
+
);
|
|
325
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-async-"));
|
|
326
|
+
try {
|
|
327
|
+
// Stand-ins: a client that takes 200 ms, and a host that records which timers fire.
|
|
328
|
+
writeFileSync(
|
|
329
|
+
join(dir, "check.c"),
|
|
330
|
+
`#include <errno.h>
|
|
331
|
+
#include <fcntl.h>
|
|
332
|
+
#include <poll.h>
|
|
333
|
+
#include <stdio.h>
|
|
334
|
+
#include <stdlib.h>
|
|
335
|
+
#include <string.h>
|
|
336
|
+
#include <time.h>
|
|
337
|
+
#include <unistd.h>
|
|
338
|
+
#include <pthread.h>
|
|
339
|
+
typedef unsigned int u32;
|
|
340
|
+
static void sb_init_anchors(void) {}
|
|
341
|
+
static char* sb_error_json(const char* a, const char* b) { char* s = malloc(256); snprintf(s, 256, "%s%s", a, b); return s; }
|
|
342
|
+
static char* sb_http_request(const char* host, int port, const char* path, const char* method,
|
|
343
|
+
const char* headers, const char* body, size_t body_len, int tls) {
|
|
344
|
+
(void)port; (void)method; (void)headers; (void)body_len; (void)tls;
|
|
345
|
+
usleep(200000);
|
|
346
|
+
char* s = malloc(256); snprintf(s, 256, "%s%s %s", host, path, body); return s;
|
|
347
|
+
}
|
|
348
|
+
static int fired = 0;
|
|
349
|
+
void porf_native_fetch_timer_clear(u32 id) { (void)id; }
|
|
350
|
+
const char* sb_async_reply(u32 id);
|
|
351
|
+
void porf_native_fetch_fire_timer(u32 id) { printf("%u %s\\n", id, sb_async_reply(id)); fired++; }
|
|
352
|
+
static char* copy(const char* s) { return strdup(s); }
|
|
353
|
+
${block}
|
|
354
|
+
int main(void) {
|
|
355
|
+
int fd = sb_async_enable();
|
|
356
|
+
if (fd < 0 || !sb_async_enabled()) return 1;
|
|
357
|
+
struct timespec a, b;
|
|
358
|
+
clock_gettime(CLOCK_MONOTONIC, &a);
|
|
359
|
+
for (u32 i = 1; i <= 16; i++) {
|
|
360
|
+
char path[16]; snprintf(path, sizeof(path), "/%u", i);
|
|
361
|
+
sb_async_submit(i, 80, 0, copy("h"), copy(path), copy("GET"), copy(""), copy("body"), 4);
|
|
362
|
+
}
|
|
363
|
+
while (fired < 16) { struct pollfd p = { fd, POLLIN, 0 }; poll(&p, 1, 5000); sb_async_drain(); }
|
|
364
|
+
clock_gettime(CLOCK_MONOTONIC, &b);
|
|
365
|
+
long ms = (b.tv_sec - a.tv_sec) * 1000 + (b.tv_nsec - a.tv_nsec) / 1000000;
|
|
366
|
+
printf("elapsed %s\\n", ms < 1000 ? "overlapped" : "serial");
|
|
367
|
+
printf("outside a callback %s\\n", sb_async_reply(1) ? "leaks" : "nothing");
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
`,
|
|
371
|
+
);
|
|
372
|
+
const cc = Bun.spawnSync([
|
|
373
|
+
"cc",
|
|
374
|
+
"-Wall",
|
|
375
|
+
"-Werror",
|
|
376
|
+
"-Wno-unused-function",
|
|
377
|
+
"-pthread",
|
|
378
|
+
"-o",
|
|
379
|
+
join(dir, "check"),
|
|
380
|
+
join(dir, "check.c"),
|
|
381
|
+
]);
|
|
382
|
+
expect(cc.stderr.toString()).toBe("");
|
|
383
|
+
const lines = Bun.spawnSync([join(dir, "check")])
|
|
384
|
+
.stdout.toString()
|
|
385
|
+
.trimEnd()
|
|
386
|
+
.split("\n");
|
|
387
|
+
// 16 requests of 200 ms on 8 threads: two rounds, not sixteen.
|
|
388
|
+
expect(lines.slice(0, 16).sort()).toEqual(
|
|
389
|
+
Array.from({ length: 16 }, (_, i) => `${i + 1} h/${i + 1} body`).sort(),
|
|
390
|
+
);
|
|
391
|
+
expect(lines.slice(16)).toEqual([
|
|
392
|
+
"elapsed overlapped",
|
|
393
|
+
"outside a callback nothing",
|
|
394
|
+
]);
|
|
395
|
+
} finally {
|
|
396
|
+
rmSync(dir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
155
398
|
});
|
package/src/gen-streams.ts
CHANGED
|
@@ -16,14 +16,13 @@ if (!code.startsWith("(() => {") || !code.trimEnd().endsWith("})();")) {
|
|
|
16
16
|
throw new Error("Unexpected streams bundle format");
|
|
17
17
|
}
|
|
18
18
|
code = code.slice("(() => {".length).trimEnd().slice(0, -"})();".length);
|
|
19
|
+
// streams-entry.js installs each class with its own static assignment (see there).
|
|
20
|
+
const exported = Object.keys(await import("web-streams-polyfill"));
|
|
21
|
+
const missing = exported.filter((name) => !code.includes(`globalThis.${name} = `));
|
|
22
|
+
if (!exported.length || missing.length) throw new Error(`streams-entry.js does not install: ${missing.join(", ") || "(no exports found)"}`);
|
|
19
23
|
code = code.replace(/^\s*\/\/ .*streams-entry.js$/m, "// streams-entry.js");
|
|
20
24
|
code = code.replace(/^\s*\/\/ .*web-streams-polyfill\/dist\/ponyfill.mjs$/m,
|
|
21
25
|
"// web-streams-polyfill 4.3.0, MIT, copyright Mattias Buelens (see WEB_STREAMS_LICENSE)");
|
|
22
|
-
// Alpha 16's direct array iterator works, but dynamic symbol lookup misses it.
|
|
23
|
-
const lookup = "const r = e[t];\n if (r != null)";
|
|
24
|
-
if (!code.includes(lookup)) throw new Error("Streams iterator adapter target changed");
|
|
25
|
-
code = code.replace(lookup,
|
|
26
|
-
"let r = e[t];\n if (r == null && t === Symbol.iterator && Array.isArray(e)) r = () => e.values();\n if (r != null)");
|
|
27
26
|
const path = `${import.meta.dir}/native-fetch-prelude.js`;
|
|
28
27
|
let prelude = await readFile(path, "utf8");
|
|
29
28
|
const start = prelude.indexOf(marker);
|