@sproutboat/runtime 0.38.0 → 0.39.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 +6 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +28 -3
- package/src/source.test.ts +44 -0
- package/src/source.ts +44 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.39.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7030f76: Database clients that need only fetch or WebSocket (`@libsql/client`, `@neondatabase/serverless`, `@supabase/supabase-js`, `@upstash/redis`, `@planetscale/database`) run unmodified. In the runtime: `fetch()` returns a promise on the standalone binary from the compatibility date that already promises it (2026-10-22), so `fetch(url).then(...)` works and a refused request rejects; a fetched 204, 205 or 304 response no longer throws "Response with null body status cannot have body"; from the proposed compatibility date 2026-11-15 the Node/Bun/Deno global check in the bundle skips a use behind a `typeof` feature test, a global the bundle declares itself, and mentions inside strings and comments, and `Error.captureStackTrace` exists. In the toolchain: a typed array with an underlying object (a `Buffer` polyfill) shadows the builtin methods it redefines, so `buf.toString("utf8")` is the Buffer one.
|
|
8
|
+
|
|
3
9
|
## 0.38.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -12,6 +12,15 @@ function __sbEnvValue() {
|
|
|
12
12
|
function __sbSetCompat(date) {
|
|
13
13
|
__sbCompatNow = date;
|
|
14
14
|
Request._cd = date;
|
|
15
|
+
if (date >= "2026-11-15" && Error.captureStackTrace === undefined) Error.captureStackTrace = __sbCaptureStackTrace;
|
|
16
|
+
}
|
|
17
|
+
// V8's Error.captureStackTrace(target): drivers (pg, @neondatabase/serverless) call it on the errors they
|
|
18
|
+
// build and rethrow. Porffor keeps no frames, so the stack is the first line, as Error.stack is.
|
|
19
|
+
function __sbCaptureStackTrace(target) {
|
|
20
|
+
if (Object(target) !== target) throw new TypeError("Invalid argument");
|
|
21
|
+
const name = target.name === undefined ? "Error" : String(target.name);
|
|
22
|
+
const message = target.message === undefined ? "" : String(target.message);
|
|
23
|
+
Object.defineProperty(target, "stack", { value: message === "" ? name : name + ": " + message, configurable: true, writable: true, enumerable: false });
|
|
15
24
|
}
|
|
16
25
|
function __sbSetEnv(value) {
|
|
17
26
|
__sbEnvNow = value;
|
|
@@ -4253,8 +4262,12 @@ function __sbRequestBody(body) {
|
|
|
4253
4262
|
}
|
|
4254
4263
|
|
|
4255
4264
|
function __sbRawBodyResponse(body, init) {
|
|
4256
|
-
|
|
4257
|
-
|
|
4265
|
+
// 204, 205 and 304 carry no body: the constructor refuses even an empty string for them, so a
|
|
4266
|
+
// fetch() of any 204 (a DELETE, a PostgREST write) threw before the caller saw the response.
|
|
4267
|
+
const status = init == null ? 200 : init.status;
|
|
4268
|
+
const bodiless = status === 204 || status === 205 || status === 304;
|
|
4269
|
+
const resp = new Response(bodiless ? null : body == null ? "" : body, init);
|
|
4270
|
+
if (body != null && !bodiless) {
|
|
4258
4271
|
let decoded = null;
|
|
4259
4272
|
resp.text = () => {
|
|
4260
4273
|
if (decoded === null) decoded = __sbFromUtf8(body);
|
|
@@ -12243,7 +12256,19 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
|
|
|
12243
12256
|
function __sbUpstream(op, msg, label, signal, always) {
|
|
12244
12257
|
if (signal) signal.throwIfAborted();
|
|
12245
12258
|
const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
|
|
12246
|
-
if (!
|
|
12259
|
+
if (!promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
12260
|
+
if (!__sbHttpAsyncEnabled()) {
|
|
12261
|
+
// A host that runs the exchange to the end before it returns (the standalone binary) still hands
|
|
12262
|
+
// back a promise from 2026-10-22: fetch(url).then(...) is how most libraries call it, and a refusal
|
|
12263
|
+
// rejects instead of throwing. Earlier dates keep the Response (the embedded transport's
|
|
12264
|
+
// __sbAsyncAtAnyDate is true for sproutd's sake, not for this).
|
|
12265
|
+
if (!always && __sbCompatDate() < "2026-10-22") return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
|
|
12266
|
+
try {
|
|
12267
|
+
return Promise.resolve(__sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg)));
|
|
12268
|
+
} catch (e) {
|
|
12269
|
+
return Promise.reject(e);
|
|
12270
|
+
}
|
|
12271
|
+
}
|
|
12247
12272
|
if (__sbCompatDate() >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
|
|
12248
12273
|
return new Promise((resolve, reject) => {
|
|
12249
12274
|
let onAbort = null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { GUARDED_GLOBALS_DATE, validateHttpSyncSource } from "./source";
|
|
3
|
+
|
|
4
|
+
const wrap = (body: string) => `var d={fetch(){${body}return new Response("ok");}};export{d as default};`;
|
|
5
|
+
|
|
6
|
+
const GUARDED = [
|
|
7
|
+
// a bundled `buffer` polyfill declares its own Buffer
|
|
8
|
+
`function Buffer(a){return a;}Buffer.isBuffer=function(b){return b instanceof Buffer;};`,
|
|
9
|
+
// upstash: a polyfill behind a test for another global
|
|
10
|
+
`if(typeof atob==="undefined"){global.atob=(b)=>Buffer.from(b,"base64").toString("utf8");}`,
|
|
11
|
+
// supabase-js: a Node branch behind a feature test
|
|
12
|
+
`function b64(d){if(typeof Buffer!=="undefined")return Buffer.from(d).toString("base64");return btoa(d);}`,
|
|
13
|
+
`if(typeof Deno!=="undefined"&&typeof Deno.unrefTimer==="function"){Deno.unrefTimer(t);}`,
|
|
14
|
+
// @neondatabase/serverless: the feature test names the member itself
|
|
15
|
+
`var B=typeof globalThis.Buffer=="function"&&typeof globalThis.Buffer.allocUnsafe=="function"?globalThis.Buffer:null;`,
|
|
16
|
+
// an error message that mentions an API
|
|
17
|
+
`throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");`,
|
|
18
|
+
`// process.env is read here in Node\n`,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
test("before the guarded-globals date every Node global match fails, as it always did", () => {
|
|
22
|
+
for (const body of GUARDED) {
|
|
23
|
+
expect(validateHttpSyncSource(wrap(body), "2026-10-22").ok).toBe(false);
|
|
24
|
+
expect(validateHttpSyncSource(wrap(body)).ok).toBe(false);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("from the guarded-globals date a feature-tested use, a string and a comment pass", () => {
|
|
29
|
+
for (const body of GUARDED) expect(validateHttpSyncSource(wrap(body), GUARDED_GLOBALS_DATE).ok).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("from the guarded-globals date an unguarded use still fails", () => {
|
|
33
|
+
for (const body of [
|
|
34
|
+
`var x=process.env.TOKEN;`,
|
|
35
|
+
`var b=Buffer.from("a");`,
|
|
36
|
+
`var b=new Buffer(4);`,
|
|
37
|
+
`var x=Bun.file("a");`,
|
|
38
|
+
// a feature test far above does not guard this one
|
|
39
|
+
`if(typeof Deno!=="undefined"){}${" ".repeat(700)}var x=process.env.TOKEN;`,
|
|
40
|
+
]) {
|
|
41
|
+
const result = validateHttpSyncSource(wrap(body), GUARDED_GLOBALS_DATE);
|
|
42
|
+
expect(result.ok).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
package/src/source.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
|
|
|
6
6
|
// bundling there are no imports left to reject, and a dependency reaching for a
|
|
7
7
|
// Node API has to fail exactly as hand-written code would. A bare specifier
|
|
8
8
|
// that resolves to nothing never gets this far — the bundler fails first.
|
|
9
|
+
const NODE_GLOBAL_USE = /\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/;
|
|
10
|
+
|
|
9
11
|
const alwaysForbidden: Array<[RegExp, string]> = [
|
|
10
12
|
[/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
|
|
11
13
|
[/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
|
|
@@ -20,13 +22,48 @@ const alwaysForbidden: Array<[RegExp, string]> = [
|
|
|
20
22
|
// No whitespace is allowed around the `.`: real member access never has
|
|
21
23
|
// any (`process.env`, always contiguous), while prose mentioning the word
|
|
22
24
|
// does (a bundled comment ending "...unique to this process. The id...").
|
|
23
|
-
[
|
|
25
|
+
[NODE_GLOBAL_USE, "Node, Bun, and Deno APIs are not supported"],
|
|
24
26
|
// `node:` only means something as an import specifier; a bare substring
|
|
25
27
|
// match would also flag it appearing in an ordinary string a dependency
|
|
26
28
|
// happens to construct (a doc link, a log message).
|
|
27
29
|
[/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
|
|
28
30
|
];
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* From this compatibility date the Node/Bun/Deno global check skips a use that sits behind a
|
|
34
|
+
* `typeof X` feature test (supabase-js, @neondatabase/serverless and many others carry a Node or
|
|
35
|
+
* Deno branch next to the Web one), a global the bundle declares itself (a bundled `buffer`
|
|
36
|
+
* polyfill) and one that only appears inside a string or comment. Earlier
|
|
37
|
+
* dates keep the plain pattern match. Proposed value, to be chosen by a maintainer.
|
|
38
|
+
*/
|
|
39
|
+
export const GUARDED_GLOBALS_DATE = "2026-11-15";
|
|
40
|
+
|
|
41
|
+
const STRINGS_AND_COMMENTS = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the module reaches for a Node/Bun/Deno global that it neither declares itself nor
|
|
45
|
+
* feature-tests first. A bundled polyfill declares its own `Buffer` (`function Buffer(){}`), and a
|
|
46
|
+
* Node branch sits behind `typeof X !== "undefined"` or `typeof atob === "undefined"`.
|
|
47
|
+
*/
|
|
48
|
+
function usesNodeGlobalUnguarded(module: string): boolean {
|
|
49
|
+
const code = module.replace(STRINGS_AND_COMMENTS, (m) => (m[0] === "/" ? " " : m[0] + m[0]));
|
|
50
|
+
const declared = (name: string) => new RegExp("(?:function|class|var|let|const)\\s+" + name + "\\b").test(code);
|
|
51
|
+
const local = new Set(["process", "Bun", "Deno", "Buffer"].filter(declared));
|
|
52
|
+
const re = new RegExp(NODE_GLOBAL_USE.source, "g");
|
|
53
|
+
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
54
|
+
const name = m[1] ?? "Buffer";
|
|
55
|
+
if (local.has(name)) continue;
|
|
56
|
+
const before = code.slice(Math.max(0, m.index - 600), m.index);
|
|
57
|
+
// `typeof globalThis.Buffer.from`: the use is the feature test itself.
|
|
58
|
+
if (/typeof\s+(?:(?:globalThis|self|global|window)\.)?$/.test(before)) continue;
|
|
59
|
+
if (new RegExp("typeof\\s+(?:(?:globalThis|self|global|window)\\.)?" + name + "\\b").test(before)) continue;
|
|
60
|
+
// `if (typeof atob === "undefined") { ... Buffer.from(...) }`: a test for something else, just before.
|
|
61
|
+
if (/typeof\s+[\w$.]+\s*[!=]==?\s*""/.test(before.slice(-200))) continue;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
/** `new WebSocket(url)` connects out from this compatibility date (earlier, only WebSocketPair makes one). */
|
|
31
68
|
export const OUTBOUND_WEBSOCKET_DATE = "2026-11-01";
|
|
32
69
|
|
|
@@ -48,7 +85,12 @@ export function validateHttpSyncSource(module: string, compatibilityDate?: strin
|
|
|
48
85
|
"handler must default-export an object with fetch(request), scheduled() or queue(), or a WorkerEntrypoint class",
|
|
49
86
|
);
|
|
50
87
|
}
|
|
51
|
-
|
|
88
|
+
const guardedGlobals = compatibilityDate !== undefined && compatibilityDate >= GUARDED_GLOBALS_DATE;
|
|
89
|
+
for (const [pattern, message] of alwaysForbidden) {
|
|
90
|
+
if (pattern === NODE_GLOBAL_USE && guardedGlobals ? !usesNodeGlobalUnguarded(source) : !pattern.test(source))
|
|
91
|
+
continue;
|
|
92
|
+
errors.push(message);
|
|
93
|
+
}
|
|
52
94
|
if (
|
|
53
95
|
/\bWebSocket\s*\(/.test(source) &&
|
|
54
96
|
!(compatibilityDate !== undefined && compatibilityDate >= OUTBOUND_WEBSOCKET_DATE)
|