@sproutboat/toolchain 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @sproutboat/toolchain
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 85aa74d: Service-binding calls and outbound `fetch()` no longer block a deployed sprout. In a deployed sprout, each call runs off the event loop on one I/O thread, so the sprout keeps answering other requests while a call is in flight. Concurrent calls overlap: eight 500 ms calls finish in about 510 ms, where they used to queue for about 4 s. A deployment can also call itself through a binding, to any depth, where this used to deadlock. The toolchain installs the loop hook the runtime uses for this; with an older runtime it does nothing.
8
+
9
+ `env.SERVICE.fetch()` now always returns a promise, as in Workers, and reads its arguments as `fetch()` does:
10
+
11
+ - a `URL` object works as input;
12
+ - `fetch(request, init)` keeps the request's body when `init` sets no body of its own;
13
+ - a `ReadableStream` body is sent whole, where it used to arrive as the text `[object ReadableStream]`.
14
+
15
+ Responses from bindings and from `fetch()` keep every `Set-Cookie` header, where they used to keep only the last one.
16
+
17
+ A broker exchange is now resent only when the broker cannot have acted on it: the request never fully left, or the connection had gone stale. A `fetch` or service call that the broker may already have run is no longer repeated.
18
+
19
+ ## 0.10.2
20
+
21
+ ### Patch Changes
22
+
23
+ - bc3fd25: `Response.body` and `Request.body` are `ReadableStream`s only from compatibility date `2026-10-21`. 0.19.0 changed this for every app, which broke handlers that read `request.body` as a string, such as `JSON.parse(request.body)` in the CLI's own D1 example. An app with an earlier `compatibility_date` keeps the raw body, as before. To opt in, move `compatibility_date` to `2026-10-21` or later and rebuild.
24
+
3
25
  ## 0.10.1
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/toolchain",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -147,7 +147,8 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
147
147
  " }\n" +
148
148
  "\n" +
149
149
  " if (body_owned) free(body_owned);\n" +
150
- "}\n",
150
+ "}\n" +
151
+ "int main(void) {\n porf_native_fetch_runtime_init();\n porf_native_fetch_static_init();\n\n return 0;\n}\n",
151
152
  "compiler/builtins/json.ts":
152
153
  "let __Porffor_json_bufLimit: i32 = 0;\n depth += 1;\n depth -= 1;\n depth += 1;\n depth -= 1;\n let out: i32 = __Porffor_json_serialize(buffer, value, 0, space);\n" +
153
154
  "// sb_json_utf16_v1\n" +
