@sproutboat/runtime 0.13.0 → 0.13.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.13.2
4
+
5
+ ### Patch Changes
6
+
7
+ - dc6539c: Log uncaught handler exceptions to stderr before answering 500, as `Uncaught (in fetch) Error: ...` (and `scheduled` / `alarm` for those triggers). A thrown or rejected handler used to produce a bare 500 with nothing logged, which hid baronunread/sproutboat#168 entirely.
8
+
9
+ ## 0.13.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.13.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1272,9 +1272,9 @@ function __sbMakeD1(dbName) {
1272
1272
  return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
1273
1273
  },
1274
1274
  // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
1275
- // snapshot of this database. Standalone only for now — the broker transport
1276
- // returns an error until hosted backup (#139) covers it. `name` lands under
1277
- // `<data-dir>/backups/`; omit it for a timestamped default. See #164.
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.
1278
1278
  backup(name) {
1279
1279
  const r = __sbRpc("d1.backup", { db: dbName, name: name == null ? "" : String(name) });
1280
1280
  return { path: r.path, bytes: r.bytes };
@@ -1993,27 +1993,43 @@ async function __sbFetchWithDrain(res, tasks) {
1993
1993
  let r;
1994
1994
  try {
1995
1995
  r = res && __sbIsFn(res.then) ? await res : res;
1996
- } catch {
1996
+ } catch (error) {
1997
1997
  // #179 — a rejected handler promise must not take the process down with it.
1998
- r = __sbErrorResponse();
1998
+ r = __sbErrorResponse(error);
1999
1999
  }
2000
2000
  await __sbDrainWaitUntil(tasks);
2001
2001
  return r;
2002
2002
  }
2003
2003
 
2004
2004
  // #179 — turn an uncaught handler exception into a 500 instead of letting it
2005
- // propagate past __sbEntry and crash the whole server.
2006
- function __sbErrorResponse() {
2005
+ // propagate past __sbEntry and crash the whole server. Logged first, the way a
2006
+ // Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
2007
+ // nothing on stderr, which hid #168 entirely.
2008
+ function __sbErrorResponse(error) {
2009
+ __sbLogUncaught("fetch", error);
2007
2010
  return new Response("Internal Server Error", { status: 500 });
2008
2011
  }
2009
2012
 
2013
+ function __sbLogUncaught(where, error) {
2014
+ try {
2015
+ let text = "";
2016
+ if (error && error.stack) text = String(error.stack);
2017
+ else if (error && error.message != null) text = (error.name || "Error") + ": " + error.message;
2018
+ else text = String(error);
2019
+ console.error("Uncaught (in " + where + ") " + text);
2020
+ } catch {
2021
+ /* logging must never turn a handled error into a crash */
2022
+ }
2023
+ }
2024
+
2010
2025
  // Shared by alarm and scheduled: both reply with an empty 204 once their
2011
2026
  // handler (and anything it queued via `waitUntil`) has settled.
2012
2027
  async function __sb204WithDrain(res, tasks) {
2013
2028
  try {
2014
2029
  if (res && __sbIsFn(res.then)) await res;
2015
- } catch {
2030
+ } catch (error) {
2016
2031
  // #179 — same rule as fetch: a rejected scheduled/alarm promise must not crash the process.
2032
+ __sbLogUncaught("scheduled/alarm", error);
2017
2033
  }
2018
2034
  await __sbDrainWaitUntil(tasks);
2019
2035
  return new Response("", { status: 204 });
@@ -2082,10 +2098,10 @@ globalThis.__sbEntry = function (handlers, request) {
2082
2098
  __res = globalThis.__sbCompat >= "2026-09-28"
2083
2099
  ? handlers.fetch(request, globalThis.env, ctx)
2084
2100
  : handlers.fetch(request, ctx);
2085
- } catch {
2101
+ } catch (error) {
2086
2102
  // #179 — a synchronous throw anywhere in the handler must not take the
2087
2103
  // whole process (and every other in-flight request) down with it.
2088
- return __sbErrorResponse();
2104
+ return __sbErrorResponse(error);
2089
2105
  }
2090
2106
  // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
2091
2107
  // own return, which is why it's tail-called rather than chained on here.
@@ -2112,8 +2128,9 @@ globalThis.__sbEntry = function (handlers, request) {
2112
2128
  __sres = globalThis.__sbCompat >= "2026-09-28"
2113
2129
  ? handlers.scheduled(event, globalThis.env, ctx)
2114
2130
  : handlers.scheduled(event, ctx);
2115
- } catch {
2131
+ } catch (error) {
2116
2132
  // #179 — same rule as fetch: don't let a throw here crash the process.
2133
+ __sbLogUncaught("scheduled", error);
2117
2134
  return new Response("", { status: 204 });
2118
2135
  }
2119
2136
  if (__sbTasks.length || (__sres && __sbIsFn(__sres.then))) return __sb204WithDrain(__sres, __sbTasks);
@@ -2146,8 +2163,9 @@ globalThis.__sbEntry = function (handlers, request) {
2146
2163
  let __ares;
2147
2164
  try {
2148
2165
  __ares = inst.alarm();
2149
- } catch {
2166
+ } catch (error) {
2150
2167
  // #179 — same rule as fetch: don't let a throw here crash the process.
2168
+ __sbLogUncaught("alarm", error);
2151
2169
  return new Response("", { status: 204 });
2152
2170
  }
2153
2171
  // Was fire-and-forget before: an async `alarm()` and any `state.waitUntil()`
@@ -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
- sb_putjson(&out, m, strlen(m));
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
- sb_putjson(&b, err ? err : "exec failed", err ? strlen(err) : 11);
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
- sb_putjson(&b, m, strlen(m));
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
- sb_putjson(&b, name ? name : "", name ? strlen(name) : 0);
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
- sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
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
- sb_putjson(&e, m, strlen(m));
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
- sb_putjson(&b, m, strlen(m));
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
- sb_putjson(&b, m, strlen(m));
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
- sb_putjson(&out, msg.p ? msg.p : "", msg.len);
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,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
+ }