@sproutboat/runtime 0.20.0 → 0.21.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 +53 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +252 -35
- package/src/source.ts +8 -4
- package/src/transport-broker.js +5 -0
- package/src/transport-embedded.js +5 -0
- package/src/wrap.ts +30 -3
- package/test/native-base64.ts +11 -2
- package/test/native-service-bindings.ts +93 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.21.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 98800ca: The asynchronous `fetch()` and service-binding behaviour from 0.20.0 now starts at compatibility date `2026-10-22`. Before, it applied at every date, which broke apps that use the result without `await`, such as the CLI's kitchen-sink: `const r = fetch(url); r.body`.
|
|
8
|
+
|
|
9
|
+
From `2026-10-22`, in a deployed sprout:
|
|
10
|
+
|
|
11
|
+
- `fetch()` and `env.SERVICE.fetch()` return promises and run off the event loop;
|
|
12
|
+
- responses stream;
|
|
13
|
+
- redirects are followed;
|
|
14
|
+
- a callee's error is thrown in the caller;
|
|
15
|
+
- a callee sees its caller's URL.
|
|
16
|
+
|
|
17
|
+
With an earlier `compatibility_date`, a deployed sprout's `fetch()` and binding calls answer at once with a `Response`, as they did before 0.20.0. To get the new behaviour, move `compatibility_date` to `2026-10-22` and rebuild.
|
|
18
|
+
|
|
19
|
+
These stay ungated:
|
|
20
|
+
|
|
21
|
+
- sproutd answered with promises at every date, and still does;
|
|
22
|
+
- RPC calls are always asynchronous;
|
|
23
|
+
- abort signals, `Set-Cookie` and `Content-Type` work at every date.
|
|
24
|
+
|
|
25
|
+
## 0.21.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- 6f253b9: Service bindings support Workers RPC.
|
|
30
|
+
|
|
31
|
+
**Callee.** A deployment whose default export extends `WorkerEntrypoint`, imported from `cloudflare:workers`, exposes its methods:
|
|
32
|
+
|
|
33
|
+
- `env.SERVICE.add(2, 3)` runs `add(2, 3)` on a fresh instance, with `this.env` and `this.ctx` set, and resolves to its result.
|
|
34
|
+
- Arguments and results are copied as structured clone does: `undefined`, `NaN`, `-0`, `Date`, `RegExp`, binary data, `Map`, `Set` and errors survive the trip.
|
|
35
|
+
- An error the method throws rejects the call with the same class and message.
|
|
36
|
+
- An unknown method rejects with workerd's `TypeError`.
|
|
37
|
+
- Passing functions or RPC stubs is not supported yet.
|
|
38
|
+
|
|
39
|
+
**Where it works.** RPC travels over the service-binding calls a platform edge vouches for, so no client can invoke a method.
|
|
40
|
+
|
|
41
|
+
**Imports.** `cloudflare:workers` provides `WorkerEntrypoint`, `DurableObject`, `RpcTarget` and `env`. The runtime turns the import into globals, so a handler that imports it runs both bundled and as plain ESM.
|
|
42
|
+
|
|
43
|
+
**Default exports.**
|
|
44
|
+
- A class default export is instantiated per request, for `fetch`, `scheduled` and `queue`.
|
|
45
|
+
- A `WorkerEntrypoint` may have no `fetch` at all.
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 581c2f4: `atob()` and `btoa()` give the right result for any string. They used Porffor's own implementations, which read how a string is stored, so they went wrong on strings held as UTF-16, such as ones from `JSON.parse` or `TextDecoder`:
|
|
50
|
+
|
|
51
|
+
- `atob(JSON.parse('"AQL/"'))` returned two wrong bytes instead of `01 02 ff`;
|
|
52
|
+
- `btoa` of a parsed `"héllo"` encoded the UTF-16 storage.
|
|
53
|
+
|
|
54
|
+
Both are now plain JavaScript, with the same validation and errors as before.
|
|
55
|
+
|
|
3
56
|
## 0.20.0
|
|
4
57
|
|
|
5
58
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -3594,18 +3594,53 @@ if (globalThis.crypto.getRandomValues == null) {
|
|
|
3594
3594
|
Promise.resolve().then(() => callback());
|
|
3595
3595
|
};
|
|
3596
3596
|
}
|
|
3597
|
-
|
|
3597
|
+
// Base64 in JavaScript rather than Porffor's atob/btoa: those read a string's storage,
|
|
3598
|
+
// so a string held as UTF-16 (one from JSON.parse or TextDecoder, say) decoded to the
|
|
3599
|
+
// wrong bytes, and btoa encoded its UTF-16 units.
|
|
3600
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3598
3601
|
globalThis.atob = function (data) {
|
|
3599
3602
|
let text = String(data).replace(/[\t\n\f\r ]/g, "");
|
|
3600
3603
|
if (text.length % 4 === 0) text = text.replace(/==?$/, "");
|
|
3601
3604
|
if (text.length % 4 === 1 || !/^[A-Za-z0-9+/]*$/.test(text)) throw invalidCharacter("atob: the string is not valid base64");
|
|
3602
|
-
|
|
3603
|
-
|
|
3605
|
+
const parts = [];
|
|
3606
|
+
let chunk = "";
|
|
3607
|
+
let bits = 0;
|
|
3608
|
+
let count = 0;
|
|
3609
|
+
for (let i = 0; i < text.length; i++) {
|
|
3610
|
+
bits = (bits << 6) | alphabet.indexOf(text[i]);
|
|
3611
|
+
count += 6;
|
|
3612
|
+
if (count >= 8) {
|
|
3613
|
+
count -= 8;
|
|
3614
|
+
chunk += String.fromCharCode((bits >> count) & 0xff);
|
|
3615
|
+
if (chunk.length === 512) {
|
|
3616
|
+
parts.push(chunk);
|
|
3617
|
+
chunk = "";
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
parts.push(chunk);
|
|
3622
|
+
return parts.join("");
|
|
3604
3623
|
};
|
|
3605
3624
|
globalThis.btoa = function (data) {
|
|
3606
3625
|
const text = String(data);
|
|
3607
|
-
|
|
3608
|
-
|
|
3626
|
+
const parts = [];
|
|
3627
|
+
let chunk = "";
|
|
3628
|
+
for (let i = 0; i < text.length; i += 3) {
|
|
3629
|
+
const a = text.charCodeAt(i);
|
|
3630
|
+
const b = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
|
|
3631
|
+
const c = i + 2 < text.length ? text.charCodeAt(i + 2) : 0;
|
|
3632
|
+
if (a > 0xff || b > 0xff || c > 0xff) throw invalidCharacter("btoa: the string has characters outside Latin-1");
|
|
3633
|
+
const n = (a << 16) | (b << 8) | c;
|
|
3634
|
+
chunk += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63];
|
|
3635
|
+
chunk += i + 1 < text.length ? alphabet[(n >> 6) & 63] : "=";
|
|
3636
|
+
chunk += i + 2 < text.length ? alphabet[n & 63] : "=";
|
|
3637
|
+
if (chunk.length >= 512) {
|
|
3638
|
+
parts.push(chunk);
|
|
3639
|
+
chunk = "";
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
parts.push(chunk);
|
|
3643
|
+
return parts.join("");
|
|
3609
3644
|
};
|
|
3610
3645
|
}
|
|
3611
3646
|
|
|
@@ -5183,30 +5218,49 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5183
5218
|
|
|
5184
5219
|
// #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
|
|
5185
5220
|
// resolves the target itself and forwards it internally, so this is not
|
|
5186
|
-
// egress and is not subject to the private-address block.
|
|
5187
|
-
// in Workers
|
|
5188
|
-
// which is also what lets a deployment call itself without
|
|
5221
|
+
// egress and is not subject to the private-address block. From compatibility date
|
|
5222
|
+
// 2026-10-22 a promise, as in Workers: on an async host the caller keeps serving
|
|
5223
|
+
// while the call runs, which is also what lets a deployment call itself without
|
|
5224
|
+
// deadlocking. Before it, the Response comes back at once, as it always did, so a
|
|
5225
|
+
// handler that uses it without awaiting keeps working.
|
|
5189
5226
|
for (let i = 0; i < (bindings.services || []).length; i++) {
|
|
5190
5227
|
const binding = bindings.services[i].binding;
|
|
5191
|
-
|
|
5192
|
-
|
|
5228
|
+
const fetcher = {
|
|
5229
|
+
fetch(input, init) {
|
|
5230
|
+
if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
|
|
5193
5231
|
const parts = __sbFetchParts(input, init);
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
let body = __sbUnwrapBody(parts.body);
|
|
5197
|
-
if (body instanceof ReadableStream) body = await __sbCollectBody(body);
|
|
5198
|
-
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
|
|
5199
|
-
const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
|
|
5200
|
-
// The callee threw: as in workerd, its error is thrown here.
|
|
5201
|
-
const uncaught = response.headers.get("x-sb-uncaught");
|
|
5202
|
-
if (uncaught != null) throw __sbRemoteError(uncaught);
|
|
5203
|
-
return response;
|
|
5232
|
+
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
|
|
5233
|
+
return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
|
|
5204
5234
|
},
|
|
5205
5235
|
};
|
|
5236
|
+
// Any other name is an RPC method of the callee's WorkerEntrypoint (see __sbRpcCall).
|
|
5237
|
+
// "then" stays undefined, so awaiting the binding itself is not a call.
|
|
5238
|
+
target[binding] = new Proxy(fetcher, {
|
|
5239
|
+
get(t, prop) {
|
|
5240
|
+
if (prop in t) return t[prop];
|
|
5241
|
+
if (!__sbIsStr(prop) || prop === "then") return undefined;
|
|
5242
|
+
return (...args) => __sbRpcCall(binding, prop, args);
|
|
5243
|
+
},
|
|
5244
|
+
});
|
|
5206
5245
|
}
|
|
5207
|
-
|
|
5208
5246
|
};
|
|
5209
5247
|
|
|
5248
|
+
// A service-binding fetch from compatibility date 2026-10-22: a promise, off the event
|
|
5249
|
+
// loop on an async host, following redirects, and throwing the callee's error.
|
|
5250
|
+
async function __sbServiceFetch(binding, input, init) {
|
|
5251
|
+
const parts = __sbFetchParts(input, init);
|
|
5252
|
+
if (parts.signal) parts.signal.throwIfAborted();
|
|
5253
|
+
// A stream body is read to the end first: the wire carries a whole body.
|
|
5254
|
+
let body = __sbUnwrapBody(parts.body);
|
|
5255
|
+
if (body instanceof ReadableStream) body = await __sbCollectBody(body);
|
|
5256
|
+
const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
|
|
5257
|
+
const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
|
|
5258
|
+
// The callee threw: as in workerd, its error is thrown here.
|
|
5259
|
+
const uncaught = response.headers.get("x-sb-uncaught");
|
|
5260
|
+
if (uncaught != null) throw __sbRemoteError(uncaught);
|
|
5261
|
+
return response;
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5210
5264
|
// The error a service-binding callee threw, from its x-sb-uncaught marker: the same
|
|
5211
5265
|
// standard class where there is one, else an Error with the original name.
|
|
5212
5266
|
function __sbRemoteError(marker) {
|
|
@@ -5229,6 +5283,157 @@ function __sbRemoteError(marker) {
|
|
|
5229
5283
|
return error;
|
|
5230
5284
|
}
|
|
5231
5285
|
|
|
5286
|
+
// Workers RPC (cloudflare:workers). A service binding's methods other than fetch() call
|
|
5287
|
+
// the callee's WorkerEntrypoint: env.SVC.add(1, 2) runs `add(1, 2)` on an instance of
|
|
5288
|
+
// the callee's default export and resolves to what it returns. The call travels as a
|
|
5289
|
+
// service-binding request the platform's edge vouched for (x-sb-rpc names the method),
|
|
5290
|
+
// so no client can invoke a method. Arguments and results are copied as structured
|
|
5291
|
+
// clone does (__sbRpcEncode); functions and RPC stubs are not supported yet. An error
|
|
5292
|
+
// the method throws rejects the call with the same class and message, as fetch does.
|
|
5293
|
+
if (globalThis.WorkerEntrypoint == null) {
|
|
5294
|
+
class WorkerEntrypoint {
|
|
5295
|
+
constructor(ctx, env) {
|
|
5296
|
+
this.ctx = ctx;
|
|
5297
|
+
this.env = env;
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
class DurableObject {
|
|
5301
|
+
constructor(ctx, env) {
|
|
5302
|
+
this.ctx = ctx;
|
|
5303
|
+
this.env = env;
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
class RpcTarget {}
|
|
5307
|
+
globalThis.WorkerEntrypoint = WorkerEntrypoint;
|
|
5308
|
+
globalThis.DurableObject = DurableObject;
|
|
5309
|
+
globalThis.RpcTarget = RpcTarget;
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5312
|
+
// A value -> JSON that keeps what structured clone keeps: undefined, NaN, ±Infinity
|
|
5313
|
+
// and -0, Dates, RegExps, binary data, Maps, Sets and Errors are tagged ("$"); a plain
|
|
5314
|
+
// object that has its own "$" key is wrapped so it is not read as a tag.
|
|
5315
|
+
function __sbRpcEncode(value) {
|
|
5316
|
+
if (value === undefined) return { $: "u" };
|
|
5317
|
+
if (value === null || __sbIsStr(value) || value === true || value === false) return value;
|
|
5318
|
+
// Number.isNaN is true only for NaN itself; strings and booleans returned above.
|
|
5319
|
+
if (Number.isNaN(value)) return { $: "n", v: "NaN" };
|
|
5320
|
+
if (value === Number(value)) {
|
|
5321
|
+
if (value === Infinity) return { $: "n", v: "Infinity" };
|
|
5322
|
+
if (value === -Infinity) return { $: "n", v: "-Infinity" };
|
|
5323
|
+
if (value === 0 && 1 / value < 0) return { $: "n", v: "-0" };
|
|
5324
|
+
return value;
|
|
5325
|
+
}
|
|
5326
|
+
if (__sbIsFn(value)) {
|
|
5327
|
+
throw new TypeError("Could not serialize a function for an RPC call: passing functions is not supported yet.");
|
|
5328
|
+
}
|
|
5329
|
+
if (Array.isArray(value)) return value.map(__sbRpcEncode);
|
|
5330
|
+
if (value instanceof Date) return { $: "d", v: value.getTime() };
|
|
5331
|
+
if (value instanceof RegExp) return { $: "r", s: value.source, f: value.flags };
|
|
5332
|
+
if (value instanceof ArrayBuffer) return { $: "ab", v: btoa(__sbToBytes(new Uint8Array(value))) };
|
|
5333
|
+
if (ArrayBuffer.isView(value)) {
|
|
5334
|
+
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
5335
|
+
return { $: "ta", t: value.constructor.name, v: btoa(__sbToBytes(bytes)) };
|
|
5336
|
+
}
|
|
5337
|
+
if (value instanceof Map) {
|
|
5338
|
+
const entries = [];
|
|
5339
|
+
value.forEach((v, k) => entries.push([__sbRpcEncode(k), __sbRpcEncode(v)]));
|
|
5340
|
+
return { $: "m", v: entries };
|
|
5341
|
+
}
|
|
5342
|
+
if (value instanceof Set) {
|
|
5343
|
+
const items = [];
|
|
5344
|
+
value.forEach((v) => items.push(__sbRpcEncode(v)));
|
|
5345
|
+
return { $: "s", v: items };
|
|
5346
|
+
}
|
|
5347
|
+
if (value instanceof Error) return { $: "e", n: value.name, m: String(value.message) };
|
|
5348
|
+
const out = {};
|
|
5349
|
+
for (const key of Object.keys(value)) out[key] = __sbRpcEncode(value[key]);
|
|
5350
|
+
return Object.prototype.hasOwnProperty.call(value, "$") ? { $: "o", v: out } : out;
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
function __sbRpcDecode(value) {
|
|
5354
|
+
if (value === null || !(value instanceof Object)) return value;
|
|
5355
|
+
if (Array.isArray(value)) return value.map(__sbRpcDecode);
|
|
5356
|
+
const tag = value.$;
|
|
5357
|
+
if (tag === undefined) {
|
|
5358
|
+
const out = {};
|
|
5359
|
+
for (const key of Object.keys(value)) out[key] = __sbRpcDecode(value[key]);
|
|
5360
|
+
return out;
|
|
5361
|
+
}
|
|
5362
|
+
if (tag === "u") return undefined;
|
|
5363
|
+
if (tag === "n") return value.v === "-0" ? -0 : Number(value.v);
|
|
5364
|
+
if (tag === "d") return new Date(value.v);
|
|
5365
|
+
if (tag === "r") return new RegExp(value.s, value.f);
|
|
5366
|
+
if (tag === "ab") return __sbBufFrom(atob(value.v));
|
|
5367
|
+
if (tag === "ta") {
|
|
5368
|
+
const buffer = __sbBufFrom(atob(value.v));
|
|
5369
|
+
const View = globalThis[value.t];
|
|
5370
|
+
if (View === globalThis.DataView) return new DataView(buffer);
|
|
5371
|
+
return View && View.BYTES_PER_ELEMENT ? new View(buffer) : new Uint8Array(buffer);
|
|
5372
|
+
}
|
|
5373
|
+
if (tag === "m") return new Map(value.v.map((pair) => [__sbRpcDecode(pair[0]), __sbRpcDecode(pair[1])]));
|
|
5374
|
+
if (tag === "s") return new Set(value.v.map(__sbRpcDecode));
|
|
5375
|
+
if (tag === "e") return __sbRemoteError(encodeURIComponent(JSON.stringify({ name: value.n, message: value.m })));
|
|
5376
|
+
if (tag === "o") return __sbRpcDecode(value.v);
|
|
5377
|
+
return value;
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
// Caller: one RPC call through service binding `binding`.
|
|
5381
|
+
async function __sbRpcCall(binding, method, args) {
|
|
5382
|
+
const label = "sproutboat service " + binding;
|
|
5383
|
+
const body = JSON.stringify(args.map(__sbRpcEncode));
|
|
5384
|
+
const msg = {
|
|
5385
|
+
binding,
|
|
5386
|
+
url: "https://rpc.sproutboat.internal/" + method,
|
|
5387
|
+
method: "POST",
|
|
5388
|
+
headers: [
|
|
5389
|
+
["x-sb-rpc", method],
|
|
5390
|
+
["content-type", "application/json"],
|
|
5391
|
+
],
|
|
5392
|
+
body,
|
|
5393
|
+
};
|
|
5394
|
+
const response = await __sbUpstream("service.fetch", msg, label, null, true);
|
|
5395
|
+
const uncaught = response.headers.get("x-sb-uncaught");
|
|
5396
|
+
if (uncaught != null) throw __sbRemoteError(uncaught);
|
|
5397
|
+
if (response.headers.get("x-sb-rpc-result") !== "1") {
|
|
5398
|
+
throw new TypeError(
|
|
5399
|
+
label + ": the target answered " + response.status + " instead of an RPC result. RPC needs a platform edge " +
|
|
5400
|
+
"that vouches for service-binding calls and a target whose default export extends WorkerEntrypoint.",
|
|
5401
|
+
);
|
|
5402
|
+
}
|
|
5403
|
+
return __sbRpcDecode(JSON.parse(await response.text()));
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5406
|
+
// Whether the default export handles `name` (fetch, scheduled, queue): a method of the
|
|
5407
|
+
// object, or of the class's instances.
|
|
5408
|
+
function __sbHandles(handlers, name) {
|
|
5409
|
+
return __sbIsFn(handlers) ? !!handlers.prototype && __sbIsFn(handlers.prototype[name]) : __sbIsFn(handlers[name]);
|
|
5410
|
+
}
|
|
5411
|
+
|
|
5412
|
+
// Callee: the receiver of one request. A default export that is a class (a
|
|
5413
|
+
// WorkerEntrypoint) is instantiated per request with its ctx and env, as in workerd.
|
|
5414
|
+
function __sbReceiver(handlers, ctx) {
|
|
5415
|
+
return __sbIsFn(handlers) ? new handlers(ctx, globalThis.env) : handlers;
|
|
5416
|
+
}
|
|
5417
|
+
|
|
5418
|
+
// Callee: runs RPC method `method` of `receiver` with the arguments in the request body.
|
|
5419
|
+
// Only methods the receiver's class defines can be called, not ones every object has.
|
|
5420
|
+
async function __sbRpcDispatch(handlers, receiver, method, request) {
|
|
5421
|
+
if (!__sbIsFn(handlers)) {
|
|
5422
|
+
throw new TypeError(
|
|
5423
|
+
"The receiving Worker does not allow its default export to be invoked over RPC, because it does not extend WorkerEntrypoint.",
|
|
5424
|
+
);
|
|
5425
|
+
}
|
|
5426
|
+
const fn = receiver[method];
|
|
5427
|
+
if (!__sbIsFn(fn) || method === "constructor" || method in Object.prototype) {
|
|
5428
|
+
throw new TypeError('The RPC receiver does not implement the method "' + method + '".');
|
|
5429
|
+
}
|
|
5430
|
+
const args = JSON.parse(await request.text()).map(__sbRpcDecode);
|
|
5431
|
+
const result = await fn.apply(receiver, args);
|
|
5432
|
+
return new Response(JSON.stringify(__sbRpcEncode(result)), {
|
|
5433
|
+
headers: { "content-type": "application/json", "x-sb-rpc-result": "1" },
|
|
5434
|
+
});
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5232
5437
|
// fetch(input, init) as Fetch reads it: a Request's method, headers, body, redirect mode
|
|
5233
5438
|
// and signal apply unless init overrides them; a URL object stands for its href.
|
|
5234
5439
|
function __sbFetchParts(input, init) {
|
|
@@ -5267,8 +5472,8 @@ function __sbUpstreamResponse(r) {
|
|
|
5267
5472
|
return response;
|
|
5268
5473
|
}
|
|
5269
5474
|
|
|
5270
|
-
// A streamed upstream response (from compatibility date 2026-10-
|
|
5271
|
-
// stream anyway): the call resolves as soon as the head arrives, and the body is a
|
|
5475
|
+
// A streamed upstream response (from compatibility date 2026-10-22, with the other
|
|
5476
|
+
// asynchronous fetch() behaviour; body is a stream from 2026-10-21 anyway): the call resolves as soon as the head arrives, and the body is a
|
|
5272
5477
|
// ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
|
|
5273
5478
|
// same callback, so it is an interval that the last frame clears. A reply that comes
|
|
5274
5479
|
// whole (a broker from before streaming, or sproutd's embedded transport) is handled
|
|
@@ -5392,10 +5597,15 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
5392
5597
|
// callback alive until then. An aborted `signal` rejects with its reason and cancels
|
|
5393
5598
|
// the exchange, which closes its broker connection: the broker stops the upstream
|
|
5394
5599
|
// request, and the edge in turn stops the callee's.
|
|
5395
|
-
|
|
5600
|
+
// Promises from compatibility date 2026-10-22. Before it, a transport that always
|
|
5601
|
+
// answered at once (the broker's) still does, so handlers that use fetch()'s result
|
|
5602
|
+
// without awaiting keep working; sproutd's answered with promises at any date
|
|
5603
|
+
// (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
|
|
5604
|
+
function __sbUpstream(op, msg, label, signal, always) {
|
|
5396
5605
|
if (signal) signal.throwIfAborted();
|
|
5397
|
-
|
|
5398
|
-
if (
|
|
5606
|
+
const promised = always || globalThis.__sbCompat >= "2026-10-22" || __sbAsyncAtAnyDate();
|
|
5607
|
+
if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
5608
|
+
if (globalThis.__sbCompat >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
|
|
5399
5609
|
return new Promise((resolve, reject) => {
|
|
5400
5610
|
let onAbort = null;
|
|
5401
5611
|
const settled = () => {
|
|
@@ -5969,7 +6179,8 @@ function __sbBindingCallIngress(request) {
|
|
|
5969
6179
|
const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
|
|
5970
6180
|
if (vouched) {
|
|
5971
6181
|
const url = headers.get("x-sb-url");
|
|
5972
|
-
|
|
6182
|
+
// The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
|
|
6183
|
+
if (url && URL.canParse(url) && globalThis.__sbCompat >= "2026-10-22") request.url = url;
|
|
5973
6184
|
headers.delete("x-sb-remote-addr");
|
|
5974
6185
|
}
|
|
5975
6186
|
for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
|
|
@@ -6066,9 +6277,14 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6066
6277
|
};
|
|
6067
6278
|
let __res;
|
|
6068
6279
|
try {
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6280
|
+
const receiver = __sbReceiver(handlers, ctx);
|
|
6281
|
+
// An RPC call (a vouched binding call naming a method) runs that method instead.
|
|
6282
|
+
const rpc = bindingCall ? request.headers.get("x-sb-rpc") : null;
|
|
6283
|
+
__res = rpc != null
|
|
6284
|
+
? __sbRpcDispatch(handlers, receiver, rpc, request)
|
|
6285
|
+
: globalThis.__sbCompat >= "2026-09-28"
|
|
6286
|
+
? receiver.fetch(request, globalThis.env, ctx)
|
|
6287
|
+
: receiver.fetch(request, ctx);
|
|
6072
6288
|
} catch (error) {
|
|
6073
6289
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
6074
6290
|
// whole process (and every other in-flight request) down with it.
|
|
@@ -6082,7 +6298,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6082
6298
|
if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
|
|
6083
6299
|
|
|
6084
6300
|
if (trigger === "scheduled") {
|
|
6085
|
-
if (!
|
|
6301
|
+
if (!__sbHandles(handlers, "scheduled")) return new Response("no scheduled handler", { status: 404 });
|
|
6086
6302
|
const body = __sbReadJson(request);
|
|
6087
6303
|
// #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
|
|
6088
6304
|
// before this: an async `scheduled()`'s own promise was dropped the
|
|
@@ -6096,9 +6312,10 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6096
6312
|
let __sres;
|
|
6097
6313
|
try {
|
|
6098
6314
|
const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
|
|
6315
|
+
const receiver = __sbReceiver(handlers, ctx);
|
|
6099
6316
|
__sres = globalThis.__sbCompat >= "2026-09-28"
|
|
6100
|
-
?
|
|
6101
|
-
:
|
|
6317
|
+
? receiver.scheduled(event, globalThis.env, ctx)
|
|
6318
|
+
: receiver.scheduled(event, ctx);
|
|
6102
6319
|
} catch (error) {
|
|
6103
6320
|
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
6104
6321
|
__sbLogUncaught("scheduled", error);
|
|
@@ -6109,7 +6326,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6109
6326
|
}
|
|
6110
6327
|
|
|
6111
6328
|
if (trigger === "queue") {
|
|
6112
|
-
if (!
|
|
6329
|
+
if (!__sbHandles(handlers, "queue")) return new Response("no queue handler", { status: 404 });
|
|
6113
6330
|
// #171 — same ctx.waitUntil shape. `__sbRunQueueBatch` was also
|
|
6114
6331
|
// fire-and-forget for an async `queue()`: it computed the default
|
|
6115
6332
|
// ack/retry and answered the broker before the handler's own awaits ran,
|
|
@@ -6124,7 +6341,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
6124
6341
|
// Always a promise now (`__sbRunQueueBatch` is async, to await the
|
|
6125
6342
|
// handler above), so always tail-call the drain wrapper — same
|
|
6126
6343
|
// promise-identity reasoning as `__sbFetchWithDrain`.
|
|
6127
|
-
return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
|
|
6344
|
+
return __sbQueueWithDrain(__sbRunQueueBatch(__sbReceiver(handlers, ctx), __sbReadJson(request), ctx), __sbTasks);
|
|
6128
6345
|
}
|
|
6129
6346
|
|
|
6130
6347
|
if (trigger === "alarm") {
|
package/src/source.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { neutraliseExports } from "./wrap";
|
|
1
|
+
import { inlineWorkersImports, neutraliseExports } from "./wrap";
|
|
2
2
|
|
|
3
3
|
export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
|
|
4
4
|
|
|
@@ -28,7 +28,10 @@ const alwaysForbidden: Array<[RegExp, string]> = [
|
|
|
28
28
|
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
-
export function validateHttpSyncSource(
|
|
31
|
+
export function validateHttpSyncSource(module: string): SourceValidation {
|
|
32
|
+
// `cloudflare:workers` is the one import allowed past bundling: the compiler turns it
|
|
33
|
+
// into globals the same way (see inlineWorkersImports).
|
|
34
|
+
const source = inlineWorkersImports(module);
|
|
32
35
|
const errors: string[] = [];
|
|
33
36
|
// The default export must be an object literal with a `fetch` method. A module
|
|
34
37
|
// may also declare Durable Object classes / helpers before it, so this is not
|
|
@@ -36,8 +39,9 @@ export function validateHttpSyncSource(source: string): SourceValidation {
|
|
|
36
39
|
// A hand-written file exports inline; a bundled one re-exports at the end.
|
|
37
40
|
// `neutraliseExports` is the same reader the compiler uses, so `check` cannot
|
|
38
41
|
// accept a module the build would then reject.
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
// A WorkerEntrypoint may serve only RPC, with no fetch() at all.
|
|
43
|
+
if (neutraliseExports(source) === null || (!/\bfetch\s*\(/.test(source) && !/\bextends\s+WorkerEntrypoint\b/.test(source))) {
|
|
44
|
+
errors.push("handler must default-export an object with fetch(request), or a WorkerEntrypoint class");
|
|
41
45
|
}
|
|
42
46
|
for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
|
|
43
47
|
return errors.length ? { ok: false, errors } : { ok: true };
|
package/src/transport-broker.js
CHANGED
|
@@ -895,6 +895,11 @@ function __sbCancelAsync(asyncId) {
|
|
|
895
895
|
`;
|
|
896
896
|
}
|
|
897
897
|
|
|
898
|
+
// Before compatibility date 2026-10-22, fetch() through the broker answered at once.
|
|
899
|
+
function __sbAsyncAtAnyDate() {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
|
|
898
903
|
// Whether the host runs exchanges off its loop (it installed a waker).
|
|
899
904
|
function __sbHttpAsyncEnabled() {
|
|
900
905
|
let res = false;
|
|
@@ -2086,6 +2086,11 @@ function __sbHttpAsyncResult(asyncId) {
|
|
|
2086
2086
|
// placeholder timer cleared, the reply is dropped when it arrives.
|
|
2087
2087
|
function __sbCancelAsync() {}
|
|
2088
2088
|
|
|
2089
|
+
// sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18).
|
|
2090
|
+
function __sbAsyncAtAnyDate() {
|
|
2091
|
+
return true;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2089
2094
|
// Whether the host runs async requests (sb_async_enable was called).
|
|
2090
2095
|
function __sbHttpAsyncEnabled() {
|
|
2091
2096
|
let res = false;
|
package/src/wrap.ts
CHANGED
|
@@ -113,7 +113,33 @@ function hasBindings(b: Bindings): boolean {
|
|
|
113
113
|
* and re-exports at the end (`export { src_default as default, Counter }`) —
|
|
114
114
|
* #89 made the second shape the normal case. Returns null when neither matches.
|
|
115
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* `cloudflare:workers` is not a module here: WorkerEntrypoint, DurableObject, RpcTarget
|
|
118
|
+
* and env are globals the prelude defines. Each import of it becomes a destructuring
|
|
119
|
+
* of globalThis, `as` renames included, so the handler's names still resolve. The CLI
|
|
120
|
+
* leaves the specifier external for this.
|
|
121
|
+
*/
|
|
122
|
+
export function inlineWorkersImports(source: string): string {
|
|
123
|
+
return source.replace(
|
|
124
|
+
/^import\s*\{([^}]*)\}\s*from\s*["']cloudflare:workers["'];?[ \t]*$/gm,
|
|
125
|
+
(_match, names: string) => {
|
|
126
|
+
const pairs = names
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((part) => part.trim())
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((part) => part.replace(/^(\S+)\s+as\s+(\S+)$/, "$1: $2"));
|
|
131
|
+
return `const { ${pairs.join(", ")} } = globalThis;`;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
116
136
|
export function neutraliseExports(source: string): string | null {
|
|
137
|
+
// `export default class Name extends WorkerEntrypoint {`: the class is the handler.
|
|
138
|
+
if (/^\s*export\s+default\s+class\b/m.test(source)) {
|
|
139
|
+
return source
|
|
140
|
+
.replace(/^(\s*)export\s+default\s+class\b/m, "$1const __sbHandlers = class")
|
|
141
|
+
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
142
|
+
}
|
|
117
143
|
if (/\bexport\s+default\s*\{/.test(source)) {
|
|
118
144
|
return source
|
|
119
145
|
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
@@ -182,9 +208,10 @@ export function wrapNativeFetchHandler(
|
|
|
182
208
|
*/
|
|
183
209
|
versionMetadata?: { binding: string; id: string; tag: string; timestamp: string },
|
|
184
210
|
): string {
|
|
185
|
-
const neutralised = neutraliseExports(source);
|
|
186
|
-
|
|
187
|
-
|
|
211
|
+
const neutralised = neutraliseExports(inlineWorkersImports(source));
|
|
212
|
+
// A WorkerEntrypoint may serve only RPC, with no fetch() at all.
|
|
213
|
+
if (neutralised === null || (!/\bfetch\s*\(/.test(source) && !/\bextends\s+WorkerEntrypoint\b/.test(source))) {
|
|
214
|
+
throw new Error("handler must default-export an object with a fetch(request) method, or a WorkerEntrypoint class");
|
|
188
215
|
}
|
|
189
216
|
|
|
190
217
|
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
package/test/native-base64.ts
CHANGED
|
@@ -18,7 +18,13 @@ const handler = `export default {fetch() {
|
|
|
18
18
|
const decoded=Array.from(Uint8Array.fromBase64(url,{alphabet:"base64url"}));
|
|
19
19
|
rows.push({padded,url,decoded});
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// atob/btoa on strings that are not literals (from JSON.parse, from TextDecoder): they
|
|
22
|
+
// used to read the string's UTF-16 storage and return the wrong bytes.
|
|
23
|
+
const parsed=JSON.parse('{"latin":"h\\u00e9llo w\\u00f6rld","b64":"AQL/"}');
|
|
24
|
+
const decodedText=new TextDecoder().decode(new TextEncoder().encode("AQL/"));
|
|
25
|
+
const codes=(s)=>Array.from(s,(c)=>c.charCodeAt(0));
|
|
26
|
+
const strings={btoa:btoa(parsed.latin),atob:codes(atob(parsed.b64)),fromDecoder:codes(atob(decodedText))};
|
|
27
|
+
return Response.json({rows,strings});
|
|
22
28
|
}};`;
|
|
23
29
|
|
|
24
30
|
function freePort(): number {
|
|
@@ -78,7 +84,10 @@ try {
|
|
|
78
84
|
}
|
|
79
85
|
assert(response, "compiled streams handler did not start");
|
|
80
86
|
assert.equal(response.status, 200);
|
|
81
|
-
assert.deepEqual(await response.json(),
|
|
87
|
+
assert.deepEqual(await response.json(), {
|
|
88
|
+
rows: expected,
|
|
89
|
+
strings: { btoa: btoa("h\u00e9llo w\u00f6rld"), atob: [1, 2, 255], fromDecoder: [1, 2, 255] },
|
|
90
|
+
});
|
|
82
91
|
console.log("Native base64 and base64url round-trip zero to nine bytes with correct padding");
|
|
83
92
|
} finally {
|
|
84
93
|
if (server) {
|
|
@@ -15,7 +15,9 @@ import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativ
|
|
|
15
15
|
// From compatibility date 2026-10-22, redirects are followed as Fetch does. A stand-in
|
|
16
16
|
// edge plays the platform's.
|
|
17
17
|
const handler = `
|
|
18
|
-
|
|
18
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
19
|
+
|
|
20
|
+
export default class Service extends WorkerEntrypoint {
|
|
19
21
|
async fetch(request, env) {
|
|
20
22
|
const url = new URL(request.url);
|
|
21
23
|
if (url.pathname === "/ping") return new Response("pong");
|
|
@@ -86,6 +88,22 @@ export default {
|
|
|
86
88
|
for (let i = 0; bigOk && i < big.length; i += 4093) bigOk = big[i] === (i & 255);
|
|
87
89
|
return Response.json({ firstEarly: firstMs >= 0 && firstMs < 250, text, whole, bigOk });
|
|
88
90
|
}
|
|
91
|
+
if (url.pathname === "/rpc") {
|
|
92
|
+
// RPC to this deployment's own WorkerEntrypoint, through the vouched SELF binding.
|
|
93
|
+
const sum = await env.SELF.add(2, 3);
|
|
94
|
+
const trip = await env.SELF.roundTrip({ d: new Date(5), b: new Uint8Array([7, 255]), m: new Map([["k", -0]]), u: undefined, n: NaN });
|
|
95
|
+
const kept = {
|
|
96
|
+
d: trip.d instanceof Date && trip.d.getTime() === 5,
|
|
97
|
+
b: trip.b instanceof Uint8Array && trip.b.length === 2 && trip.b[1] === 255,
|
|
98
|
+
m: trip.m instanceof Map && Object.is(trip.m.get("k"), -0),
|
|
99
|
+
u: "u" in trip && trip.u === undefined,
|
|
100
|
+
n: Number.isNaN(trip.n),
|
|
101
|
+
};
|
|
102
|
+
const thrown = await env.SELF.fail("nope").then(() => null, (e) => [e.name, e.message, e instanceof RangeError]);
|
|
103
|
+
const unknown = await env.SELF.noSuchMethod().then(() => null, (e) => [e.name, e.message]);
|
|
104
|
+
const context = await env.SELF.whoAmI();
|
|
105
|
+
return Response.json({ sum, kept, thrown, unknown, context });
|
|
106
|
+
}
|
|
89
107
|
if (url.pathname === "/whoami") {
|
|
90
108
|
return Response.json({ url: request.url, internal: [...request.headers.keys()].filter((k) => k.startsWith("x-sb-")) });
|
|
91
109
|
}
|
|
@@ -101,8 +119,24 @@ export default {
|
|
|
101
119
|
return Response.json({ who, thrown });
|
|
102
120
|
}
|
|
103
121
|
return new Response("no route", { status: 404 });
|
|
104
|
-
}
|
|
105
|
-
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
add(a, b) {
|
|
125
|
+
return a + b;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
roundTrip(value) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fail(message) {
|
|
133
|
+
throw new RangeError(message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
whoAmI() {
|
|
137
|
+
return { env: this.env !== undefined && this.env.SELF !== undefined, waitUntil: this.ctx.waitUntil instanceof Function };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
106
140
|
`;
|
|
107
141
|
|
|
108
142
|
function freePort(): number {
|
|
@@ -136,7 +170,8 @@ const edge = Bun.serve({
|
|
|
136
170
|
const callerUrl = request.headers.get("x-sb-url");
|
|
137
171
|
if (callerUrl) headers.set("x-sb-url", callerUrl);
|
|
138
172
|
}
|
|
139
|
-
|
|
173
|
+
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
|
|
174
|
+
return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { method: request.method, headers, body });
|
|
140
175
|
}
|
|
141
176
|
if (url.pathname === "/echo") {
|
|
142
177
|
return Response.json({
|
|
@@ -204,6 +239,7 @@ const broker = createBroker({
|
|
|
204
239
|
});
|
|
205
240
|
const brokerServer = listen(broker, "127.0.0.1", 0);
|
|
206
241
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
242
|
+
let legacy: ReturnType<typeof Bun.spawn> | undefined;
|
|
207
243
|
const get = async (path: string) => {
|
|
208
244
|
const t0 = performance.now();
|
|
209
245
|
const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(20_000) });
|
|
@@ -321,24 +357,75 @@ try {
|
|
|
321
357
|
assert.equal(publicBoom.status, 500);
|
|
322
358
|
assert.equal(publicBoom.headers.get("x-sb-uncaught"), null);
|
|
323
359
|
|
|
324
|
-
// Streamed responses (compatibility date 2026-10-
|
|
360
|
+
// Streamed responses (compatibility date 2026-10-22 on): the call resolves on the
|
|
325
361
|
// head, the first chunk arrives before the rest, and a 12 MiB body arrives intact.
|
|
326
362
|
const streamed = await get("/streamed");
|
|
327
363
|
assert.equal(streamed.status, 200, streamed.text);
|
|
328
364
|
assert.deepEqual(JSON.parse(streamed.text), { firstEarly: true, text: "0 1 2 3 ", whole: "0 1 2 3 ", bigOk: true });
|
|
329
365
|
|
|
366
|
+
// RPC: methods of the WorkerEntrypoint, called on the binding, with values copied as
|
|
367
|
+
// structured clone does and errors thrown as in workerd.
|
|
368
|
+
const rpc = await get("/rpc");
|
|
369
|
+
assert.equal(rpc.status, 200, rpc.text);
|
|
370
|
+
assert.deepEqual(JSON.parse(rpc.text), {
|
|
371
|
+
sum: 5,
|
|
372
|
+
kept: { d: true, b: true, m: true, u: true, n: true },
|
|
373
|
+
thrown: ["RangeError", "nope", true],
|
|
374
|
+
unknown: ["TypeError", 'The RPC receiver does not implement the method "noSuchMethod".'],
|
|
375
|
+
context: { env: true, waitUntil: true },
|
|
376
|
+
});
|
|
377
|
+
// A client cannot call a method: without the edge's vouching, x-sb-rpc is just a header.
|
|
378
|
+
const forgedRpc = await fetch(`http://127.0.0.1:${port}/ping`, { method: "POST", headers: { "x-sb-rpc": "fail" }, body: "[\"x\"]" });
|
|
379
|
+
assert.equal(await forgedRpc.text(), "pong");
|
|
380
|
+
|
|
330
381
|
// A deployment calling itself, 30 levels deep: every level waits on the next at once.
|
|
331
382
|
const self = await get("/self");
|
|
332
383
|
assert.equal(self.text, "bottom 30");
|
|
384
|
+
// Before compatibility date 2026-10-22 a binding call answers at once, as it always
|
|
385
|
+
// did: a handler that uses the Response without awaiting it keeps working.
|
|
386
|
+
const legacyHandler = `
|
|
387
|
+
export default {
|
|
388
|
+
fetch(request, env) {
|
|
389
|
+
const res = env.PEER.fetch("https://peer/echo?legacy=1");
|
|
390
|
+
return new Response(String(res instanceof Response) + ":" + res.status + ":" + res.json().path);
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
`;
|
|
394
|
+
const legacyPort = freePort();
|
|
395
|
+
const legacySource = join(workdir, "legacy.generated.js");
|
|
396
|
+
const legacyBinary = join(workdir, "legacy-native");
|
|
397
|
+
await writeFile(legacySource, wrapNativeFetchHandler(legacyHandler, core.replace(TRANSPORT_MARKER, transport), {}, bindings, undefined, "2026-10-21"));
|
|
398
|
+
const legacyCompile = Bun.spawnSync([node, resolve(porffor, "runtime/index.js"), "native", legacySource, "-o", legacyBinary, "-s", "-O0"], { cwd: workdir });
|
|
399
|
+
assert.equal(legacyCompile.exitCode, 0, `legacy compile failed:\n${legacyCompile.stderr}`);
|
|
400
|
+
legacy = Bun.spawn([legacyBinary], {
|
|
401
|
+
cwd: workdir,
|
|
402
|
+
env: { ...process.env, PORT: String(legacyPort), SB_BROKER_PORT: String(brokerServer.port), SB_BROKER_TOKEN: "native-services" },
|
|
403
|
+
stdout: "ignore",
|
|
404
|
+
stderr: "inherit",
|
|
405
|
+
});
|
|
406
|
+
let legacyText = "";
|
|
407
|
+
for (let attempt = 0; attempt < 50 && !legacyText; attempt++) {
|
|
408
|
+
try {
|
|
409
|
+
legacyText = await (await fetch(`http://127.0.0.1:${legacyPort}/`, { signal: AbortSignal.timeout(20_000) })).text();
|
|
410
|
+
} catch {
|
|
411
|
+
await Bun.sleep(100);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
assert.equal(legacyText, "true:200:/echo?legacy=1", "before 2026-10-22 a binding call answers synchronously");
|
|
415
|
+
|
|
333
416
|
console.log(
|
|
334
417
|
`Service bindings return promises and run off the loop: /ping ${Math.round(ping.ms)} ms during a call, ` +
|
|
335
|
-
`8x500 ms in ${Math.round(total)} ms, self-recursion ok; URL, Request+init, stream bodies, Set-Cookie and missing content types preserved; redirects follow Fetch; aborted calls stop upstream; vouched calls see the caller's URL and rethrow the callee's errors; responses stream`,
|
|
418
|
+
`8x500 ms in ${Math.round(total)} ms, self-recursion ok; URL, Request+init, stream bodies, Set-Cookie and missing content types preserved; redirects follow Fetch; aborted calls stop upstream; before 2026-10-22 calls answer at once; vouched calls see the caller's URL and rethrow the callee's errors; responses stream; RPC calls WorkerEntrypoint methods`,
|
|
336
419
|
);
|
|
337
420
|
} finally {
|
|
338
421
|
if (server) {
|
|
339
422
|
server.kill(9);
|
|
340
423
|
await server.exited;
|
|
341
424
|
}
|
|
425
|
+
if (legacy) {
|
|
426
|
+
legacy.kill(9);
|
|
427
|
+
await legacy.exited;
|
|
428
|
+
}
|
|
342
429
|
brokerServer.stop();
|
|
343
430
|
broker.close();
|
|
344
431
|
edge.stop(true);
|