1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
// Copyright 2026 The KCP Authors.
// SPDX-License-Identifier: Apache-2.0
// policy_sandbox.js is the page half of the fenced policy sandbox (plan 0037),
// the browser counterpart of socialweb's deno runner under the policy flags.
//
// - startFrame() (page main thread): one hidden <iframe sandbox="allow-scripts">
// per tab. Its srcdoc has an opaque origin: no page storage, DOM or
// channels, and fetch carries Origin: null without credentials.
// - connectFrame(frame) gives a MessagePort the kcp worker receives in init.
// - The frame script (serve/runProgram) starts one module Worker per run from
// a data: bootstrap that imports the program as its own module, and terminates it on its answer, at the
// deadline or on kill: one Worker per Run, as deno is one process per Run.
// The frame only relays; program code never runs on the page's thread.
// - connect(port) (kcp worker) is __socialwebSandbox {run, kill, probe} for
// socialweb-wasm/internal/browsersandbox.
//
// The bootstrap (the Worker's module) gives a program what deno gives policy code and nothing of the
// page: Deno.env (Program.Env), exit, stdout/stderr.writeSync, inspect,
// read/writeTextFile that fail like deno without --allow-read, console.log/
// info/debug on stdout and warn/error on stderr (64 KiB each), uncaught
// errors exit 1. "node:" imports of the bundled actions resolve to small data:
// shims (Buffer, crypto.webcrypto/randomBytes, dns that always fails so the
// atproto handle resolver falls back to HTTPS). fetch is the Worker's own.
export const OUTPUT_LIMIT = 64 << 10;
const PROBE_DB = "kcp-fence-probe";
const PROBE_CACHE_URL = "https://kcp-fence-probe.invalid/marker";
// --- runs inside the sandbox Worker (serialized into each program) ------------
function bootstrap(env, cap, program) {
const out = { stdout: "", stderr: "" };
let done = false;
const decoder = new TextDecoder();
// Stack frames name their module URL, and the program's is a data: URL of
// its whole (spliced, possibly secret-bearing) source: never print one.
// \S+: encodeURIComponent leaves ( ) ' ! * ~ as they are, so stopping at
// any of them would print the rest of the source (re-check page-rc134).
const scrub = (text) => String(text).replace(/data:text\/javascript,\S+/g, "policy-program.js");
const write = (stream, text) => {
if (out[stream].length < cap) out[stream] = (out[stream] + scrub(text)).slice(0, cap);
};
const exit = (code = 0) => {
if (done) return;
done = true;
postMessage({ exitCode: Number(code) | 0, stdout: out.stdout, stderr: out.stderr });
};
const inspect = (value) => {
if (value instanceof Error) return value.stack || String(value);
try {
const text = JSON.stringify(value);
return text === undefined ? String(value) : text;
} catch {
return String(value);
}
};
const line = (args) => args.map((v) => typeof v === "string" ? v : inspect(v)).join(" ") + "\n";
for (const m of ["log", "info", "debug"]) console[m] = (...args) => write("stdout", line(args));
for (const m of ["warn", "error", "trace"]) console[m] = (...args) => write("stderr", line(args));
class NotCapable extends Error {
name = "NotCapable";
}
const denied = (what, path) => new NotCapable(`Requires ${what} access to "${path}", run again with the --allow-${what} flag`);
const stream = (name) => ({
writeSync: (bytes) => {
write(name, typeof bytes === "string" ? bytes : decoder.decode(bytes));
return bytes.length;
},
write: (bytes) => Promise.resolve(stream(name).writeSync(bytes)),
});
const deno = {
env: {
get: (key) => env[key],
has: (key) => Object.prototype.hasOwnProperty.call(env, key),
set: (key, value) => { env[key] = String(value); },
delete: (key) => { delete env[key]; },
toObject: () => ({ ...env }),
},
exit,
stdout: stream("stdout"),
stderr: stream("stderr"),
inspect,
errors: { NotCapable },
readTextFile: (path) => Promise.reject(denied("read", path)),
readTextFileSync: (path) => { throw denied("read", path); },
writeTextFile: (path) => Promise.reject(denied("write", path)),
writeTextFileSync: (path) => { throw denied("write", path); },
};
Object.defineProperty(globalThis, "Deno", { value: deno, writable: true, configurable: true });
// Uncaught errors exit 1 unless the program handled them (the action shim
// calls preventDefault and exits itself); checked after all listeners ran.
const uncaught = (prefix, error, event) => {
if (done || event.defaultPrevented) return;
write("stderr", `error: ${prefix}${inspect(error)}\n`);
exit(1);
};
addEventListener("unhandledrejection", (e) => setTimeout(() => uncaught("Uncaught (in promise) ", e.reason, e)));
addEventListener("error", (e) => setTimeout(() => uncaught("Uncaught ", e.error ?? e.message, e)));
// The program is its own module, imported from here so its evaluation
// (incl. top-level await) is observed: done exits 0, as deno does at the
// end of the main module; a throw is dispatched as an "unhandledrejection"
// event, as deno does for its main module.
// data:, not blob:: WebKit denies a module load of a blob: URL made in this
// opaque-origin Worker as cross-origin (iOS WKWebView probe, plan 0038 I6).
// sourceURL names the module in stack traces instead of its data: URL.
const evaluated = import("data:text/javascript," + encodeURIComponent(program + "\n//# sourceURL=policy-program.js\n"));
evaluated.then(() => exit(0), (reason) => {
const event = Object.assign(new Event("unhandledrejection", { cancelable: true }), { reason, promise: evaluated });
dispatchEvent(event);
uncaught("Uncaught (in promise) ", reason, event);
});
}
function nodeShim(name) {
const shims = {
buffer: `export class Buffer extends Uint8Array {
static from(value, a, b) {
if (typeof value === "string") {
if (a && !/^utf-?8$/i.test(a)) throw new Error("Buffer.from: only utf8 in the browser policy sandbox");
return new Buffer(new TextEncoder().encode(value));
}
if (value instanceof ArrayBuffer) return new Buffer(value, a ?? 0, b ?? value.byteLength - (a ?? 0));
return new Buffer(value);
}
static alloc(n) { return new Buffer(n); }
static allocUnsafe(n) { return new Buffer(n); }
static isBuffer(value) { return value instanceof Buffer; }
static concat(list, total) {
total ??= list.reduce((n, b) => n + b.length, 0);
const out = new Buffer(total);
let at = 0;
for (const b of list) {
if (at >= total) break;
out.set(b.subarray(0, total - at), at);
at += b.length;
}
return out;
}
toString(encoding = "utf8") {
if (!/^utf-?8$/i.test(encoding)) throw new Error("Buffer.toString: only utf8 in the browser policy sandbox");
return new TextDecoder().decode(this);
}
}
export default { Buffer };`,
crypto: `export const webcrypto = globalThis.crypto;
export const randomBytes = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
export const randomUUID = () => globalThis.crypto.randomUUID();
export function createHash(algorithm) {
throw new Error("node:crypto createHash(" + algorithm + ") is not available in the browser policy sandbox");
}
export default { webcrypto, randomBytes, randomUUID, createHash };`,
"dns/promises": `const fail = async () => {
const err = new Error("DNS is not available in the browser policy sandbox");
err.code = "ENOTFOUND";
throw err;
};
export const resolveTxt = fail;
export const lookup = fail;
export class Resolver { setServers() {} resolveTxt() { return fail(); } }
export default { resolveTxt, lookup, Resolver };`,
};
const code = shims[name] ?? `throw new Error(${JSON.stringify(`node:${name} is not available in the browser policy sandbox`)});`;
return "data:text/javascript," + encodeURIComponent(code);
}
// ponytail: a regex, so a "node:" specifier spelled inside a string literal
// is rewritten too; a parser if a policy ever needs that text verbatim.
// node: imports become loads of their data: shims. WebKit denies a static
// import of a data: module inside the opaque-origin Worker (a dynamic
// import() of one loads), so static forms turn into top-level awaits:
// import d, { a, b as c } from "node:x" -> const { default: d, a, b: c } =
// await import("data:..."); import * as ns from -> const ns = await import;
// import "node:x" -> await import; export { a as b } from "node:x" -> const
// { a: b } = await import; export { b }. export * from "node:x" cannot be
// rewritten without a static import and fails with a clear error. Static
// forms only match at the start of a line (bundlers emit them there; spliced
// JSON data never starts one) and brace lists are bounded, so the scan stays
// linear on hostile input (review page-rc134 F2, F4).
function rewriteNodeImports(source) {
const shim = (name) => JSON.stringify(nodeShim(name));
const pairs = (named) => named.split(",").map((part) => part.trim()).filter(Boolean)
.map((part) => {
const m = part.match(/^([\w$]+)(?:\s+as\s+([\w$]+))?$/);
return m ? { from: m[1], to: m[2] ?? m[1] } : null;
});
const fields = (list) => list.map((p) => (p.from === p.to ? p.from : `${p.from}: ${p.to}`)).join(", ");
return source
.replace(/^import\s+([\w$]+\s*,\s*)?(\*\s*as\s+[\w$]+|\{[^}]{0,4096}\}|[\w$]+)\s*from\s*(["'])node:([\w/]+)\3\s*;?/gm,
(m, first, clause, _q, name) => {
const def = first ? first.replace(/\s*,\s*$/, "") : "";
const mod = `await import(${shim(name)})`;
if (clause.startsWith("*")) {
const ns = clause.replace(/^\*\s*as\s+/, "");
return `const ${ns} = ${mod};` + (def ? ` const ${def} = ${ns}.default;` : "");
}
if (clause.startsWith("{")) {
const list = pairs(clause.slice(1, -1));
if (list.includes(null)) return m;
return `const { ${[def && `default: ${def}`, fields(list)].filter(Boolean).join(", ")} } = ${mod};`;
}
return `const { default: ${clause} } = ${mod};`;
})
.replace(/^export\s*\{([^}]{0,4096})\}\s*from\s*(["'])node:([\w/]+)\2\s*;?/gm, (m, named, _q, name) => {
const list = pairs(named);
if (list.includes(null)) return m;
return `const { ${fields(list)} } = await import(${shim(name)}); export { ${list.map((p) => p.to).join(", ")} };`;
})
.replace(/^export\s*\*\s*(?:as\s+[\w$]+\s*)?from\s*(["'])node:([\w/]+)\1\s*;?/gm, (_m, _q, name) =>
`throw new Error(${JSON.stringify(`export * from "node:${name}" is not supported in the browser policy sandbox`)});`)
.replace(/^import\s*(["'])node:([\w/]+)\1\s*;?/gm, (_m, _q, name) => `await import(${shim(name)});`)
.replace(/(\bimport\s*\(\s*)(["'])node:([\w/]+)\2/g, (_m, head, _q, name) => head + shim(name));
}
function programModule(source, env, cap) {
return `(${bootstrap})(${JSON.stringify(env ?? {})}, ${cap}, ${JSON.stringify(rewriteNodeImports(source))});\n`;
}
// --- runs in the frame (serialized into its srcdoc) and in tests -------------
function runProgram(request, done, cap) {
let finished = false;
let worker = null;
let timer = null;
// data:, not blob: Chromium fails every module Worker from a blob:null URL
// made in an opaque-origin frame (a classic one loads); data: loads in
// Chrome and iOS Safari (plan 0037 step 0).
const url = "data:text/javascript," + encodeURIComponent(programModule(String(request.source ?? ""), request.env, cap));
const finish = (result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
worker?.terminate();
done(result);
};
try {
worker = new Worker(url, { type: "module" });
} catch (err) {
finish({ exitCode: 1, stdout: "", stderr: `error: ${err}\n`, timedOut: false });
return { kill() {} };
}
worker.onmessage = ({ data }) =>
finish({ exitCode: Number(data?.exitCode) | 0, stdout: String(data?.stdout ?? ""), stderr: String(data?.stderr ?? ""), timedOut: false });
// A module that fails to load or throws past the bootstrap; the Worker's own
// report (with its captured output) wins if it arrives within 100 ms.
worker.onerror = (event) => {
event.preventDefault();
const message = event.message || "the program failed to load";
setTimeout(() => finish({ exitCode: 1, stdout: "", stderr: `error: ${message}\n`, timedOut: false }), 100);
};
const killed = () => finish({ exitCode: -1, stdout: "", stderr: "", timedOut: true });
timer = setTimeout(killed, request.timeoutMs > 0 ? request.timeoutMs : 120_000);
return { kill: killed };
}
function serve(port, cap) {
const runs = new Map();
port.onmessage = ({ data }) => {
if (data?.kill !== undefined) return runs.get(data.kill)?.kill();
if (data?.frame) {
let parentDocument = "blocked";
try {
if (parent.document) parentDocument = "READABLE";
} catch {
// SecurityError: the opaque origin cannot reach the page's DOM
}
return port.postMessage({ id: data.id, origin: self.origin, parentDocument });
}
const id = data?.id;
runs.set(id, runProgram(data, (result) => {
runs.delete(id);
port.postMessage({ id, ...result });
}, cap));
};
}
function relay(cap) {
addEventListener("message", (event) => {
if (event.source === parent && event.data?.type === "policy-sandbox-port" && event.ports[0]) serve(event.ports[0], cap);
});
parent.postMessage({ type: "policy-sandbox-ready" }, "*");
}
// Defense in depth (the page has no CSP): scripts only inline (the relay)
// and data: (the per-run Worker, the program it imports, node shims); blob:
// is no longer used by the sandbox itself; fetch only https.
export const FRAME_CSP = "default-src 'none'; script-src 'unsafe-inline' blob: data:; worker-src data:; connect-src https:";
export function frameSrcdoc(cap = OUTPUT_LIMIT) {
const code = [bootstrap, nodeShim, rewriteNodeImports, programModule, runProgram, serve, relay].join("\n");
return `<!doctype html><meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}"><script>${code}\nrelay(${cap});<\/script>`;
}
// For tests: run one program in a module Worker as the frame does.
export function runInWorker(request, cap = OUTPUT_LIMIT) {
let handle;
const result = new Promise((resolve) => {
handle = runProgram(request, resolve, cap);
});
return Object.assign(result, { kill: () => handle.kill() });
}
export { programModule, rewriteNodeImports };
// --- page main thread ----------------------------------------------------------
// The Compute panel's policy sandbox line from the status feed's PolicyEngine.
// Before the probe has written Fenced (no status, no message) it is probing,
// not "not fenced".
export function policyEngineStatus(engine) {
if (!engine) return { text: "", className: "" };
const fenced = engine.fenced === "True";
const state = fenced ? "fenced" : engine.fenced === "False" || engine.message ? "not fenced" : "probing...";
const text = `policy sandbox: ${state}${engine.ready ? ", ready" : ""}${engine.message ? ` (${engine.message})` : ""}`;
return { text, className: fenced && engine.ready ? "ok" : "warn" };
}
// startFrame appends the hidden sandbox iframe and resolves once its relay is
// listening.
export function startFrame(doc = document) {
return new Promise((resolve, reject) => {
const frame = doc.createElement("iframe");
const timer = setTimeout(() => {
removeEventListener("message", onMessage);
reject(new Error("policy sandbox frame did not start"));
}, 10_000);
const onMessage = (event) => {
if (event.source !== frame.contentWindow || event.data?.type !== "policy-sandbox-ready") return;
clearTimeout(timer);
removeEventListener("message", onMessage);
resolve(frame);
};
addEventListener("message", onMessage);
frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("aria-hidden", "true");
frame.hidden = true;
frame.srcdoc = frameSrcdoc();
doc.body.appendChild(frame);
});
}
// connectFrame returns a port for one kcp worker; the frame serves every port
// it is given, so it outlives kcp worker restarts.
export function connectFrame(frame) {
const channel = new MessageChannel();
frame.contentWindow.postMessage({ type: "policy-sandbox-port" }, "*", [channel.port2]);
return channel.port1;
}
// --- kcp worker ------------------------------------------------------------------
// connect is __socialwebSandbox: run(requestJSON) and probe() resolve to JSON
// strings for Go; kill(id) ends a run early.
export function connect(port, { timeoutMs = 20_000 } = {}) {
const pending = new Map();
let next = 0;
port.onmessage = ({ data }) => {
pending.get(data?.id)?.(data);
pending.delete(data?.id);
};
const call = (message, wait) => new Promise((resolve, reject) => {
pending.set(message.id, resolve);
port.postMessage(message);
if (wait) {
setTimeout(() => {
if (pending.delete(message.id)) reject(new Error(`the policy sandbox frame did not answer in ${wait} ms`));
}, wait);
}
});
const run = (request) => call({ ...request, id: `probe-${++next}` }, (request.timeoutMs ?? 0) + timeoutMs);
return {
run: (json) => {
const { id, source, env, timeoutMs: ms } = JSON.parse(json);
return call({ id, source, env, timeoutMs: ms }).then(({ exitCode, stdout, stderr, timedOut }) =>
JSON.stringify({ exitCode, stdout, stderr, timedOut }));
},
kill: (id) => port.postMessage({ kill: id }),
probe: () => probe({ run, frame: () => call({ id: `probe-${++next}`, frame: true }, timeoutMs) }).then((r) => JSON.stringify(r)),
};
}
// probe (kcp worker, page origin) plants markers in the page's IndexedDB,
// Cache Storage and OPFS and listens on a BroadcastChannel, then runs a probe
// program in the sandbox. Fenced only if the sandbox sees none of them, has
// an opaque origin and no DOM, the frame cannot read the page's document, a
// program that never ends is killed at its deadline and the next run works.
export async function probe({ run, frame }) {
const marker = `m${crypto.getRandomValues(new Uint32Array(2)).join("")}`;
const facts = {};
const pageNames = { idb: [], opfs: [] };
let heard = false;
const channel = new BroadcastChannel(PROBE_DB);
channel.onmessage = () => { heard = true; };
try {
facts.idbPlanted = await plantIDB(marker).then(() => "yes", (e) => `no:${e?.name ?? "error"}`);
pageNames.idb = (await globalThis.indexedDB?.databases?.())?.map((d) => d.name) ?? [];
facts.cachePlanted = await plantCache(marker);
pageNames.opfs = await plantOPFS();
const inner = await run({ source: probeProgram(marker, pageNames), env: {}, timeoutMs: 15_000 });
let seen = {};
try {
seen = JSON.parse(inner.stdout.trim().split("\n").pop());
} catch {
facts.program = `exit=${inner.exitCode}${inner.timedOut ? " timedOut" : ""} ${inner.stderr.slice(0, 200)}`.trim();
}
await new Promise((resolve) => setTimeout(resolve, 100));
Object.assign(facts, seen);
facts.bc = heard ? "HEARD" : "silent";
const f = await frame();
facts.frameOrigin = f.origin;
facts.parent = f.parentDocument;
const endless = await run({ source: "for (;;) {}", env: {}, timeoutMs: 1_000 });
facts.timeout = endless.timedOut ? "killed" : `exit=${endless.exitCode}`;
const again = await run({ source: 'console.log("ok");', env: {}, timeoutMs: 10_000 });
facts.rerun = again.exitCode === 0 && again.stdout === "ok\n" ? "ok" : `exit=${again.exitCode}`;
} catch (err) {
facts.error = String(err?.message ?? err);
} finally {
channel.close();
await cleanup();
}
const fenced = !facts.error && !facts.program && facts.origin === "null" && facts.frameOrigin === "null" &&
facts.dom === "none" && facts.shim === "ok" &&
!["idb", "cache", "opfs"].some((k) => String(facts[k]).startsWith("VISIBLE")) &&
facts.bc === "silent" && facts.parent === "blocked" && facts.timeout === "killed" && facts.rerun === "ok";
const message = Object.entries(facts).map(([k, v]) => `${k}=${v}`).join(" ");
return { fenced, message };
}
function probeProgram(marker, pageNames) {
return `import { Buffer } from "node:buffer";
const marker = ${JSON.stringify(marker)};
const pageNames = ${JSON.stringify(pageNames)};
const out = { origin: String(self.origin), shim: Buffer.from("ok").toString() };
out.dom = [typeof document, typeof parent, typeof top].every((t) => t === "undefined") ? "none" : "PRESENT";
const err = (e) => "denied:" + (e?.name ?? "error");
try {
const names = (await indexedDB.databases?.())?.map((d) => d.name) ?? [];
const value = await new Promise((resolve, reject) => {
const r = indexedDB.open(${JSON.stringify(PROBE_DB)});
r.onsuccess = () => {
const db = r.result;
if (!db.objectStoreNames.contains("s")) return resolve(undefined);
const g = db.transaction("s").objectStore("s").get("k");
g.onsuccess = () => resolve(g.result);
g.onerror = () => reject(g.error);
};
r.onerror = () => reject(r.error);
});
const listed = names.filter((n) => pageNames.idb.includes(n) && n !== ${JSON.stringify(PROBE_DB)});
out.idb = value === marker ? "VISIBLE" : listed.length ? "VISIBLE:" + listed.join(",") : "empty";
} catch (e) { out.idb = err(e); }
try {
out.cache = typeof caches === "undefined" ? "none"
: (await (await caches.open(${JSON.stringify(PROBE_DB)})).match(${JSON.stringify(PROBE_CACHE_URL)})) ? "VISIBLE" : "empty";
} catch (e) { out.cache = err(e); }
try {
const root = await navigator.storage.getDirectory();
const names = [];
for await (const name of root.keys()) names.push(name);
const seen = names.filter((n) => n === ${JSON.stringify(PROBE_DB)} || pageNames.opfs.includes(n));
out.opfs = seen.length ? "VISIBLE:" + seen.join(",") : "empty";
} catch (e) { out.opfs = err(e); }
try { new BroadcastChannel(${JSON.stringify(PROBE_DB)}).postMessage(marker); } catch {}
console.log(JSON.stringify(out));
`;
}
async function plantIDB(marker) {
await new Promise((resolve, reject) => {
const r = indexedDB.open(PROBE_DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore("s");
r.onsuccess = () => {
const tx = r.result.transaction("s", "readwrite");
tx.objectStore("s").put(marker, "k");
tx.oncomplete = () => {
r.result.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
r.onerror = () => reject(r.error);
});
}
async function plantCache(marker) {
try {
await (await caches.open(PROBE_DB)).put(PROBE_CACHE_URL, new Response(marker));
return "yes";
} catch (e) {
return `no:${e?.name ?? "error"}`;
}
}
// Returns the page's OPFS root names, including the planted marker file.
async function plantOPFS() {
try {
const root = await navigator.storage.getDirectory();
await root.getFileHandle(PROBE_DB, { create: true });
const names = [];
for await (const name of root.keys()) names.push(name);
return names;
} catch {
return [];
}
}
async function cleanup() {
await Promise.allSettled([
new Promise((resolve) => {
const r = globalThis.indexedDB?.deleteDatabase(PROBE_DB);
if (!r) return resolve();
r.onsuccess = r.onerror = r.onblocked = resolve;
}),
globalThis.caches?.delete(PROBE_DB),
navigator.storage?.getDirectory?.().then((root) => root.removeEntry(PROBE_DB)),
]);
}