@polyengine/wasi 0.1.0-pre.g633468a

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,315 @@
1
+ // `@polyengine/wasi/filesystem-web` — `wasi:filesystem@0.2` + `@0.3` over the
2
+ // Origin Private File System (the browser's `navigator.storage` file API;
3
+ // any object implementing the structural handle interfaces below works,
4
+ // including in-memory fakes for tests). Preopens are explicit handle
5
+ // grants:
6
+ //
7
+ // const root = await navigator.storage.getDirectory();
8
+ // instantiate(a, { ...wasi(), ...filesystemWeb({ preopens: { "/": root } }).imports })
9
+ //
10
+ // READ-ONLY BY DEFAULT: the grant above is read-only. Writes need the
11
+ // package-level `writable: true` — one flag for the whole
12
+ // implementation, never per-preopen (fs_provider.ts header for why, and
13
+ // for the enforcement site: it is the provider, not this backend).
14
+ //
15
+ // ASYNC BY CONSTRUCTION: every OPFS op returns a promise, so the 0.2
16
+ // track's sync WIT descriptor methods are marked park-capable (A14, on
17
+ // the per-call class prototypes — fs_provider.ts): a p2 guest that
18
+ // touches the filesystem parks through the suspending kernel and needs
19
+ // JSPI; on engines without it a genuine wait raises `NeedsJspi` at the
20
+ // park site. The 0.3 track is async in WIT and needs no parking. (The
21
+ // synchronous OPFS access handles — `createSyncAccessHandle` — exist
22
+ // only in dedicated workers; this impl deliberately targets the portable
23
+ // async API.)
24
+ //
25
+ // Fidelity notes, honest and deliberate:
26
+ // * every positional write is open-writable → write → close (OPFS
27
+ // writables COMMIT on close; keeping one open would make reads stale
28
+ // and crashes lossy). Durable and simple, not fast.
29
+ // * OPFS has no symlinks, hard links, or settable timestamps:
30
+ // `link-at`/`symlink-at`/`readlink-at`/`set-times*` fail
31
+ // `unsupported`. Stat reports mtime only (files; from
32
+ // `File.lastModified`, ms resolution), link-count 1.
33
+ // * `exclusive` create and `rename-at` are emulated (probe-then-act /
34
+ // `move()` where the engine ships it, copy+delete for files
35
+ // otherwise; directory renames without `move()` are `unsupported`).
36
+ // * object identity (metadata-hash, is-same-object) derives from the
37
+ // guest path (FNV via the provider) and `isSameEntry`.
38
+ import { makeFilesystem, } from "./internal/fs_provider.js";
39
+ // --- error mapping -----------------------------------------------------------------
40
+ const DOM_ERROR_MAP = {
41
+ NotFoundError: "no-entry",
42
+ TypeMismatchError: "not-directory",
43
+ NotAllowedError: "access",
44
+ SecurityError: "access",
45
+ QuotaExceededError: "quota",
46
+ InvalidModificationError: "not-empty",
47
+ AbortError: "interrupted",
48
+ NoModificationAllowedError: "busy",
49
+ };
50
+ function domError(code, message) {
51
+ return Object.assign(new Error(message), { fsCode: code });
52
+ }
53
+ const NS_PER_MS = 1000000n;
54
+ function makeWebBackend() {
55
+ const childPath = (base, segments) => segments.length === 0 ? base.path : `${base.path}/${segments.join("/")}`;
56
+ const requireDir = (h) => {
57
+ if (h.handle.kind !== "directory") {
58
+ throw domError("not-directory", `${h.path}: not a directory`);
59
+ }
60
+ return h.handle;
61
+ };
62
+ const requireFile = (h) => {
63
+ if (h.handle.kind !== "file") {
64
+ throw domError("is-directory", `${h.path}: is a directory`);
65
+ }
66
+ return h.handle;
67
+ };
68
+ /** Walk intermediate segments (never creating); returns the parent dir
69
+ * for the final component, or the base itself for []. */
70
+ const walk = async (base, segments) => {
71
+ let dir = requireDir(base);
72
+ for (const seg of segments)
73
+ dir = await dir.getDirectoryHandle(seg);
74
+ return dir;
75
+ };
76
+ const parentOf = (base, segments) => walk(base, segments.slice(0, -1));
77
+ /** Resolve segments to a handle (file or directory), never creating. */
78
+ const resolve = async (base, segments) => {
79
+ if (segments.length === 0)
80
+ return base.handle;
81
+ const parent = await parentOf(base, segments);
82
+ const name = segments[segments.length - 1];
83
+ try {
84
+ return await parent.getFileHandle(name);
85
+ }
86
+ catch (e) {
87
+ if (e?.name === "TypeMismatchError") {
88
+ return await parent.getDirectoryHandle(name);
89
+ }
90
+ throw e;
91
+ }
92
+ };
93
+ const statOfFile = async (file) => {
94
+ const f = await file.getFile();
95
+ return {
96
+ type: "regular-file",
97
+ linkCount: 1n,
98
+ size: BigInt(f.size),
99
+ mtimeNs: BigInt(f.lastModified) * NS_PER_MS,
100
+ };
101
+ };
102
+ const statOfHandle = (h) => h.kind === "file" ? statOfFile(h) : Promise.resolve({
103
+ type: "directory",
104
+ linkCount: 1n,
105
+ size: 0n,
106
+ });
107
+ /** FNV-1a over the guest path: OPFS has no dev/ino (module header). */
108
+ const pathIdentity = (path) => {
109
+ let h = 0xcbf29ce484222325n;
110
+ for (const byte of new TextEncoder().encode(path)) {
111
+ h = ((h ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn;
112
+ }
113
+ return { a: h, b: BigInt(path.length) };
114
+ };
115
+ const writeAt = async (file, data, position) => {
116
+ const w = await file.createWritable({ keepExistingData: true });
117
+ try {
118
+ await w.write({ type: "write", position, data });
119
+ }
120
+ finally {
121
+ await w.close(); // commit (or persist what was written before a failure)
122
+ }
123
+ };
124
+ return {
125
+ isSync: false,
126
+ mapError(e) {
127
+ const tagged = e?.fsCode;
128
+ if (tagged !== undefined)
129
+ return tagged;
130
+ const name = e?.name;
131
+ return (typeof name === "string" ? DOM_ERROR_MAP[name] : undefined) ?? "io";
132
+ },
133
+ async openAt(base, segments, opts) {
134
+ const path = childPath(base, segments);
135
+ if (segments.length === 0) {
136
+ // Opening "." — the base itself.
137
+ if (opts.exclusive && opts.create)
138
+ throw domError("exist", `${path}: exists`);
139
+ return { handle: { handle: base.handle, path }, type: base.handle.kind === "file" ? "regular-file" : "directory" };
140
+ }
141
+ const parent = await parentOf(base, segments);
142
+ const name = segments[segments.length - 1];
143
+ if (opts.directory) {
144
+ const dir = await parent.getDirectoryHandle(name, { create: opts.create });
145
+ return { handle: { handle: dir, path }, type: "directory" };
146
+ }
147
+ // Does it already exist, and as what?
148
+ let existing;
149
+ try {
150
+ existing = await parent.getFileHandle(name);
151
+ }
152
+ catch (e) {
153
+ const en = e?.name;
154
+ if (en === "TypeMismatchError")
155
+ existing = await parent.getDirectoryHandle(name);
156
+ else if (en !== "NotFoundError")
157
+ throw e;
158
+ }
159
+ if (existing?.kind === "directory") {
160
+ if (opts.create && opts.exclusive)
161
+ throw domError("exist", `${path}: exists`);
162
+ return { handle: { handle: existing, path }, type: "directory" };
163
+ }
164
+ if (existing !== undefined && opts.create && opts.exclusive) {
165
+ throw domError("exist", `${path}: exists`);
166
+ }
167
+ const file = existing ??
168
+ await (opts.create
169
+ ? parent.getFileHandle(name, { create: true })
170
+ : parent.getFileHandle(name)); // throws NotFoundError
171
+ if (opts.truncate) {
172
+ const w = await file.createWritable({ keepExistingData: false });
173
+ await w.close();
174
+ }
175
+ return { handle: { handle: file, path }, type: "regular-file" };
176
+ },
177
+ close(_h) {
178
+ // OPFS handles hold no OS resources between operations.
179
+ },
180
+ stat: (h) => statOfHandle(h.handle),
181
+ async statAt(base, segments, _follow) {
182
+ return await statOfHandle(await resolve(base, segments));
183
+ },
184
+ async read(h, length, offset) {
185
+ const f = await requireFile(h).getFile();
186
+ if (offset >= f.size || length === 0)
187
+ return new Uint8Array(0);
188
+ const end = Math.min(offset + length, f.size);
189
+ return new Uint8Array(await f.slice(offset, end).arrayBuffer());
190
+ },
191
+ async write(h, buffer, offset) {
192
+ await writeAt(requireFile(h), buffer, offset);
193
+ return buffer.length;
194
+ },
195
+ async append(h, buffer) {
196
+ const file = requireFile(h);
197
+ const size = (await file.getFile()).size;
198
+ await writeAt(file, buffer, size);
199
+ return buffer.length;
200
+ },
201
+ async setSize(h, size) {
202
+ const w = await requireFile(h).createWritable({ keepExistingData: true });
203
+ try {
204
+ await w.truncate(size);
205
+ }
206
+ finally {
207
+ await w.close();
208
+ }
209
+ },
210
+ setTimes() {
211
+ throw domError("unsupported", "OPFS: timestamps are not settable");
212
+ },
213
+ setTimesAt() {
214
+ throw domError("unsupported", "OPFS: timestamps are not settable");
215
+ },
216
+ syncAll() {
217
+ // Per-operation commits (module header): nothing buffered to sync.
218
+ },
219
+ syncData() { },
220
+ async readDirectory(h) {
221
+ const out = [];
222
+ for await (const [name, handle] of requireDir(h).entries()) {
223
+ out.push({ name, type: handle.kind === "directory" ? "directory" : "regular-file" });
224
+ }
225
+ return out;
226
+ },
227
+ async createDirectoryAt(base, segments) {
228
+ const parent = await parentOf(base, segments);
229
+ const name = segments[segments.length - 1];
230
+ // POSIX mkdir fails on ANY existing entry; getDirectoryHandle
231
+ // ({create}) would silently accept an existing directory.
232
+ let exists = true;
233
+ try {
234
+ await resolve({ handle: parent, path: "" }, [name]);
235
+ }
236
+ catch (e) {
237
+ if (e?.name !== "NotFoundError")
238
+ throw e;
239
+ exists = false;
240
+ }
241
+ if (exists)
242
+ throw domError("exist", `${childPath(base, segments)}: exists`);
243
+ await parent.getDirectoryHandle(name, { create: true });
244
+ },
245
+ async removeDirectoryAt(base, segments) {
246
+ const parent = await parentOf(base, segments);
247
+ const name = segments[segments.length - 1];
248
+ await parent.getDirectoryHandle(name); // TypeMismatchError → not-directory
249
+ await parent.removeEntry(name); // InvalidModificationError → not-empty
250
+ },
251
+ async unlinkFileAt(base, segments) {
252
+ const parent = await parentOf(base, segments);
253
+ const name = segments[segments.length - 1];
254
+ try {
255
+ await parent.getFileHandle(name);
256
+ }
257
+ catch (e) {
258
+ if (e?.name === "TypeMismatchError") {
259
+ throw domError("is-directory", `${childPath(base, segments)}: is a directory`);
260
+ }
261
+ throw e;
262
+ }
263
+ await parent.removeEntry(name);
264
+ },
265
+ async renameAt(oldBase, oldSegments, newBase, newSegments) {
266
+ const target = await resolve(oldBase, oldSegments);
267
+ const newParent = await parentOf(newBase, newSegments);
268
+ const newName = newSegments[newSegments.length - 1];
269
+ if (target.move !== undefined) {
270
+ await target.move(newParent, newName);
271
+ return;
272
+ }
273
+ if (target.kind === "directory") {
274
+ throw domError("unsupported", "OPFS: directory rename requires FileSystemHandle.move");
275
+ }
276
+ // Fallback: copy + delete (non-atomic, module header).
277
+ const bytes = new Uint8Array(await (await target.getFile()).arrayBuffer());
278
+ const dest = await newParent.getFileHandle(newName, { create: true });
279
+ const w = await dest.createWritable({ keepExistingData: false });
280
+ try {
281
+ await w.write({ type: "write", position: 0, data: bytes });
282
+ }
283
+ finally {
284
+ await w.close();
285
+ }
286
+ const oldParent = await parentOf(oldBase, oldSegments);
287
+ await oldParent.removeEntry(oldSegments[oldSegments.length - 1]);
288
+ },
289
+ // linkAt / symlinkAt / readlinkAt: absent → the provider answers
290
+ // `unsupported` (OPFS has no links).
291
+ identity: (h) => pathIdentity(h.path),
292
+ async identityAt(base, segments, _follow) {
293
+ await resolve(base, segments); // existence check (NotFound → no-entry)
294
+ return pathIdentity(childPath(base, segments));
295
+ },
296
+ isSame(a, b) {
297
+ return a.handle.isSameEntry(b.handle);
298
+ },
299
+ };
300
+ }
301
+ /**
302
+ * `wasi:filesystem` over the Origin Private File System (module header).
303
+ * Serves both the `@0.2` (parking, JSPI) and `@0.3` tracks.
304
+ */
305
+ export function filesystemWeb(options) {
306
+ const preopens = Object.entries(options.preopens).map(([guestName, handle]) => {
307
+ if (handle.kind !== "directory") {
308
+ throw new TypeError(`filesystemWeb: preopen ${guestName} is not a directory handle`);
309
+ }
310
+ return [{ handle, path: guestName }, guestName];
311
+ });
312
+ return makeFilesystem(makeWebBackend(), preopens, {
313
+ writable: options.writable === true,
314
+ });
315
+ }