@sproutboat/runtime 0.37.1 → 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.
@@ -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
- [/\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/, "Node, Bun, and Deno APIs are not supported"],
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
- for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
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)
@@ -0,0 +1,62 @@
1
+ # crypto.subtle, native half
2
+
3
+ `crypto.subtle` follows workerd's surface from compatibility date `__SB_SUBTLE_DATE` (a
4
+ placeholder constant at the top of the new block in `native-fetch-prelude.js`). The JavaScript
5
+ side (key objects, algorithm and usage checks, JWK, DOMException names) lives in the prelude;
6
+ this directory is the C it calls.
7
+
8
+ ## Where the primitives come from
9
+
10
+ Nothing here implements a primitive. The rule is that anything with a vetted C implementation
11
+ uses it.
12
+
13
+ | Need | Source | Licence |
14
+ | --- | --- | --- |
15
+ | SHA-1/256/384/512, HMAC, HKDF | BearSSL 0.6 | MIT |
16
+ | RSA (private and public operations, key generation, PKCS#1 v1.5 padding), RSA-OAEP | BearSSL 0.6 | MIT |
17
+ | ECDSA and ECDH on P-256, P-384, P-521, EC key generation | BearSSL 0.6 | MIT |
18
+ | AES (constant-time), CBC, CTR, GCM | BearSSL 0.6 | MIT |
19
+ | Ed25519, X25519 | Monocypher 4.0.2, vendored in `vendor/` | BSD-2-Clause or CC0-1.0 |
20
+
21
+ BearSSL is already linked into every embedded sprout (standalone binaries and sproutd) for
22
+ TLS, so this adds no dependency there. It has no Ed25519 or X25519 signing, which is why
23
+ Monocypher is vendored: one small library, audited (Cure53, 2017), written for constant time,
24
+ public domain or BSD. The two `.c` files and two headers are verbatim from the 4.0.2 release
25
+ (sha256 of the tarball `38d07179738c0c90677dba3ceb7a7b8496bcfea758ba1a53e803fed30ae0879c`);
26
+ `assemble.ts` wraps each in preprocessor aliases so none of its short names (`u8`, `fe`, `FOR`,
27
+ `MIN`) leak into the shared translation unit.
28
+
29
+ What is written here is composition and checking, each of it tested against BoringSSL (Bun's
30
+ WebCrypto) in `subtle.test.ts`:
31
+
32
+ - RSASSA-PSS encoding and verification (EMSA-PSS with MGF1), since BearSSL has the RSA
33
+ primitive but no PSS.
34
+ - PBKDF2 (RFC 8018) over BearSSL's HMAC.
35
+ - The DER for SPKI and PKCS#8, and the PKCS#8 RSA reader. BearSSL's own PKCS#8 decoder
36
+ recomputes the public exponent and fails for primes that are 1 mod 4, which OpenSSL and
37
+ BoringSSL keys often have, so the structure is read directly and BearSSL gets the CRT parts.
38
+ - Argument and point validation (an EC public point is accepted only if it is on the curve).
39
+
40
+ ## Constant time
41
+
42
+ Secret-dependent work is BearSSL's (RSA private exponentiation, ECDSA signing, AES) and
43
+ Monocypher's (Ed25519 signing, X25519), which are written for constant time. Comparisons of
44
+ MACs and signatures use a constant-time compare. Verification of public data (RSA, ECDSA,
45
+ PSS check) is not secret-dependent. ECDSA signatures are deterministic (RFC 6979, BearSSL's
46
+ HMAC-DRBG), which WebCrypto permits.
47
+
48
+ ## Layout
49
+
50
+ - `subtle.c`: the C; one entry point, `sbc_op`.
51
+ - `vendor/`: Monocypher, verbatim, with its licence.
52
+ - `assemble.ts`: builds the block that is spliced into `transport-embedded.js` between the
53
+ `SUBTLE:BEGIN` and `SUBTLE:END` markers. Run `bun run gen:subtle` in `packages/runtime` after
54
+ editing `subtle.c`.
55
+ - `driver.c`, `subtle.test.ts`: compile the same C with the host `cc` and BearSSL from the CLI
56
+ cache, and compare with Bun's WebCrypto.
57
+
58
+ ## Transports
59
+
60
+ Only the embedded transport has BearSSL and Monocypher. On the broker transport
61
+ `__sbSubtleRaw` returns an empty string and the prelude throws `NotSupportedError` for
62
+ anything that needs the native half (HMAC and digest never do).
@@ -0,0 +1,152 @@
1
+ // Builds the C that crypto.subtle compiles into an embedded sprout: Monocypher (vendored verbatim in
2
+ // vendor/) plus subtle.c, adapted for the one place it is compiled.
3
+ //
4
+ // Porffor splits inline C into a shared header and per-unit implementation files, and the splitter
5
+ // is a small scanner, not a C parser. Three things follow, and this file handles each:
6
+ // - A comment that opens a top-level chunk makes the chunk look like a variable and breaks the
7
+ // split, so every comment is removed (the licence travels in vendor/ and the README).
8
+ // - A top-level non-const static function becomes a global symbol in its unit, so every file-scope
9
+ // name Monocypher declares static is aliased to a prefixed one by the preprocessor, for the span
10
+ // of the file. Its short typedef names (u8, fe) and macros (FOR, MIN) are aliased or undefined
11
+ // the same way, so none of them reaches the rest of the translation unit, which also holds
12
+ // Porffor's generated code and SQLite glue.
13
+ // - Preprocessor lines are copied to both halves in order, so the aliases hold on both sides.
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+
16
+ const here = (name: string) => new URL(name, import.meta.url);
17
+ const read = (name: string) => readFileSync(here(name), "utf8");
18
+
19
+ /** Monocypher's internal typedef names. Each is aliased by the preprocessor across the whole block. */
20
+ const INTERNAL_NAMES = ["i8", "u8", "i16", "u32", "i32", "i64", "u64", "fe", "ge", "ge_cached", "ge_precomp", "blk"];
21
+
22
+ export const SUBTLE_BEGIN =
23
+ "// SUBTLE:BEGIN (generated by `bun run gen:subtle`; edit src/subtle/subtle.c, not this block)";
24
+ export const SUBTLE_END = "// SUBTLE:END";
25
+
26
+ /** Removes C comments, leaving strings and character constants alone. */
27
+ export function stripComments(source: string): string {
28
+ let out = "";
29
+ for (let i = 0; i < source.length;) {
30
+ const c = source[i];
31
+ if (c === '"' || c === "'") {
32
+ let j = i + 1;
33
+ while (source[j] !== c) j += source[j] === "\\" ? 2 : 1;
34
+ out += source.slice(i, j + 1);
35
+ i = j + 1;
36
+ } else if (c === "/" && source[i + 1] === "/") {
37
+ while (i < source.length && source[i] !== "\n") i++;
38
+ } else if (c === "/" && source[i + 1] === "*") {
39
+ i = source.indexOf("*/", i + 2) + 2;
40
+ out += " ";
41
+ } else {
42
+ out += c;
43
+ i++;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ const tidy = (source: string) =>
50
+ stripComments(source)
51
+ .split("\n")
52
+ .map((line) => line.replace(/\s+$/, ""))
53
+ .filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""))
54
+ .join("\n");
55
+
56
+ export function assembleSubtleC(): string {
57
+ // The C++ guards (a namespace, extern "C") would swallow the rest of the block in the splitter.
58
+ const strip = (name: string) =>
59
+ tidy(read(name))
60
+ .replace(/^#include "monocypher[^"]*"\s*$/gm, "")
61
+ .replace(/^#ifdef (?:MONOCYPHER_CPP_NAMESPACE|__cplusplus)\n[\s\S]*?^#endif\n/gm, "");
62
+ // Each vendored .c file was written to be its own translation unit: its macros end with it, and
63
+ // its static names get a prefix of their own (both define `hash_reduce`, differently).
64
+ const unit = (name: string, index: number) => {
65
+ const body = strip(name);
66
+ const macros = [...new Set([...body.matchAll(/^#define\s+(\w+)/gm)].map((m) => m[1]))];
67
+ const statics = [
68
+ ...new Set([...body.matchAll(/^static\b([^;{(=]*?)\b(\w+)\s*(?:\(|\[|=|;)/gm)].map((m) => m[2])),
69
+ ].filter((n) => !INTERNAL_NAMES.includes(n));
70
+ return [
71
+ ...statics.map((n) => `#define ${n} sbm${index}_${n}`),
72
+ body,
73
+ ...macros.map((n) => `#undef ${n}`),
74
+ ...statics.map((n) => `#undef ${n}`),
75
+ ].join("\n");
76
+ };
77
+ const headers = ["vendor/monocypher.h", "vendor/monocypher-ed25519.h"].map(strip).join("\n");
78
+ const alias = INTERNAL_NAMES.map((n) => `#define ${n} sbm_${n}`).join("\n");
79
+ const unalias = INTERNAL_NAMES.map((n) => `#undef ${n}`).join("\n");
80
+ return [
81
+ "#include <bearssl.h>",
82
+ "// Monocypher 4.0.2 (BSD-2-Clause OR CC0-1.0), Loup Vaillant: vendor/monocypher*.[ch] without comments.",
83
+ alias,
84
+ headers,
85
+ unit("vendor/monocypher.c", 1),
86
+ unit("vendor/monocypher-ed25519.c", 2),
87
+ unalias,
88
+ "// End of Monocypher.",
89
+ tidy(read("subtle.c")),
90
+ ].join("\n");
91
+ }
92
+
93
+ /**
94
+ * One call into sbc_op. `op` is a decimal string; a, b, c, d are byte strings (latin1, one char per
95
+ * byte). Returns a byte string whose first byte is the status (0 ok, 1 DataError, 2 OperationError,
96
+ * 3 NotSupportedError) and whose rest is the result. Sits inside the markers so that pruning the
97
+ * block (wrap.ts, for a handler that never touches crypto.subtle) removes it with the C it calls.
98
+ */
99
+ const SUBTLE_RAW = `// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
100
+ function __sbSubtleRaw(op, a, b, c, d) {
101
+ let out = "";
102
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c\`...\` is inline C the compiler consumes, not a JS expression.
103
+ Porffor.c\`
104
+ const char* __os; size_t __osl; char* __oso = 0;
105
+ porf_native_fetch_read_value(op, &__os, &__osl, &__oso);
106
+ char __ob[8]; size_t __ok = __osl < 7 ? __osl : 7;
107
+ memcpy(__ob, __os, __ok); __ob[__ok] = 0;
108
+ if (__oso) free(__oso);
109
+ int __op = atoi(__ob);
110
+
111
+ const char* __pa = ""; size_t __pal = 0; porf_native_fetch_read_raw_bytes(a, &__pa, &__pal);
112
+ const char* __pb = ""; size_t __pbl = 0; porf_native_fetch_read_raw_bytes(b, &__pb, &__pbl);
113
+ const char* __pc = ""; size_t __pcl = 0; porf_native_fetch_read_raw_bytes(c, &__pc, &__pcl);
114
+ const char* __pd = ""; size_t __pdl = 0; porf_native_fetch_read_raw_bytes(d, &__pd, &__pdl);
115
+ unsigned char* __res = 0; size_t __resl = 0;
116
+ int __rc = sbc_op(__op, (const unsigned char*)__pa, __pal, (const unsigned char*)__pb, __pbl,
117
+ (const unsigned char*)__pc, __pcl, (const unsigned char*)__pd, __pdl, &__res, &__resl);
118
+ unsigned char* __w = (unsigned char*)malloc(__resl + 1);
119
+ if (__w) {
120
+ __w[0] = (unsigned char)__rc;
121
+ if (__resl) memcpy(__w + 1, __res, __resl);
122
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__w, __resl + 1), 195);
123
+ free(__w);
124
+ }
125
+ free(__res);
126
+ \`;
127
+ return out;
128
+ }
129
+ `;
130
+
131
+ /** The prelude with the generated block (re)written between its markers. */
132
+ export function spliceSubtle(prelude: string): string {
133
+ const start = prelude.indexOf(SUBTLE_BEGIN);
134
+ const end = prelude.indexOf(SUBTLE_END);
135
+ if (start < 0 || end < start) throw new Error("native-fetch-prelude.js has no SUBTLE markers");
136
+ const block = assembleSubtleC();
137
+ if (/`|\$\{/.test(block)) throw new Error("subtle C contains a backtick or ${: it is spliced into a JS template");
138
+ return (
139
+ prelude.slice(0, start) +
140
+ SUBTLE_BEGIN +
141
+ "\n// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.\nPorffor.c`\n" +
142
+ block +
143
+ "\n`;\n" +
144
+ SUBTLE_RAW +
145
+ prelude.slice(end)
146
+ );
147
+ }
148
+
149
+ if (import.meta.main) {
150
+ const path = new URL("../native-fetch-prelude.js", import.meta.url);
151
+ writeFileSync(path, spliceSubtle(readFileSync(path, "utf8")));
152
+ }
@@ -0,0 +1,33 @@
1
+ /* Test driver for subtle.c: reads "op a b c d" lines (hex, "-" for empty), prints "rc hex". */
2
+ #include <stdio.h>
3
+ #include <stdlib.h>
4
+ #include <string.h>
5
+ #include <fcntl.h>
6
+ #include <unistd.h>
7
+ #include <stdint.h>
8
+ #include <bearssl.h>
9
+ #include "assembled.c"
10
+
11
+ static unsigned char* unhex(const char* s, size_t* n) {
12
+ if (!strcmp(s, "-")) { *n = 0; return (unsigned char*)calloc(1, 1); }
13
+ size_t l = strlen(s) / 2; unsigned char* o = (unsigned char*)malloc(l + 1);
14
+ for (size_t i = 0; i < l; i++) { unsigned v; sscanf(s + 2 * i, "%2x", &v); o[i] = (unsigned char)v; }
15
+ *n = l; return o;
16
+ }
17
+ int main(void) {
18
+ static char line[1 << 20];
19
+ while (fgets(line, sizeof line, stdin)) {
20
+ char* t[5]; int k = 0;
21
+ for (char* p = strtok(line, " \n"); p && k < 5; p = strtok(0, " \n")) t[k++] = p;
22
+ if (k < 5) continue;
23
+ size_t l[4]; unsigned char* a[4];
24
+ for (int i = 0; i < 4; i++) a[i] = unhex(t[i + 1], &l[i]);
25
+ unsigned char* out; size_t ol;
26
+ int rc = sbc_op(atoi(t[0]), a[0], l[0], a[1], l[1], a[2], l[2], a[3], l[3], &out, &ol);
27
+ printf("%d ", rc);
28
+ for (size_t i = 0; i < ol; i++) printf("%02x", out[i]);
29
+ printf("\n"); fflush(stdout);
30
+ free(out); for (int i = 0; i < 4; i++) free(a[i]);
31
+ }
32
+ return 0;
33
+ }
@@ -0,0 +1,53 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { preludePath, pruneSubtle, wrapNativeFetchHandler } from "../wrap";
4
+
5
+ const prelude = readFileSync(preludePath, "utf8");
6
+ const handler = (body: string) => `export default { async fetch() { ${body} return new Response("x"); } };`;
7
+
8
+ test("pruning removes the workerd-style crypto.subtle, its C and its glue, and nothing else", () => {
9
+ const pruned = pruneSubtle(prelude);
10
+ for (const gone of [
11
+ "__sbSubtleNext",
12
+ "__sbSubtleLegacy",
13
+ "SUBTLE:",
14
+ "const __SB_SUBTLE_DATE",
15
+ "sbc_op",
16
+ "crypto_ed25519_sign",
17
+ "__sbSubtleRaw",
18
+ "class DigestStream",
19
+ ])
20
+ expect(pruned).not.toContain(gone);
21
+ for (const kept of ["globalThis.crypto.subtle = {", "sb_sha256_block", "__sbHmacRaw"]) expect(pruned).toContain(kept);
22
+ expect(pruned.length).toBeLessThan(prelude.length - 150_000);
23
+ // What is left is still JavaScript.
24
+ new Bun.Transpiler().transformSync(pruned);
25
+ });
26
+
27
+ test("auto keeps the code for a handler that names crypto.subtle, DigestStream or CryptoKey", () => {
28
+ const wrap = (source: string, mode: "auto" | "always") =>
29
+ wrapNativeFetchHandler(
30
+ source,
31
+ prelude,
32
+ {},
33
+ undefined,
34
+ undefined,
35
+ "2026-11-06",
36
+ "app",
37
+ undefined,
38
+ "embedded",
39
+ undefined,
40
+ mode,
41
+ );
42
+ expect(wrap(handler("await crypto.subtle.digest('SHA-256', new Uint8Array(1));"), "auto")).toContain(
43
+ "__sbSubtleNext",
44
+ );
45
+ expect(wrap(handler("new crypto.DigestStream('SHA-1');"), "auto")).toContain("__sbSubtleNext");
46
+ expect(wrap(handler("if (k instanceof CryptoKey) {}"), "auto")).toContain("__sbSubtleNext");
47
+ expect(wrap(handler(""), "auto")).not.toContain("__sbSubtleNext");
48
+ expect(wrap(handler(""), "always")).toContain("__sbSubtleNext");
49
+ });
50
+
51
+ test("a prelude without the markers is an error, not a silent no-op", () => {
52
+ expect(() => pruneSubtle("function f() {}")).toThrow(/no \/\/ SUBTLE-JS:BEGIN/);
53
+ });