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.
package/src/archive.ts ADDED
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Archive — the headline API for xit-wasm-ts.
3
+ *
4
+ * - Pure in-memory by default (uses MemoryHost as the wasm fs backend).
5
+ * - Hides every host/file/wasm concept from consumers.
6
+ * - Persists via toBytes() → zip / open(zipBytes) → unzip.
7
+ *
8
+ * The "pleasant surprise" version control methods (commit, log, branch,
9
+ * merge, etc.) sit on the same Archive instance so any consumer who
10
+ * receives an opened archive finds them in their IDE without asking.
11
+ */
12
+
13
+ import { unzipSync, zipSync } from "fflate";
14
+
15
+ import { MemoryHost } from "./host-memory.ts";
16
+ import { hostImports } from "./host.ts";
17
+
18
+ /** Where the in-memory repo lives inside the wasm fs. Keep this stable —
19
+ * changing it would invalidate any existing .甲 archive. */
20
+ const REPO_PATH = "/";
21
+
22
+ /** Hex SHA-1 oid length. */
23
+ const OID_HEX = 40;
24
+
25
+ export interface CommitOptions {
26
+ /** Author/committer string in the conventional `Name <email>` form.
27
+ * Defaults to `"plastron <plastron@local>"` if not given. */
28
+ author?: string;
29
+ }
30
+
31
+ export interface LoadOptions {
32
+ /** Override how the wasm module is sourced. Useful in browsers (pass a
33
+ * URL or already-fetched Uint8Array). Defaults to a relative path
34
+ * resolved against the dev tree — set this in any non-dev environment. */
35
+ wasm?: WasmSource;
36
+ }
37
+
38
+ export type WasmSource = Uint8Array | URL | string | ArrayBuffer;
39
+
40
+ export interface CommitInfo {
41
+ oid: string;
42
+ parents: string[];
43
+ /** Unix timestamp in seconds — taken from the commit metadata. May be 0
44
+ * if the commit was created without an explicit timestamp. */
45
+ timestamp: number;
46
+ author: string;
47
+ message: string;
48
+ }
49
+
50
+ export interface BranchListing {
51
+ branches: string[];
52
+ /** Index into `branches` of the currently checked-out branch, or null if
53
+ * HEAD is detached or not on any of the listed refs. */
54
+ current: number | null;
55
+ }
56
+
57
+ export interface MergeOptions {
58
+ message?: string;
59
+ author?: string;
60
+ }
61
+
62
+ export type MergeResult =
63
+ | { kind: "success"; oid: string }
64
+ | { kind: "fast_forward" }
65
+ | { kind: "nothing" }
66
+ | { kind: "conflict" };
67
+
68
+ export interface SwitchResult {
69
+ kind: "success" | "conflict";
70
+ }
71
+
72
+ interface RawExports {
73
+ memory: WebAssembly.Memory;
74
+ xit_abi_version(): number;
75
+ xit_alloc(size: number): number;
76
+ xit_free(ptr: number, size: number): void;
77
+ xit_repo_init(pathPtr: number, pathLen: number): number;
78
+ xit_repo_open(pathPtr: number, pathLen: number): number;
79
+ xit_repo_close(handle: number): void;
80
+ xit_repo_add(handle: number, pathsPtr: number, pathsLen: number): number;
81
+ xit_repo_remove(handle: number, pathsPtr: number, pathsLen: number): number;
82
+ xit_repo_commit(
83
+ handle: number,
84
+ msgPtr: number,
85
+ msgLen: number,
86
+ authorPtr: number,
87
+ authorLen: number,
88
+ outOidPtr: number,
89
+ outOidLen: number,
90
+ ): number;
91
+ xit_repo_log(handle: number, maxCount: number, outSize: number): number;
92
+ xit_repo_branch_add(handle: number, namePtr: number, nameLen: number): number;
93
+ xit_repo_branch_list(handle: number, outSize: number): number;
94
+ xit_repo_switch(handle: number, targetPtr: number, targetLen: number): number;
95
+ xit_repo_merge(
96
+ handle: number,
97
+ branchPtr: number,
98
+ branchLen: number,
99
+ msgPtr: number,
100
+ msgLen: number,
101
+ authorPtr: number,
102
+ authorLen: number,
103
+ outSize: number,
104
+ ): number;
105
+ }
106
+
107
+ let defaultWasmSource: WasmSource | undefined;
108
+
109
+ /** Configure where the wasm module is loaded from for any subsequent
110
+ * Archive.open() call that doesn't pass `opts.wasm`. Most consumers don't
111
+ * need this — the package ships its `xit.wasm` next to the entry module
112
+ * and the loader auto-resolves it via `import.meta.url`. Useful when:
113
+ *
114
+ * - You want to load from a CDN (`new URL("https://...")`).
115
+ * - You want to use a different wasm artifact (e.g. dev build).
116
+ * - Your bundler doesn't preserve `import.meta.url` and you need to
117
+ * fall back to a hand-supplied URL or pre-fetched bytes. */
118
+ export function setDefaultWasmSource(source: WasmSource): void {
119
+ defaultWasmSource = source;
120
+ }
121
+
122
+ /** Default location of `xit.wasm` shipped with the package. Resolves
123
+ * consistently from src (during dev) and from dist (after build) because
124
+ * the file lives at the package root, one level up from either entry. */
125
+ function bundledWasmUrl(): URL {
126
+ return new URL("../xit.wasm", import.meta.url);
127
+ }
128
+
129
+ async function resolveWasmBytes(source: WasmSource): Promise<Uint8Array> {
130
+ if (source instanceof Uint8Array) return source;
131
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
132
+
133
+ const ref = source instanceof URL ? source.toString() : source;
134
+
135
+ // Prefer fetch for http(s) and blob URLs (browser, Bun, modern Node);
136
+ // file:// and bare paths go through the Node fs path so we don't take a
137
+ // dependency on fetch supporting file URLs (Node only added that recently).
138
+ if (typeof globalThis.fetch === "function" && /^https?:|^blob:/.test(ref)) {
139
+ const r = await fetch(ref);
140
+ return new Uint8Array(await r.arrayBuffer());
141
+ }
142
+
143
+ // Fall back to Node fs only when actually running on Node. Browser
144
+ // bundlers that statically analyze a bare `import("node:fs/promises")`
145
+ // would emit it as a stub and warn even when this branch is
146
+ // unreachable; routing through a runtime-built specifier keeps the
147
+ // browser build clean. The Node-default `bundledWasmUrl()` uses
148
+ // `import.meta.url`, which resolves to an http(s) URL after Vite asset
149
+ // emission — so browsers take the fetch branch above and never reach
150
+ // this fallback.
151
+ const isNode = typeof globalThis.process !== "undefined"
152
+ && globalThis.process.versions != null
153
+ && typeof globalThis.process.versions.node === "string";
154
+ if (!isNode) {
155
+ throw new Error(
156
+ `xit-wasm: cannot resolve wasm source "${ref}" — fetch is unavailable ` +
157
+ `and no Node fs is present. Pass opts.wasm as a Uint8Array, ArrayBuffer, ` +
158
+ `or http(s)/blob URL.`,
159
+ );
160
+ }
161
+ const nodeFsSpecifier = "node:fs/promises";
162
+ const fs = await import(/* @vite-ignore */ nodeFsSpecifier);
163
+ if (ref.startsWith("file:")) {
164
+ return new Uint8Array(await fs.readFile(new URL(ref)));
165
+ }
166
+ return new Uint8Array(await fs.readFile(ref));
167
+ }
168
+
169
+ export class Archive {
170
+ private host: MemoryHost;
171
+ private memory: WebAssembly.Memory;
172
+ private exports: RawExports;
173
+ private repoHandle: number;
174
+ private dirtyAdded = new Set<string>();
175
+ private dirtyRemoved = new Set<string>();
176
+
177
+ /** Files prefixed with `.xit/` are repo internals — Archive content
178
+ * reads/writes never touch them. */
179
+ private static readonly INTERNAL_PREFIX = ".xit/";
180
+
181
+ private constructor(
182
+ host: MemoryHost,
183
+ memory: WebAssembly.Memory,
184
+ exports: RawExports,
185
+ repoHandle: number,
186
+ ) {
187
+ this.host = host;
188
+ this.memory = memory;
189
+ this.exports = exports;
190
+ this.repoHandle = repoHandle;
191
+ }
192
+
193
+ /** Open an archive. With no `bytes`, creates a fresh in-memory repo
194
+ * (master branch, no commits). With `bytes`, unzips into memory and
195
+ * opens the existing repo. */
196
+ static async open(bytes?: Uint8Array, opts: LoadOptions = {}): Promise<Archive> {
197
+ const wasmSource = opts.wasm ?? defaultWasmSource ?? bundledWasmUrl();
198
+ const wasmBytes = await resolveWasmBytes(wasmSource);
199
+ // WebAssembly.compile expects a BufferSource over a non-shared ArrayBuffer.
200
+ // Make a fresh copy with a plain ArrayBuffer to satisfy strict typings.
201
+ const wasmCopy = new Uint8Array(wasmBytes.byteLength);
202
+ wasmCopy.set(wasmBytes);
203
+ const mod = await WebAssembly.compile(wasmCopy);
204
+
205
+ const host = new MemoryHost();
206
+ if (bytes) {
207
+ const tree = unzipSync(bytes);
208
+ const map = new Map<string, Uint8Array>();
209
+ for (const [name, content] of Object.entries(tree)) {
210
+ if (content.length === 0 && name.endsWith("/")) continue; // skip dir markers
211
+ map.set(name, content);
212
+ }
213
+ host.bulkLoad(map);
214
+ }
215
+
216
+ let memory!: WebAssembly.Memory;
217
+ const inst = await WebAssembly.instantiate(mod, hostImports(host, () => memory));
218
+ const exports = inst.exports as unknown as RawExports;
219
+ memory = exports.memory;
220
+
221
+ const enc = new TextEncoder();
222
+ const pathBytes = enc.encode(REPO_PATH);
223
+ const pathPtr = exports.xit_alloc(pathBytes.length);
224
+ if (pathPtr === 0) throw new Error("xit-wasm: alloc failed for repo path");
225
+ new Uint8Array(memory.buffer, pathPtr, pathBytes.length).set(pathBytes);
226
+
227
+ const handle = bytes
228
+ ? exports.xit_repo_open(pathPtr, pathBytes.length)
229
+ : exports.xit_repo_init(pathPtr, pathBytes.length);
230
+
231
+ exports.xit_free(pathPtr, pathBytes.length);
232
+
233
+ if (handle < 0) {
234
+ throw new Error(
235
+ `xit-wasm: ${bytes ? "xit_repo_open" : "xit_repo_init"} failed with code ${handle}`,
236
+ );
237
+ }
238
+
239
+ return new Archive(host, memory, exports, handle);
240
+ }
241
+
242
+ // ---- content ops (no wasm involvement) ----
243
+
244
+ /** Write a file into the archive's working tree. Triggers no commit. */
245
+ async write(path: string, content: Uint8Array): Promise<void> {
246
+ if (path.startsWith(Archive.INTERNAL_PREFIX) || path.startsWith("/" + Archive.INTERNAL_PREFIX)) {
247
+ throw new Error(`Archive.write: path "${path}" is reserved for repo internals`);
248
+ }
249
+ this.host.putFile(path, content);
250
+ this.dirtyAdded.add(stripLeading(path));
251
+ this.dirtyRemoved.delete(stripLeading(path));
252
+ }
253
+
254
+ async read(path: string): Promise<Uint8Array | null> {
255
+ return this.host.getFile(path);
256
+ }
257
+
258
+ /** List all content paths (excluding repo internals). */
259
+ async list(): Promise<string[]> {
260
+ return this.host
261
+ .listFiles()
262
+ .filter((p) => !p.startsWith(Archive.INTERNAL_PREFIX));
263
+ }
264
+
265
+ async remove(path: string): Promise<void> {
266
+ const removed = this.host.removeFile(path);
267
+ if (!removed) return;
268
+ const stripped = stripLeading(path);
269
+ this.dirtyRemoved.add(stripped);
270
+ this.dirtyAdded.delete(stripped);
271
+ }
272
+
273
+ // ---- version control ----
274
+
275
+ /** Stage every dirty path and commit. Returns the new commit OID (hex). */
276
+ async commit(message: string, opts: CommitOptions = {}): Promise<string> {
277
+ const author = opts.author ?? "plastron <plastron@local>";
278
+
279
+ if (this.dirtyAdded.size > 0) {
280
+ this.callPathsBuf("xit_repo_add", Array.from(this.dirtyAdded));
281
+ }
282
+ if (this.dirtyRemoved.size > 0) {
283
+ this.callPathsBuf("xit_repo_remove", Array.from(this.dirtyRemoved));
284
+ }
285
+
286
+ const oid = this.callCommit(message, author);
287
+
288
+ this.dirtyAdded.clear();
289
+ this.dirtyRemoved.clear();
290
+ return oid;
291
+ }
292
+
293
+ /** Walk commit history reachable from HEAD. Newest first. */
294
+ async log(opts: { limit?: number } = {}): Promise<CommitInfo[]> {
295
+ return this.callBufReturn(
296
+ (outSizePtr) => this.exports.xit_repo_log(this.repoHandle, opts.limit ?? 0, outSizePtr),
297
+ (bytes) => decodeLog(bytes),
298
+ );
299
+ }
300
+
301
+ /** Create a new branch off the current HEAD. */
302
+ async branch(name: string): Promise<void> {
303
+ const enc = new TextEncoder();
304
+ const code = this.withBytes(enc.encode(name), (ptr, len) =>
305
+ this.exports.xit_repo_branch_add(this.repoHandle, ptr, len),
306
+ );
307
+ if (code < 0) throw new Error(`xit-wasm: xit_repo_branch_add failed with code ${code}`);
308
+ }
309
+
310
+ /** Returns every branch name plus an index identifying the currently
311
+ * checked-out one (or null when HEAD is detached). */
312
+ async listBranches(): Promise<BranchListing> {
313
+ return this.callBufReturn(
314
+ (outSizePtr) => this.exports.xit_repo_branch_list(this.repoHandle, outSizePtr),
315
+ (bytes) => decodeBranches(bytes),
316
+ );
317
+ }
318
+
319
+ /** Convenience: just the currently checked-out branch name, or null. */
320
+ async currentBranch(): Promise<string | null> {
321
+ const { branches, current } = await this.listBranches();
322
+ return current === null ? null : branches[current] ?? null;
323
+ }
324
+
325
+ /** Switch the working tree to the named branch. */
326
+ async checkout(branch: string): Promise<SwitchResult> {
327
+ const enc = new TextEncoder();
328
+ const code = this.withBytes(enc.encode(branch), (ptr, len) =>
329
+ this.exports.xit_repo_switch(this.repoHandle, ptr, len),
330
+ );
331
+ if (code < 0) throw new Error(`xit-wasm: xit_repo_switch failed with code ${code}`);
332
+ return { kind: code === 0 ? "success" : "conflict" };
333
+ }
334
+
335
+ /** Merge the named branch into the current HEAD. */
336
+ async merge(branch: string, opts: MergeOptions = {}): Promise<MergeResult> {
337
+ const enc = new TextEncoder();
338
+ const message = opts.message ?? `merge ${branch}`;
339
+ const author = opts.author ?? "plastron <plastron@local>";
340
+
341
+ return this.callBufReturn(
342
+ (outSizePtr) =>
343
+ this.withBytes(enc.encode(branch), (bp, bl) =>
344
+ this.withBytes(enc.encode(message), (mp, ml) =>
345
+ this.withBytes(enc.encode(author), (ap, al) =>
346
+ this.exports.xit_repo_merge(
347
+ this.repoHandle,
348
+ bp,
349
+ bl,
350
+ mp,
351
+ ml,
352
+ ap,
353
+ al,
354
+ outSizePtr,
355
+ ),
356
+ ),
357
+ ),
358
+ ),
359
+ (bytes) => decodeMergeResult(bytes),
360
+ );
361
+ }
362
+
363
+ /** Serialize the entire working tree (including repo internals) as a
364
+ * fflate zip. The result IS the .甲 byte stream. */
365
+ async toBytes(): Promise<Uint8Array> {
366
+ const tree = this.host.snapshot();
367
+ const files: Record<string, Uint8Array> = {};
368
+ for (const [path, content] of tree) files[path] = content;
369
+ return zipSync(files);
370
+ }
371
+
372
+ /** Release the wasm-side repo handle. Subsequent calls fail. The host's
373
+ * in-memory tree is dropped. */
374
+ async close(): Promise<void> {
375
+ this.exports.xit_repo_close(this.repoHandle);
376
+ this.repoHandle = -1;
377
+ }
378
+
379
+ // ---- internal: marshalling helpers ----
380
+
381
+ /** Pattern shared by log/branch_list/merge: wasm allocates a result buffer,
382
+ * writes the size to a u32 out-parameter, returns the pointer. We copy
383
+ * the bytes out, free both, and hand them to a decoder. */
384
+ private callBufReturn<T>(
385
+ call: (outSizePtr: number) => number,
386
+ decode: (bytes: Uint8Array) => T,
387
+ ): T {
388
+ const outSizePtr = this.exports.xit_alloc(4);
389
+ if (outSizePtr === 0) throw new Error("xit-wasm: alloc failed for out_size");
390
+ let dataPtr = 0;
391
+ let size = 0;
392
+ try {
393
+ dataPtr = call(outSizePtr);
394
+ if (dataPtr === 0) throw new Error("xit-wasm: returned null buffer");
395
+ size = new DataView(this.memory.buffer, outSizePtr, 4).getUint32(0, true);
396
+ const copy = new Uint8Array(this.memory.buffer, dataPtr, size).slice();
397
+ return decode(copy);
398
+ } finally {
399
+ if (dataPtr !== 0) this.exports.xit_free(dataPtr, size);
400
+ this.exports.xit_free(outSizePtr, 4);
401
+ }
402
+ }
403
+
404
+ private withBytes<T>(bytes: Uint8Array, fn: (ptr: number, len: number) => T): T {
405
+ const ptr = this.exports.xit_alloc(bytes.length);
406
+ if (ptr === 0) throw new Error("xit-wasm: alloc failed");
407
+ new Uint8Array(this.memory.buffer, ptr, bytes.length).set(bytes);
408
+ try {
409
+ return fn(ptr, bytes.length);
410
+ } finally {
411
+ this.exports.xit_free(ptr, bytes.length);
412
+ }
413
+ }
414
+
415
+ private callPathsBuf(
416
+ fn: "xit_repo_add" | "xit_repo_remove",
417
+ paths: string[],
418
+ ): void {
419
+ const enc = new TextEncoder();
420
+ const buf = enc.encode(paths.join("\n"));
421
+ const code = this.withBytes(buf, (ptr, len) =>
422
+ this.exports[fn](this.repoHandle, ptr, len),
423
+ );
424
+ if (code < 0) {
425
+ throw new Error(`xit-wasm: ${fn} failed with code ${code}`);
426
+ }
427
+ }
428
+
429
+ private callCommit(message: string, author: string): string {
430
+ const enc = new TextEncoder();
431
+ const msgBytes = enc.encode(message);
432
+ const authorBytes = enc.encode(author);
433
+ const oidPtr = this.exports.xit_alloc(OID_HEX);
434
+ if (oidPtr === 0) throw new Error("xit-wasm: alloc failed for oid");
435
+ try {
436
+ return this.withBytes(msgBytes, (mp, ml) =>
437
+ this.withBytes(authorBytes, (ap, al) => {
438
+ const code = this.exports.xit_repo_commit(
439
+ this.repoHandle,
440
+ mp,
441
+ ml,
442
+ ap,
443
+ al,
444
+ oidPtr,
445
+ OID_HEX,
446
+ );
447
+ if (code < 0) {
448
+ throw new Error(`xit-wasm: xit_repo_commit failed with code ${code}`);
449
+ }
450
+ const oidBytes = new Uint8Array(this.memory.buffer, oidPtr, OID_HEX);
451
+ return new TextDecoder().decode(oidBytes);
452
+ }),
453
+ );
454
+ } finally {
455
+ this.exports.xit_free(oidPtr, OID_HEX);
456
+ }
457
+ }
458
+ }
459
+
460
+ function stripLeading(p: string): string {
461
+ return p.startsWith("/") ? p.slice(1) : p;
462
+ }
463
+
464
+ // =========================================================================
465
+ // Wire decoders (mirror the formats documented in xit/src/lib.zig).
466
+ // =========================================================================
467
+
468
+ const dec = new TextDecoder();
469
+
470
+ function decodeLog(buf: Uint8Array): CommitInfo[] {
471
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
472
+ let p = 0;
473
+ const count = dv.getUint32(p, true); p += 4;
474
+ const out: CommitInfo[] = [];
475
+ for (let i = 0; i < count; i++) {
476
+ const oid = dec.decode(buf.subarray(p, p + OID_HEX)); p += OID_HEX;
477
+ const parentCount = dv.getUint32(p, true); p += 4;
478
+ const parents: string[] = [];
479
+ for (let j = 0; j < parentCount; j++) {
480
+ parents.push(dec.decode(buf.subarray(p, p + OID_HEX))); p += OID_HEX;
481
+ }
482
+ const timestamp = Number(dv.getBigUint64(p, true)); p += 8;
483
+ const authorLen = dv.getUint32(p, true); p += 4;
484
+ const author = dec.decode(buf.subarray(p, p + authorLen)); p += authorLen;
485
+ const messageLen = dv.getUint32(p, true); p += 4;
486
+ const message = dec.decode(buf.subarray(p, p + messageLen)); p += messageLen;
487
+ out.push({ oid, parents, timestamp, author, message });
488
+ }
489
+ return out;
490
+ }
491
+
492
+ function decodeBranches(buf: Uint8Array): BranchListing {
493
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
494
+ let p = 0;
495
+ const count = dv.getUint32(p, true); p += 4;
496
+ const currentRaw = dv.getInt32(p, true); p += 4;
497
+ const branches: string[] = [];
498
+ for (let i = 0; i < count; i++) {
499
+ const nameLen = dv.getUint32(p, true); p += 4;
500
+ branches.push(dec.decode(buf.subarray(p, p + nameLen))); p += nameLen;
501
+ }
502
+ return { branches, current: currentRaw < 0 ? null : currentRaw };
503
+ }
504
+
505
+ function decodeMergeResult(buf: Uint8Array): MergeResult {
506
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
507
+ const kind = dv.getUint32(0, true);
508
+ switch (kind) {
509
+ case 0: return { kind: "success", oid: dec.decode(buf.subarray(4, 4 + OID_HEX)) };
510
+ case 1: return { kind: "fast_forward" };
511
+ case 2: return { kind: "nothing" };
512
+ case 3: return { kind: "conflict" };
513
+ default: throw new Error(`xit-wasm: unexpected merge result kind ${kind}`);
514
+ }
515
+ }