xit-wasm 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.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Pure in-memory Host implementation. No filesystem, no Node imports —
3
+ * runs identically in Node, Deno, Bun, and the browser. This is the default
4
+ * backend for the public Archive class.
5
+ *
6
+ * Internal layout: a single `Map<string, MemEntry>` keyed by absolute paths
7
+ * with `/` separators. Directories are tracked explicitly (so we can detect
8
+ * "is this an empty dir vs a missing path"). File handles map to a small
9
+ * record with the entry path and a streaming position.
10
+ */
11
+
12
+ import {
13
+ KIND_FILE,
14
+ KIND_DIR,
15
+ KIND_OTHER,
16
+ OK,
17
+ ERR_GENERIC,
18
+ ERR_NOT_FOUND,
19
+ } from "./host.ts";
20
+ import type { Host, StatResult } from "./host.ts";
21
+
22
+ interface FileEntry {
23
+ type: "file";
24
+ content: Uint8Array;
25
+ mtimeNs: bigint;
26
+ }
27
+ interface DirEntry {
28
+ type: "dir";
29
+ mtimeNs: bigint;
30
+ }
31
+ type MemEntry = FileEntry | DirEntry;
32
+
33
+ interface OpenFile {
34
+ path: string;
35
+ position: bigint;
36
+ }
37
+
38
+ const ROOT = "/";
39
+
40
+ function joinPath(base: string, sub: string): string {
41
+ if (sub.startsWith("/")) return normalize(sub);
42
+ if (base === ROOT) return normalize("/" + sub);
43
+ return normalize(base + "/" + sub);
44
+ }
45
+
46
+ /** Normalize a path: collapse runs of `/`, resolve `.` and `..`. Throws on
47
+ * attempts to escape the root via `..`. */
48
+ function normalize(p: string): string {
49
+ const parts = p.split("/").filter((s) => s !== "" && s !== ".");
50
+ const stack: string[] = [];
51
+ for (const part of parts) {
52
+ if (part === "..") {
53
+ if (stack.length === 0) {
54
+ throw new Error(`path ${p} escapes the in-memory root`);
55
+ }
56
+ stack.pop();
57
+ } else {
58
+ stack.push(part);
59
+ }
60
+ }
61
+ return "/" + stack.join("/");
62
+ }
63
+
64
+ function dirname(p: string): string {
65
+ if (p === ROOT) return ROOT;
66
+ const idx = p.lastIndexOf("/");
67
+ if (idx <= 0) return ROOT;
68
+ return p.slice(0, idx);
69
+ }
70
+
71
+ export class MemoryHost implements Host {
72
+ private entries = new Map<string, MemEntry>();
73
+ private dirs = new Map<number, string>();
74
+ private files = new Map<number, OpenFile>();
75
+ private nextHandle = 100;
76
+ trace = false;
77
+
78
+ constructor() {
79
+ this.entries.set(ROOT, { type: "dir", mtimeNs: this.now() });
80
+ this.dirs.set(0, ROOT);
81
+ }
82
+
83
+ private log(...args: unknown[]) {
84
+ if (this.trace) console.error("[mem]", ...args);
85
+ }
86
+
87
+ private now(): bigint {
88
+ return BigInt(Date.now()) * 1_000_000n;
89
+ }
90
+
91
+ private allocHandle(): number {
92
+ return this.nextHandle++;
93
+ }
94
+
95
+ private resolve(dirHandle: number, sub: string): string {
96
+ const base = this.dirs.get(dirHandle);
97
+ if (base === undefined) {
98
+ throw new Error(`unknown dir handle ${dirHandle}`);
99
+ }
100
+ return joinPath(base, sub);
101
+ }
102
+
103
+ private ensureDir(path: string): void {
104
+ if (path === ROOT) return;
105
+ if (!this.entries.has(path)) {
106
+ this.ensureDir(dirname(path));
107
+ this.entries.set(path, { type: "dir", mtimeNs: this.now() });
108
+ }
109
+ }
110
+
111
+ private statToResult(path: string, entry: MemEntry): StatResult {
112
+ return {
113
+ size: entry.type === "file" ? BigInt(entry.content.length) : 0n,
114
+ mtimeNs: entry.mtimeNs,
115
+ atimeNs: entry.mtimeNs,
116
+ ctimeNs: entry.mtimeNs,
117
+ inode: BigInt(this.hashPath(path)),
118
+ kind:
119
+ entry.type === "file" ? KIND_FILE : entry.type === "dir" ? KIND_DIR : KIND_OTHER,
120
+ modeBits: 0o644,
121
+ };
122
+ }
123
+
124
+ /** Stable per-path inode-like number, useful for index entry equality. */
125
+ private hashPath(p: string): number {
126
+ let h = 2166136261;
127
+ for (let i = 0; i < p.length; i++) {
128
+ h ^= p.charCodeAt(i);
129
+ h = Math.imul(h, 16777619);
130
+ }
131
+ return h >>> 0;
132
+ }
133
+
134
+ // ---- public helpers used by Archive (not part of Host) ----
135
+
136
+ /** Bulk-load a tree from a path → bytes map (e.g. unzipped archive). */
137
+ bulkLoad(tree: Map<string, Uint8Array>): void {
138
+ for (const [path, content] of tree) {
139
+ const abs = normalize(path.startsWith("/") ? path : "/" + path);
140
+ this.ensureDir(dirname(abs));
141
+ this.entries.set(abs, { type: "file", content, mtimeNs: this.now() });
142
+ }
143
+ }
144
+
145
+ /** Snapshot every file in the tree (excluding directories) for serialization. */
146
+ snapshot(): Map<string, Uint8Array> {
147
+ const out = new Map<string, Uint8Array>();
148
+ for (const [path, entry] of this.entries) {
149
+ if (entry.type === "file") {
150
+ // Strip the leading slash so consumers can use the keys as zip
151
+ // entry names without producing absolute paths inside the archive.
152
+ out.set(path.slice(1), entry.content);
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /** Direct write bypassing wasm — used by Archive.write. */
159
+ putFile(path: string, content: Uint8Array): void {
160
+ const abs = normalize(path.startsWith("/") ? path : "/" + path);
161
+ this.ensureDir(dirname(abs));
162
+ this.entries.set(abs, { type: "file", content, mtimeNs: this.now() });
163
+ }
164
+
165
+ getFile(path: string): Uint8Array | null {
166
+ const abs = normalize(path.startsWith("/") ? path : "/" + path);
167
+ const entry = this.entries.get(abs);
168
+ return entry?.type === "file" ? entry.content : null;
169
+ }
170
+
171
+ removeFile(path: string): boolean {
172
+ const abs = normalize(path.startsWith("/") ? path : "/" + path);
173
+ const entry = this.entries.get(abs);
174
+ if (entry?.type !== "file") return false;
175
+ this.entries.delete(abs);
176
+ return true;
177
+ }
178
+
179
+ listFiles(prefix?: string): string[] {
180
+ const out: string[] = [];
181
+ for (const [path, entry] of this.entries) {
182
+ if (entry.type !== "file") continue;
183
+ const rel = path.slice(1);
184
+ if (prefix === undefined || rel.startsWith(prefix)) out.push(rel);
185
+ }
186
+ return out.sort();
187
+ }
188
+
189
+ // ---- Host interface ----
190
+
191
+ dirCreateFile(dirHandle: number, sub: string) {
192
+ let abs: string;
193
+ try {
194
+ abs = this.resolve(dirHandle, sub);
195
+ } catch {
196
+ return { code: ERR_GENERIC, handle: -1 };
197
+ }
198
+ this.ensureDir(dirname(abs));
199
+ this.entries.set(abs, { type: "file", content: new Uint8Array(0), mtimeNs: this.now() });
200
+ const handle = this.allocHandle();
201
+ this.files.set(handle, { path: abs, position: 0n });
202
+ this.log("dirCreateFile", abs, "->", handle);
203
+ return { code: OK, handle };
204
+ }
205
+
206
+ dirOpenFile(dirHandle: number, sub: string) {
207
+ let abs: string;
208
+ try {
209
+ abs = this.resolve(dirHandle, sub);
210
+ } catch {
211
+ return { code: ERR_NOT_FOUND, handle: -1 };
212
+ }
213
+ const entry = this.entries.get(abs);
214
+ if (entry?.type !== "file") {
215
+ this.log("dirOpenFile MISSING", abs);
216
+ return { code: ERR_NOT_FOUND, handle: -1 };
217
+ }
218
+ const handle = this.allocHandle();
219
+ this.files.set(handle, { path: abs, position: 0n });
220
+ this.log("dirOpenFile", abs, "->", handle);
221
+ return { code: OK, handle };
222
+ }
223
+
224
+ dirCreateDir(dirHandle: number, sub: string) {
225
+ try {
226
+ const abs = this.resolve(dirHandle, sub);
227
+ const existing = this.entries.get(abs);
228
+ if (existing?.type === "dir") return ERR_GENERIC; // EEXIST equivalent
229
+ this.entries.set(abs, { type: "dir", mtimeNs: this.now() });
230
+ return OK;
231
+ } catch {
232
+ return ERR_GENERIC;
233
+ }
234
+ }
235
+
236
+ dirCreateDirPath(dirHandle: number, sub: string) {
237
+ try {
238
+ const abs = this.resolve(dirHandle, sub);
239
+ const existed = this.entries.get(abs)?.type === "dir";
240
+ this.ensureDir(abs);
241
+ return { code: OK, existed };
242
+ } catch {
243
+ return { code: ERR_GENERIC, existed: false };
244
+ }
245
+ }
246
+
247
+ dirCreateDirPathOpen(dirHandle: number, sub: string) {
248
+ try {
249
+ const abs = this.resolve(dirHandle, sub);
250
+ this.ensureDir(abs);
251
+ const handle = this.allocHandle();
252
+ this.dirs.set(handle, abs);
253
+ return { code: OK, handle };
254
+ } catch {
255
+ return { code: ERR_GENERIC, handle: -1 };
256
+ }
257
+ }
258
+
259
+ dirOpenDir(dirHandle: number, sub: string) {
260
+ try {
261
+ const abs = this.resolve(dirHandle, sub);
262
+ const entry = this.entries.get(abs);
263
+ if (entry?.type !== "dir") return { code: ERR_NOT_FOUND, handle: -1 };
264
+ const handle = this.allocHandle();
265
+ this.dirs.set(handle, abs);
266
+ return { code: OK, handle };
267
+ } catch {
268
+ return { code: ERR_NOT_FOUND, handle: -1 };
269
+ }
270
+ }
271
+
272
+ dirClose(handle: number) {
273
+ if (handle === 0) return;
274
+ this.dirs.delete(handle);
275
+ }
276
+
277
+ dirDeleteFile(dirHandle: number, sub: string) {
278
+ try {
279
+ const abs = this.resolve(dirHandle, sub);
280
+ const entry = this.entries.get(abs);
281
+ if (entry?.type !== "file") return ERR_NOT_FOUND;
282
+ this.entries.delete(abs);
283
+ return OK;
284
+ } catch {
285
+ return ERR_NOT_FOUND;
286
+ }
287
+ }
288
+
289
+ dirRename(oldDir: number, oldSub: string, newDir: number, newSub: string) {
290
+ try {
291
+ const oldAbs = this.resolve(oldDir, oldSub);
292
+ const newAbs = this.resolve(newDir, newSub);
293
+ const entry = this.entries.get(oldAbs);
294
+ if (!entry) return ERR_NOT_FOUND;
295
+ this.entries.delete(oldAbs);
296
+ this.ensureDir(dirname(newAbs));
297
+ this.entries.set(newAbs, entry);
298
+ // Update any file handles pointing at the old path.
299
+ for (const open of this.files.values()) {
300
+ if (open.path === oldAbs) open.path = newAbs;
301
+ }
302
+ return OK;
303
+ } catch {
304
+ return ERR_GENERIC;
305
+ }
306
+ }
307
+
308
+ dirStatFile(dirHandle: number, sub: string) {
309
+ try {
310
+ const abs = this.resolve(dirHandle, sub);
311
+ const entry = this.entries.get(abs);
312
+ if (!entry) return { code: ERR_NOT_FOUND };
313
+ return { code: OK, stat: this.statToResult(abs, entry) };
314
+ } catch {
315
+ return { code: ERR_NOT_FOUND };
316
+ }
317
+ }
318
+
319
+ dirAccess(dirHandle: number, sub: string) {
320
+ try {
321
+ const abs = this.resolve(dirHandle, sub);
322
+ return this.entries.has(abs) ? OK : ERR_NOT_FOUND;
323
+ } catch {
324
+ return ERR_NOT_FOUND;
325
+ }
326
+ }
327
+
328
+ dirReadLink(_dirHandle: number, _sub: string) {
329
+ // No symlinks in the in-memory tree. Return "exists but not a symlink"
330
+ // when the path points at a real entry, ENOENT otherwise — xit's
331
+ // fs.Metadata.init switches on error.NotLink (code 0) to fall through.
332
+ try {
333
+ const abs = this.resolve(_dirHandle, _sub);
334
+ return this.entries.has(abs) ? { code: 0 } : { code: -1 };
335
+ } catch {
336
+ return { code: -1 };
337
+ }
338
+ }
339
+
340
+ fileClose(handle: number) {
341
+ this.files.delete(handle);
342
+ }
343
+
344
+ fileRead(handle: number, offset: bigint, len: number) {
345
+ const open = this.files.get(handle);
346
+ if (!open) return { code: ERR_GENERIC, data: new Uint8Array(0) };
347
+ const entry = this.entries.get(open.path);
348
+ if (entry?.type !== "file") return { code: ERR_GENERIC, data: new Uint8Array(0) };
349
+ const start = Number(offset);
350
+ const end = Math.min(entry.content.length, start + len);
351
+ const data = entry.content.subarray(start, end);
352
+ return { code: OK, data };
353
+ }
354
+
355
+ fileWrite(handle: number, offset: bigint, data: Uint8Array) {
356
+ const open = this.files.get(handle);
357
+ if (!open) return { code: ERR_GENERIC, written: 0 };
358
+ const entry = this.entries.get(open.path);
359
+ if (entry?.type !== "file") return { code: ERR_GENERIC, written: 0 };
360
+ const start = Number(offset);
361
+ const end = start + data.length;
362
+ if (end > entry.content.length) {
363
+ const grown = new Uint8Array(end);
364
+ grown.set(entry.content);
365
+ entry.content = grown;
366
+ }
367
+ entry.content.set(data, start);
368
+ entry.mtimeNs = this.now();
369
+ return { code: OK, written: data.length };
370
+ }
371
+
372
+ fileWriteStream(handle: number, data: Uint8Array) {
373
+ const open = this.files.get(handle);
374
+ if (!open) return { code: ERR_GENERIC, written: 0 };
375
+ const r = this.fileWrite(handle, open.position, data);
376
+ if (r.code === OK) open.position += BigInt(r.written);
377
+ return r;
378
+ }
379
+
380
+ fileReadStream(handle: number, len: number) {
381
+ const open = this.files.get(handle);
382
+ if (!open) return { code: ERR_GENERIC, data: new Uint8Array(0) };
383
+ const r = this.fileRead(handle, open.position, len);
384
+ if (r.code === OK) open.position += BigInt(r.data.length);
385
+ return r;
386
+ }
387
+
388
+ fileStat(handle: number) {
389
+ const open = this.files.get(handle);
390
+ if (!open) return { code: ERR_GENERIC };
391
+ const entry = this.entries.get(open.path);
392
+ if (!entry) return { code: ERR_GENERIC };
393
+ return { code: OK, stat: this.statToResult(open.path, entry) };
394
+ }
395
+
396
+ fileLength(handle: number) {
397
+ const open = this.files.get(handle);
398
+ if (!open) return { code: ERR_GENERIC, length: 0n };
399
+ const entry = this.entries.get(open.path);
400
+ if (entry?.type !== "file") return { code: ERR_GENERIC, length: 0n };
401
+ return { code: OK, length: BigInt(entry.content.length) };
402
+ }
403
+
404
+ fileSeekTo(handle: number, offset: bigint) {
405
+ const open = this.files.get(handle);
406
+ if (!open) return ERR_GENERIC;
407
+ open.position = offset;
408
+ return OK;
409
+ }
410
+
411
+ fileSeekBy(handle: number, relative: bigint) {
412
+ const open = this.files.get(handle);
413
+ if (!open) return ERR_GENERIC;
414
+ open.position += relative;
415
+ return OK;
416
+ }
417
+
418
+ fileLock(_handle: number, _kind: number) {
419
+ return OK;
420
+ }
421
+ fileTryLock(_handle: number, _kind: number) {
422
+ return 1;
423
+ }
424
+ fileUnlock(_handle: number) {}
425
+ fileSync(_handle: number) {
426
+ return OK;
427
+ }
428
+
429
+ fileSetLength(handle: number, length: bigint) {
430
+ const open = this.files.get(handle);
431
+ if (!open) return ERR_GENERIC;
432
+ const entry = this.entries.get(open.path);
433
+ if (entry?.type !== "file") return ERR_GENERIC;
434
+ const n = Number(length);
435
+ if (n === entry.content.length) return OK;
436
+ const grown = new Uint8Array(n);
437
+ grown.set(entry.content.subarray(0, Math.min(n, entry.content.length)));
438
+ entry.content = grown;
439
+ entry.mtimeNs = this.now();
440
+ return OK;
441
+ }
442
+
443
+ nowNanos() {
444
+ return this.now();
445
+ }
446
+
447
+ random(buf: Uint8Array) {
448
+ // Use crypto.getRandomValues if available (browser, modern Node, Deno);
449
+ // fall back to Math.random for the few environments missing it.
450
+ const c: { getRandomValues?: (b: Uint8Array) => void } | undefined =
451
+ (globalThis as any).crypto;
452
+ if (c?.getRandomValues) {
453
+ c.getRandomValues(buf);
454
+ return;
455
+ }
456
+ for (let i = 0; i < buf.length; i++) buf[i] = (Math.random() * 256) | 0;
457
+ }
458
+ }