@pooriaarab/foxden 0.0.0-stage → 0.1.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/dist/den.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { RunErrorKind, RunReply, RunRequest } from "./protocol.js";
2
+ import { type ShellOptions, type ShellResult } from "./shell.js";
3
+ export interface RuntimeInfo {
4
+ /** The adapter, for example "pyodide-iframe". */
5
+ kind: string;
6
+ /** How the runtime is cut off from the page, for example "manifest-sandbox". */
7
+ isolation: string;
8
+ loadMs: number;
9
+ }
10
+ /** A runtime adapter. foxden ships iframeRuntime; other adapters can plug in here. */
11
+ export interface DenRuntime {
12
+ start(): Promise<RuntimeInfo>;
13
+ run(request: RunRequest): Promise<RunReply>;
14
+ close(): void;
15
+ }
16
+ /** Where a den keeps its files between page loads. */
17
+ export interface DenStore {
18
+ load(name: string): Promise<Map<string, Uint8Array> | null>;
19
+ save(name: string, files: ReadonlyMap<string, Uint8Array>): Promise<void>;
20
+ remove(name: string): Promise<void>;
21
+ }
22
+ export interface OpenDenOptions {
23
+ /** 1-64 characters: ASCII letters, digits, - and _. */
24
+ name: string;
25
+ /** Makes a new runtime. The den calls it again after a timeout. */
26
+ runtime: () => DenRuntime;
27
+ /** Default: memoryStore(). Use idbStore() to keep files across page loads. */
28
+ store?: DenStore;
29
+ /** Start from these snapshot bytes instead of the stored files. */
30
+ snapshot?: Uint8Array;
31
+ /** The most bytes all files of the den can hold. Default 256 MiB. */
32
+ maxDenBytes?: number;
33
+ /** How long past timeoutMs to wait for the runtime before the den kills it. Default 5000. */
34
+ killGraceMs?: number;
35
+ }
36
+ export interface RunOptions {
37
+ /** Default 30000. */
38
+ timeoutMs?: number;
39
+ /** The most characters of stdout and of stderr to keep. Default 1 MiB. */
40
+ maxOutputBytes?: number;
41
+ }
42
+ export interface RunResult {
43
+ stdout: string;
44
+ stderr: string;
45
+ result: string | null;
46
+ error: {
47
+ kind: RunErrorKind;
48
+ message: string;
49
+ } | null;
50
+ truncated: boolean;
51
+ /** Paths that the run created, changed or deleted. */
52
+ files: string[];
53
+ durationMs: number;
54
+ }
55
+ export interface Den {
56
+ readonly name: string;
57
+ readonly info: RuntimeInfo;
58
+ run(code: string, options?: RunOptions): Promise<RunResult>;
59
+ sh(command: string, options?: ShellOptions): Promise<ShellResult>;
60
+ writeFile(path: string, data: string | Uint8Array): Promise<void>;
61
+ readFile(path: string): Promise<Uint8Array>;
62
+ deleteFile(path: string): Promise<void>;
63
+ list(): Promise<{
64
+ path: string;
65
+ size: number;
66
+ }[]>;
67
+ snapshot(): Promise<Uint8Array>;
68
+ restore(snapshot: Uint8Array): Promise<void>;
69
+ fork(options: {
70
+ name: string;
71
+ }): Promise<Den>;
72
+ close(): Promise<void>;
73
+ }
74
+ /** A store that keeps files in memory only. */
75
+ export declare function memoryStore(): DenStore;
76
+ /** Open a den by name. It loads the stored files and starts the runtime. */
77
+ export declare function openDen(options: OpenDenOptions): Promise<Den>;
package/dist/den.js ADDED
@@ -0,0 +1,227 @@
1
+ // The den: a named set of files plus a runtime that runs code over them.
2
+ // The den object owns the files. The runtime (by default a sandboxed iframe
3
+ // with Pyodide) gets a copy for each run and sends back what changed, so a
4
+ // runtime that hangs or crashes can be thrown away without losing files.
5
+ import { DenError, DenLoadError, PathError } from "./errors.js";
6
+ import { normalizePath } from "./paths.js";
7
+ import { runShell } from "./shell.js";
8
+ import { decodeSnapshot, encodeSnapshot } from "./snapshot.js";
9
+ const DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
10
+ const NAME = /^[A-Za-z0-9_-]{1,64}$/;
11
+ const open = new Set();
12
+ const size = (files) => [...files.values()].reduce((sum, b) => sum + b.length, 0);
13
+ // The one size check for every way files enter a den: writes, runs,
14
+ // restore() and openDen({ snapshot }).
15
+ function checkSize(name, files, max) {
16
+ if (size(files) > max)
17
+ throw new DenError(`The den ${name} would hold more than maxDenBytes (${max} bytes).`);
18
+ }
19
+ /** A store that keeps files in memory only. */
20
+ export function memoryStore() {
21
+ const dens = new Map();
22
+ return {
23
+ load: async (name) => (dens.has(name) ? new Map(dens.get(name)) : null),
24
+ save: async (name, files) => void dens.set(name, new Map(files)),
25
+ remove: async (name) => void dens.delete(name),
26
+ };
27
+ }
28
+ /** Open a den by name. It loads the stored files and starts the runtime. */
29
+ export async function openDen(options) {
30
+ const { name } = options;
31
+ if (typeof name !== "string" || !NAME.test(name))
32
+ throw new DenError(`The den name ${JSON.stringify(name)} must be 1-64 ASCII letters, digits, - or _.`);
33
+ if (open.has(name))
34
+ throw new DenError(`The den ${name} is already open in this page. Close it first.`);
35
+ open.add(name);
36
+ try {
37
+ const store = options.store ?? memoryStore();
38
+ let files = (await store.load(name)) ?? new Map();
39
+ if (options.snapshot) {
40
+ files = (await decodeSnapshot(options.snapshot)).files;
41
+ checkSize(name, files, options.maxDenBytes ?? DEFAULT_MAX_BYTES);
42
+ await store.save(name, files);
43
+ }
44
+ const runtime = options.runtime();
45
+ let info;
46
+ try {
47
+ info = await runtime.start();
48
+ }
49
+ catch (error) {
50
+ runtime.close();
51
+ throw new DenLoadError(`The den runtime did not start: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ return new DenImpl(options, store, files, runtime, info);
54
+ }
55
+ catch (error) {
56
+ open.delete(name);
57
+ throw error;
58
+ }
59
+ }
60
+ class DenImpl {
61
+ options;
62
+ store;
63
+ name;
64
+ info;
65
+ #files;
66
+ #runtime;
67
+ #queue = Promise.resolve();
68
+ #closed = false;
69
+ #nextId = 1;
70
+ #maxBytes;
71
+ #graceMs;
72
+ constructor(options, store, files, runtime, info) {
73
+ this.options = options;
74
+ this.store = store;
75
+ this.name = options.name;
76
+ this.#files = files;
77
+ this.#runtime = runtime;
78
+ this.info = info;
79
+ this.#maxBytes = options.maxDenBytes ?? DEFAULT_MAX_BYTES;
80
+ this.#graceMs = options.killGraceMs ?? 5000;
81
+ }
82
+ #check() {
83
+ if (this.#closed)
84
+ throw new DenError(`The den ${this.name} is closed.`);
85
+ }
86
+ // Run fn after every earlier run and write, so changes apply in call order.
87
+ #enqueue(fn) {
88
+ if (this.#closed)
89
+ return Promise.reject(new DenError(`The den ${this.name} is closed.`));
90
+ const next = this.#queue.then(fn);
91
+ this.#queue = next.catch(() => { });
92
+ return next;
93
+ }
94
+ // Save first, then switch, so a failed save leaves the den as it was.
95
+ async #commit(files) {
96
+ checkSize(this.name, files, this.#maxBytes);
97
+ await this.store.save(this.name, files);
98
+ this.#files = files;
99
+ }
100
+ async #liveRuntime() {
101
+ if (this.#runtime)
102
+ return this.#runtime;
103
+ const runtime = this.options.runtime();
104
+ try {
105
+ this.info = await runtime.start();
106
+ }
107
+ catch (error) {
108
+ runtime.close();
109
+ throw new DenLoadError(`The den runtime did not restart: ${error instanceof Error ? error.message : String(error)}`);
110
+ }
111
+ this.#runtime = runtime;
112
+ return runtime;
113
+ }
114
+ run(code, options = {}) {
115
+ return this.#enqueue(async () => {
116
+ if (typeof code !== "string")
117
+ throw new DenError("run() needs the code as a string.");
118
+ const timeoutMs = options.timeoutMs ?? 30_000;
119
+ const max = options.maxOutputBytes ?? 1024 * 1024;
120
+ const request = { id: this.#nextId++, code, files: [...this.#files], timeoutMs, maxOutputBytes: max };
121
+ const started = Date.now();
122
+ const runtime = await this.#liveRuntime();
123
+ let timer;
124
+ const late = new Promise((done) => {
125
+ timer = setTimeout(() => done(null), timeoutMs + this.#graceMs);
126
+ });
127
+ const reply = await Promise.race([runtime.run(request), late]).finally(() => clearTimeout(timer));
128
+ if (!reply) {
129
+ runtime.close();
130
+ this.#runtime = null;
131
+ await this.#liveRuntime().catch(() => { });
132
+ return this.#result({ error: { kind: "timeout", message: `The code ran for more than ${timeoutMs} ms and was stopped.` } }, max, started);
133
+ }
134
+ const keep = reply.error === null || reply.error.kind === "python";
135
+ const { files, changed, notes } = keep ? this.#apply(reply.files) : { files: this.#files, changed: [], notes: "" };
136
+ try {
137
+ if (changed.length > 0)
138
+ await this.#commit(files);
139
+ }
140
+ catch (error) {
141
+ return this.#result({ ...reply, files: [], error: { kind: "storage", message: error.message } }, max, started);
142
+ }
143
+ return this.#result({ ...reply, stderr: reply.stderr + notes, files: changed }, max, started);
144
+ });
145
+ }
146
+ #apply(changes) {
147
+ const files = new Map(this.#files);
148
+ const changed = [];
149
+ let notes = "";
150
+ for (const [raw, body] of changes) {
151
+ let path;
152
+ try {
153
+ path = normalizePath(raw);
154
+ }
155
+ catch (error) {
156
+ notes += `foxden: ignored a file with a bad path: ${error.message}\n`;
157
+ continue;
158
+ }
159
+ if (body)
160
+ files.set(path, body);
161
+ else
162
+ files.delete(path);
163
+ changed.push(path);
164
+ }
165
+ return { files, changed, notes };
166
+ }
167
+ #result(r, max, started) {
168
+ const stdout = r.stdout ?? "";
169
+ const stderr = r.stderr ?? "";
170
+ return {
171
+ stdout: stdout.slice(0, max),
172
+ stderr: stderr.slice(0, max),
173
+ result: r.result ?? null,
174
+ error: r.error ?? null,
175
+ truncated: (r.truncated ?? false) || stdout.length > max || stderr.length > max,
176
+ files: r.files ?? [],
177
+ durationMs: r.durationMs ?? Date.now() - started,
178
+ };
179
+ }
180
+ async sh(command, options) {
181
+ this.#check();
182
+ return runShell(command, this.#files, options);
183
+ }
184
+ async writeFile(path, data) {
185
+ const p = normalizePath(path);
186
+ const body = typeof data === "string" ? new TextEncoder().encode(data) : data.slice();
187
+ return this.#enqueue(() => this.#commit(new Map(this.#files).set(p, body)));
188
+ }
189
+ async readFile(path) {
190
+ this.#check();
191
+ const p = normalizePath(path);
192
+ const body = this.#files.get(p);
193
+ if (!body)
194
+ throw new DenError(`${p}: No such file in den ${this.name}.`);
195
+ return body.slice();
196
+ }
197
+ async deleteFile(path) {
198
+ const p = normalizePath(path);
199
+ return this.#enqueue(async () => {
200
+ const files = new Map(this.#files);
201
+ files.delete(p);
202
+ await this.#commit(files);
203
+ });
204
+ }
205
+ async list() {
206
+ this.#check();
207
+ return [...this.#files].map(([path, body]) => ({ path, size: body.length })).toSorted((a, b) => (a.path < b.path ? -1 : 1));
208
+ }
209
+ async snapshot() {
210
+ this.#check();
211
+ return encodeSnapshot(this.name, this.#files);
212
+ }
213
+ restore(snapshot) {
214
+ return this.#enqueue(async () => this.#commit((await decodeSnapshot(snapshot)).files));
215
+ }
216
+ async fork({ name }) {
217
+ return openDen({ ...this.options, name, snapshot: await this.snapshot() });
218
+ }
219
+ async close() {
220
+ if (this.#closed)
221
+ return;
222
+ this.#closed = true;
223
+ this.#runtime?.close();
224
+ this.#runtime = null;
225
+ open.delete(this.name);
226
+ }
227
+ }
@@ -0,0 +1,20 @@
1
+ declare class FoxdenError extends Error {
2
+ constructor(name: string, message: string);
3
+ }
4
+ /** A path is not a file path inside /drop, /out or /work. */
5
+ export declare class PathError extends FoxdenError {
6
+ constructor(message: string);
7
+ }
8
+ /** Snapshot bytes are corrupt, cut short, or forged. */
9
+ export declare class SnapshotError extends FoxdenError {
10
+ constructor(message: string);
11
+ }
12
+ /** A den is used in a wrong way: a bad name, a name already open, or a closed den. */
13
+ export declare class DenError extends FoxdenError {
14
+ constructor(message: string);
15
+ }
16
+ /** The runtime could not start, for example because Pyodide files are missing. */
17
+ export declare class DenLoadError extends FoxdenError {
18
+ constructor(message: string);
19
+ }
20
+ export {};
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ // The error classes foxden throws. Each has a stable `name`, so callers can
2
+ // check `error.name` across realms (for example an extension page and a tab).
3
+ class FoxdenError extends Error {
4
+ constructor(name, message) {
5
+ super(message);
6
+ this.name = name;
7
+ }
8
+ }
9
+ /** A path is not a file path inside /drop, /out or /work. */
10
+ export class PathError extends FoxdenError {
11
+ constructor(message) {
12
+ super("PathError", message);
13
+ }
14
+ }
15
+ /** Snapshot bytes are corrupt, cut short, or forged. */
16
+ export class SnapshotError extends FoxdenError {
17
+ constructor(message) {
18
+ super("SnapshotError", message);
19
+ }
20
+ }
21
+ /** A den is used in a wrong way: a bad name, a name already open, or a closed den. */
22
+ export class DenError extends FoxdenError {
23
+ constructor(message) {
24
+ super("DenError", message);
25
+ }
26
+ }
27
+ /** The runtime could not start, for example because Pyodide files are missing. */
28
+ export class DenLoadError extends FoxdenError {
29
+ constructor(message) {
30
+ super("DenLoadError", message);
31
+ }
32
+ }
@@ -0,0 +1,3 @@
1
+ import type { DenStore } from "./den.js";
2
+ /** A store in the IndexedDB database `dbName` (default "foxden"). */
3
+ export declare function idbStore(dbName?: string): DenStore;
@@ -0,0 +1,35 @@
1
+ import { DenError } from "./errors.js";
2
+ function request(r) {
3
+ return new Promise((resolve, reject) => {
4
+ r.addEventListener("success", () => resolve(r.result));
5
+ r.addEventListener("error", () => reject(r.error ?? new DenError("An IndexedDB request failed.")));
6
+ });
7
+ }
8
+ /** A store in the IndexedDB database `dbName` (default "foxden"). */
9
+ export function idbStore(dbName = "foxden") {
10
+ let db;
11
+ const open = () => {
12
+ if (typeof indexedDB === "undefined")
13
+ return Promise.reject(new DenError("IndexedDB is not available here, so idbStore cannot keep files. Use memoryStore()."));
14
+ if (!db) {
15
+ const r = indexedDB.open(dbName, 1);
16
+ r.addEventListener("upgradeneeded", () => r.result.createObjectStore("dens", { keyPath: "name" }));
17
+ db = request(r);
18
+ db.catch(() => (db = undefined));
19
+ }
20
+ return db;
21
+ };
22
+ const tx = async (mode) => (await open()).transaction("dens", mode).objectStore("dens");
23
+ return {
24
+ async load(name) {
25
+ const record = (await request((await tx("readonly")).get(name)));
26
+ return record ? new Map(record.files) : null;
27
+ },
28
+ async save(name, files) {
29
+ await request((await tx("readwrite")).put({ name, files: [...files] }));
30
+ },
31
+ async remove(name) {
32
+ await request((await tx("readwrite")).delete(name));
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,20 @@
1
+ import type { DenRuntime } from "./den.js";
2
+ export interface IframeRuntimeOptions {
3
+ /** URL of den.html (from @pooriaarab/foxden/dist/den/). */
4
+ denUrl: string | URL;
5
+ /** URL of a folder with pyodide.asm.wasm, python_stdlib.zip and pyodide-lock.json. */
6
+ pyodideUrl: string | URL;
7
+ /**
8
+ * "manifest-sandbox": a plain iframe of a page in the manifest `sandbox` key (Firefox 154+).
9
+ * "iframe-sandbox": an iframe with `sandbox="allow-scripts"` (websites, Firefox 153).
10
+ * "auto" (default): manifest-sandbox when the extension lists den.html there, else iframe-sandbox.
11
+ */
12
+ isolation?: Isolation | "auto";
13
+ /** Default 60000. */
14
+ loadTimeoutMs?: number;
15
+ /** Where to put the hidden iframe. Default document.body. */
16
+ container?: HTMLElement;
17
+ }
18
+ export type Isolation = "manifest-sandbox" | "iframe-sandbox";
19
+ /** A runtime factory for openDen: Pyodide in a sandboxed iframe with no network. */
20
+ export declare function iframeRuntime(options: IframeRuntimeOptions): () => DenRuntime;
@@ -0,0 +1,109 @@
1
+ import { parseDenMessage } from "./protocol.js";
2
+ const cache = new Map();
3
+ async function get(base, name) {
4
+ const response = await fetch(new URL(name, base));
5
+ if (!response.ok)
6
+ throw new Error(`${name}: HTTP ${response.status}`);
7
+ return response;
8
+ }
9
+ function assets(base) {
10
+ let pending = cache.get(base.href);
11
+ if (!pending) {
12
+ pending = Promise.all([get(base, "pyodide.asm.wasm"), get(base, "python_stdlib.zip"), get(base, "pyodide-lock.json")]).then(async ([wasm, stdlib, lock]) => ({ wasm: await wasm.arrayBuffer(), stdlib: await stdlib.arrayBuffer(), lock: (await lock.json()) }));
13
+ pending.catch(() => cache.delete(base.href));
14
+ cache.set(base.href, pending);
15
+ }
16
+ return pending;
17
+ }
18
+ function inManifestSandbox(den) {
19
+ if (location.protocol !== "moz-extension:" || den.origin !== location.origin)
20
+ return false;
21
+ const g = globalThis;
22
+ const pages = g.browser?.runtime?.getManifest?.().sandbox?.pages;
23
+ return Array.isArray(pages) && pages.some((p) => typeof p === "string" && new URL(p, `${location.origin}/`).pathname === den.pathname);
24
+ }
25
+ function within(promise, ms, message) {
26
+ let timer;
27
+ const late = new Promise((_, reject) => {
28
+ timer = setTimeout(() => reject(new Error(message)), ms);
29
+ });
30
+ return Promise.race([promise, late]).finally(() => clearTimeout(timer));
31
+ }
32
+ class IframeRuntime {
33
+ options;
34
+ #frame = null;
35
+ #port = null;
36
+ #pending = new Map();
37
+ constructor(options) {
38
+ this.options = options;
39
+ }
40
+ async start() {
41
+ const den = new URL(this.options.denUrl, location.href);
42
+ const mode = this.options.isolation ?? "auto";
43
+ const first = mode === "auto" ? (inManifestSandbox(den) ? "manifest-sandbox" : "iframe-sandbox") : mode;
44
+ try {
45
+ return await this.#open(den, first);
46
+ }
47
+ catch (error) {
48
+ // Firefox 153 ignores the manifest sandbox key, so the den page refuses
49
+ // to start there. In auto mode, try again with the sandbox attribute.
50
+ if (mode !== "auto" || first !== "manifest-sandbox" || !String(error.message).startsWith("not isolated"))
51
+ throw error;
52
+ this.close();
53
+ return this.#open(den, "iframe-sandbox");
54
+ }
55
+ }
56
+ async #open(den, isolation) {
57
+ const timeout = this.options.loadTimeoutMs ?? 60_000;
58
+ const started = performance.now();
59
+ const files = await assets(new URL(this.options.pyodideUrl, location.href));
60
+ const frame = document.createElement("iframe");
61
+ this.#frame = frame;
62
+ if (isolation === "iframe-sandbox")
63
+ frame.setAttribute("sandbox", "allow-scripts");
64
+ frame.hidden = true;
65
+ frame.title = "foxden sandbox";
66
+ frame.src = den.href;
67
+ const loaded = new Promise((done) => frame.addEventListener("load", done, { once: true }));
68
+ (this.options.container ?? document.body).append(frame);
69
+ const { port1, port2 } = new MessageChannel();
70
+ this.#port = port1;
71
+ const ready = new Promise((resolve, reject) => {
72
+ port1.addEventListener("message", (e) => {
73
+ const m = parseDenMessage(e.data);
74
+ if (m?.t === "ready")
75
+ resolve(m);
76
+ else if (m?.t === "load-error")
77
+ reject(new Error(m.message));
78
+ else if (m?.t === "reply") {
79
+ this.#pending.get(m.reply.id)?.(m.reply);
80
+ this.#pending.delete(m.reply.id);
81
+ }
82
+ });
83
+ port1.start();
84
+ });
85
+ await within(loaded, timeout, `${den.href} did not load in ${timeout} ms.`);
86
+ frame.contentWindow?.postMessage({ t: "init", v: 1, assets: files }, "*", [port2]);
87
+ const m = await within(ready, timeout, `Pyodide did not load in ${timeout} ms.`);
88
+ if (m.origin !== "null" || m.extensionApi)
89
+ throw new Error(`not isolated: the den page has origin ${m.origin}.`);
90
+ return { kind: "pyodide-iframe", isolation, loadMs: Math.round(performance.now() - started) };
91
+ }
92
+ run(request) {
93
+ return new Promise((done) => {
94
+ this.#pending.set(request.id, done);
95
+ this.#port?.postMessage({ t: "run", v: 1, request });
96
+ });
97
+ }
98
+ close() {
99
+ this.#port?.close();
100
+ this.#frame?.remove();
101
+ this.#port = null;
102
+ this.#frame = null;
103
+ this.#pending.clear();
104
+ }
105
+ }
106
+ /** A runtime factory for openDen: Pyodide in a sandboxed iframe with no network. */
107
+ export function iframeRuntime(options) {
108
+ return () => new IframeRuntime(options);
109
+ }
@@ -0,0 +1,8 @@
1
+ export { type Den, type DenRuntime, type DenStore, memoryStore, openDen, type OpenDenOptions, type RunOptions, type RunResult, type RuntimeInfo } from "./den.js";
2
+ export { DenError, DenLoadError, PathError, SnapshotError } from "./errors.js";
3
+ export { DEN_FOLDERS, normalizePath } from "./paths.js";
4
+ export { type DenMessage, type FileChanges, type HostMessage, parseDenMessage, parseHostMessage, PROTOCOL_VERSION, type RunReply, type RunRequest } from "./protocol.js";
5
+ export { runShell, type ShellOptions, type ShellResult } from "./shell.js";
6
+ export { decodeSnapshot, encodeSnapshot } from "./snapshot.js";
7
+ export { type IframeRuntimeOptions, type Isolation, iframeRuntime } from "./iframe-runtime.js";
8
+ export { idbStore } from "./idb-store.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // The public API of foxden.
2
+ export { memoryStore, openDen } from "./den.js";
3
+ export { DenError, DenLoadError, PathError, SnapshotError } from "./errors.js";
4
+ export { DEN_FOLDERS, normalizePath } from "./paths.js";
5
+ export { parseDenMessage, parseHostMessage, PROTOCOL_VERSION } from "./protocol.js";
6
+ export { runShell } from "./shell.js";
7
+ export { decodeSnapshot, encodeSnapshot } from "./snapshot.js";
8
+ export { iframeRuntime } from "./iframe-runtime.js";
9
+ export { idbStore } from "./idb-store.js";
@@ -0,0 +1,8 @@
1
+ /** The folders a den can hold files in. */
2
+ export declare const DEN_FOLDERS: readonly ["/drop", "/out", "/work"];
3
+ /**
4
+ * Return the one canonical spelling of a den file path, or throw PathError.
5
+ * Repeated slashes collapse. `.`, `..`, NUL and backslash are refused, so a
6
+ * path can never leave its folder.
7
+ */
8
+ export declare function normalizePath(path: unknown): string;
package/dist/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ // Path rules for den files. Every path that crosses a trust boundary (user,
2
+ // sandbox, snapshot) goes through normalizePath before foxden uses it.
3
+ import { PathError } from "./errors.js";
4
+ /** The folders a den can hold files in. */
5
+ export const DEN_FOLDERS = ["/drop", "/out", "/work"];
6
+ const MAX_PATH = 1024;
7
+ /**
8
+ * Return the one canonical spelling of a den file path, or throw PathError.
9
+ * Repeated slashes collapse. `.`, `..`, NUL and backslash are refused, so a
10
+ * path can never leave its folder.
11
+ */
12
+ export function normalizePath(path) {
13
+ if (typeof path !== "string")
14
+ throw new PathError(`A path must be a string, not ${typeof path}.`);
15
+ if (path.length > MAX_PATH)
16
+ throw new PathError(`A path must be ${MAX_PATH} characters or fewer.`);
17
+ if (path.includes("\0") || path.includes("\\"))
18
+ throw new PathError(`The path ${JSON.stringify(path)} holds a NUL byte or a backslash.`);
19
+ if (!path.startsWith("/"))
20
+ throw new PathError(`The path ${JSON.stringify(path)} must start with /drop/, /out/ or /work/.`);
21
+ if (path.endsWith("/"))
22
+ throw new PathError(`The path ${JSON.stringify(path)} names a folder, not a file.`);
23
+ const parts = path.split("/").filter((part) => part !== "");
24
+ if (parts.some((part) => part === "." || part === ".."))
25
+ throw new PathError(`The path ${JSON.stringify(path)} holds a . or .. segment.`);
26
+ const folder = `/${parts[0] ?? ""}`;
27
+ if (!DEN_FOLDERS.includes(folder)) {
28
+ throw new PathError(`The path ${JSON.stringify(path)} must start with /drop/, /out/ or /work/.`);
29
+ }
30
+ if (parts.length < 2)
31
+ throw new PathError(`The path ${JSON.stringify(path)} names a folder, not a file.`);
32
+ return `/${parts.join("/")}`;
33
+ }
@@ -0,0 +1,52 @@
1
+ export declare const PROTOCOL_VERSION = 1;
2
+ /** File changes from a run: bytes for a new or changed file, null for a deleted one. */
3
+ export type FileChanges = [path: string, body: Uint8Array | null][];
4
+ export type RunErrorKind = "python" | "timeout" | "crashed" | "storage";
5
+ export interface RunRequest {
6
+ id: number;
7
+ code: string;
8
+ /** Every den file, so the sandbox needs no state of its own. */
9
+ files: [path: string, body: Uint8Array][];
10
+ timeoutMs: number;
11
+ maxOutputBytes: number;
12
+ }
13
+ export interface RunReply {
14
+ id: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ /** `repr()` of the last expression, or null. */
18
+ result: string | null;
19
+ error: {
20
+ kind: RunErrorKind;
21
+ message: string;
22
+ } | null;
23
+ truncated: boolean;
24
+ files: FileChanges;
25
+ durationMs: number;
26
+ }
27
+ /** Sandbox page to host. */
28
+ export type DenMessage = {
29
+ t: "ready";
30
+ v: 1;
31
+ origin: string;
32
+ extensionApi: boolean;
33
+ loadMs: number;
34
+ } | {
35
+ t: "load-error";
36
+ v: 1;
37
+ message: string;
38
+ } | {
39
+ t: "reply";
40
+ v: 1;
41
+ reply: RunReply;
42
+ };
43
+ /** Host to sandbox page, over the MessagePort. */
44
+ export interface HostMessage {
45
+ t: "run";
46
+ v: 1;
47
+ request: RunRequest;
48
+ }
49
+ /** Parse a message from the sandbox page. Returns null for anything unexpected. */
50
+ export declare function parseDenMessage(data: unknown): DenMessage | null;
51
+ /** Parse a message from the host. Returns null for anything unexpected. */
52
+ export declare function parseHostMessage(data: unknown): HostMessage | null;