@@ -72,8 +72,11 @@ export const REQUEST_CLASS_SOURCE = String.raw`class Request {
72
72
  // body is a ReadableStream, as in Fetch: a stream body itself, else a stream made once over
73
73
  // the bytes. The value as given stays in _bodyInit, which the native writer, streaming and
74
74
  // the prelude read; assigning body (the native ingress does) sets it.
75
+ // From compatibility date 2026-10-21. Before it, body is the value as given, as it always
76
+ // was here (apps read request.body as a string); a deployed handler keeps its behaviour.
75
77
  static _bodyAsStream(message) {
76
78
  const init = message._bodyInit;
79
+ if (!(globalThis.__sbCompat >= '2026-10-21')) return init;
77
80
  if (init == null) return null;
78
81
  if (Request._isStream(init)) return init;
79
82
  if (message._bodyStream == null) {
@@ -21,7 +21,7 @@ export const PATCH_SOURCES = [
21
21
  * patch.ts as the module patch.ts is everywhere else. Kept current by fingerprint.test.ts
22
22
  * (UPDATE_FINGERPRINT=1 bun test packages/toolchain/src/fingerprint.test.ts).
23
23
  */
24
- export const RELEASED_FINGERPRINT = "9a717433bb3b";
24
+ export const RELEASED_FINGERPRINT = "66de5cdac773";
25
25
 
26
26
  /** The fingerprint of the sources in dir, or null where they are not on disk. */
27
27
  export function sourceFingerprint(dir: string): string | null {
package/src/patch.test.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  patchCaseMapping,
17
17
  patchZeroDelayTimers,
18
18
  patchStreamingResponses,
19
+ patchAsyncWaker,
19
20
  patchSplitGlobals,
20
21
  patchBlobClass,
21
22
  patchFetchClasses,
@@ -771,6 +772,30 @@ test("streaming patch adds the stream registry and attaches marked responses, id
771
772
  }
772
773
  });
773
774
 
775
+ test("async waker patch installs a loop waker in the native server, idempotently", async () => {
776
+ const root = await mkdtemp(join(tmpdir(), "sb-async-waker-"));
777
+ try {
778
+ await mkdir(join(root, "compiler"));
779
+ const file = join(root, "compiler/uwebsockets.js");
780
+ await writeFile(
781
+ file,
782
+ "static void on_request() {}\nint main(void) {\n porf_native_fetch_runtime_init();\n porf_native_fetch_static_init();\n\n uWS::App();\n}\n",
783
+ );
784
+ await patchAsyncWaker(root);
785
+ const patched = await readFile(file, "utf8");
786
+ expect(patched).toContain('extern "C" __attribute__((weak)) void sb_async_set_waker(void (*wake)(void))');
787
+ expect(patched).toContain("sb_async_loop->defer([] {");
788
+ expect(patched.indexOf("sb_async_set_waker(sb_async_wake);")).toBeGreaterThan(patched.indexOf("porf_native_fetch_static_init();"));
789
+ expect(patched.indexOf("sb_async_set_waker(sb_async_wake);")).toBeLessThan(patched.indexOf("uWS::App();"));
790
+ await patchAsyncWaker(root);
791
+ expect(await readFile(file, "utf8")).toBe(patched);
792
+ await writeFile(file, "export const x = 1;\n");
793
+ await expect(patchAsyncWaker(root)).rejects.toThrow("main() anchor not found");
794
+ } finally {
795
+ await rm(root, { recursive: true, force: true });
796
+ }
797
+ });
798
+
774
799
  test("split globals patch prefixes module globals so none binds to libc, idempotently", async () => {
775
800
  const root = await mkdtemp(join(tmpdir(), "sb-split-globals-"));
776
801
  try {
package/src/patch.ts CHANGED
@@ -1841,6 +1841,39 @@ const DEFERRED_DISCARD_EDITS: [string, string][] = [
1841
1841
  ],
1842
1842
  ];
1843
1843
 
1844
+ const ASYNC_WAKER_MARKER = "// sproutboat: async broker exchanges";
1845
+ const ASYNC_WAKER_MAIN =
1846
+ "int main(void) {\n porf_native_fetch_runtime_init();\n porf_native_fetch_static_init();\n";
1847
+
1848
+ /**
1849
+ * The broker transport runs fetch() and service-binding calls on its own threads
1850
+ * once the host gives it a way back onto the loop, so a sprout keeps serving while
1851
+ * a call is in flight. uWS's `defer()` is that way: it is the loop's one thread-safe
1852
+ * entry (it locks, queues and wakes the loop). Builds without the broker transport
1853
+ * link the weak no-op defaults and stay synchronous.
1854
+ */
1855
+ export async function patchAsyncWaker(root: string): Promise<void> {
1856
+ const shimFile = resolve(root, "compiler/uwebsockets.js");
1857
+ const shim = await readFile(shimFile, "utf8");
1858
+ if (shim.includes(ASYNC_WAKER_MARKER)) return;
1859
+ if (!shim.includes(ASYNC_WAKER_MAIN))
1860
+ throw new Error(`could not patch Porffor's native server for async calls: main() anchor not found in ${shimFile}`);
1861
+ const waker = `${ASYNC_WAKER_MARKER}
1862
+ extern "C" __attribute__((weak)) void sb_async_set_waker(void (*wake)(void)) { (void)wake; }
1863
+ extern "C" __attribute__((weak)) void sb_async_drain(void) {}
1864
+ static uWS::Loop* sb_async_loop = nullptr;
1865
+ static void sb_async_wake(void) {
1866
+ sb_async_loop->defer([] {
1867
+ __porffor_js_enter();
1868
+ sb_async_drain();
1869
+ __porffor_js_exit();
1870
+ });
1871
+ }
1872
+ `;
1873
+ const install = " sb_async_loop = uWS::Loop::get();\n sb_async_set_waker(sb_async_wake);\n";
1874
+ await writeFile(shimFile, shim.replace(ASYNC_WAKER_MAIN, waker + ASYNC_WAKER_MAIN + install));
1875
+ }
1876
+
1844
1877
  /** #287: deferred discard and idle return. After patchQuietCollection, which adds the quiet collector. */
1845
1878
  export async function patchDeferredDiscard(root: string): Promise<void> {
1846
1879
  const file = resolve(root, "compiler/render.js");
@@ -2282,6 +2315,7 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
2282
2315
  await patchRenderJs(root);
2283
2316
  await patchStreamingResponses(root);
2284
2317
  await patchQuietCollection(root);
2318
+ await patchAsyncWaker(root);
2285
2319
  await patchDeferredDiscard(root);
2286
2320
  await patchDateParser(root);
2287
2321
  await patchPromiseResolveNull(root);