@threadedstack/wmd-web 0.0.0-stage → 0.1.12

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.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
@@ -0,0 +1,395 @@
1
+ // P-B9: the checkpoint file (browser form of research/10 appendix B's WMDCKPT, which the desktop
2
+ // writes; the two are not interchangeable: the desktop's carries hash vectors and artifact keys of
3
+ // its JIT cache, this one carries what the browser host needs, so it has its own magic).
4
+ //
5
+ // [0, 64 KiB) header block: magic "WMDWCKP1" (written LAST: a file that never got that far
6
+ // reads as "incomplete"), u32 version, u32 json length, the header JSON, then the SHA-256 of that
7
+ // JSON (a flipped byte in the section table or in a count of the header is refused as corrupt)
8
+ // then, in the order of header.sections, each section's chunks back to back, 8-byte aligned
9
+ //
10
+ // header JSON: {format, createdAt, label, vmKey, bootKey, memPages, pageSize, guestHdr (base64),
11
+ // ports, sections: [{name, off, chunks: [{len, sha256}]}], fileLen, ...whatever the caller adds}
12
+ //
13
+ // Every chunk has its SHA-256 in the header; a reader that verifies compares all of them, so a
14
+ // flipped byte anywhere is refused and the message names the section and the chunk.
15
+ //
16
+ // "handle" is a FileSystemSyncAccessHandle or anything with read(buf,{at}), write(buf,{at}),
17
+ // getSize(), truncate(n), flush(), close() (the unit tests use a memory file).
18
+
19
+ export const MAGIC = 'WMDWCKP1'
20
+ export const FORMAT_VERSION = 2
21
+ export const HEADER_SPACE = 64 * 1024
22
+ const ALIGN = 8
23
+
24
+ export class CheckpointError extends Error {
25
+ constructor(code, message) {
26
+ super(message)
27
+ this.code = code // 'incomplete' | 'foreign' | 'version' | 'truncated' | 'corrupt' | 'malformed' | 'quota' | ...
28
+ // W24: a storage failure is told by its name, as the browser's own (DOMException QuotaExceededError)
29
+ if (code === 'quota') this.name = 'QuotaExceededError'
30
+ }
31
+ }
32
+
33
+ export async function sha256Hex(u8) {
34
+ const d = new Uint8Array(await crypto.subtle.digest('SHA-256', u8))
35
+ let s = ''
36
+ for (const b of d) s += b.toString(16).padStart(2, '0')
37
+ return s
38
+ }
39
+
40
+ const enc = new TextEncoder()
41
+ const dec = new TextDecoder()
42
+ const pad = n => (ALIGN - (n % ALIGN)) % ALIGN
43
+
44
+ function writeAll(h, u8, at) {
45
+ let done = 0
46
+ while (done < u8.length) {
47
+ let n
48
+ try {
49
+ n = h.write(done ? u8.subarray(done) : u8, { at: at + done })
50
+ } catch (e) {
51
+ if (e && e.name === 'QuotaExceededError') throw e
52
+ // say how far it got: a browser's limit on one file or on its storage shows in the offset
53
+ throw new CheckpointError('io', `writing the checkpoint failed at byte ${at + done} (${e && e.name}: ${e && e.message})`)
54
+ }
55
+ if (!(n > 0)) throw new CheckpointError('truncated', `checkpoint write stopped at ${at + done} (storage full?)`)
56
+ done += n
57
+ }
58
+ }
59
+
60
+ function readExact(h, off, len) {
61
+ const out = new Uint8Array(len)
62
+ let got = 0
63
+ while (got < len) {
64
+ const n = h.read(got ? out.subarray(got) : out, { at: off + got })
65
+ if (!(n > 0)) break
66
+ got += n
67
+ }
68
+ if (got !== len) throw new CheckpointError('truncated', `checkpoint is cut short: wanted ${len} bytes at ${off}, the file has ${got}`)
69
+ return out
70
+ }
71
+
72
+ /**
73
+ * Writes sections ({name, chunks: Uint8Array[]}) and the header. Returns {bytes, headerJson, ms, sha}.
74
+ * `header` is the caller's fields; format, sections and fileLen are added here.
75
+ */
76
+ export async function writeCheckpoint(h, header, sections, { now = () => performance.now() } = {}) {
77
+ const t0 = now()
78
+ h.truncate(0)
79
+ writeAll(h, new Uint8Array(HEADER_SPACE), 0) // zeros: no magic yet
80
+ let off = HEADER_SPACE
81
+ const table = []
82
+ let shaMs = 0
83
+ for (const s of sections) {
84
+ const entry = { name: s.name, off, chunks: [] }
85
+ for (const chunk of s.chunks) {
86
+ // a chunk is bytes, or a function (with .length set to its size) that returns them when asked: a save streams
87
+ // 32 MiB at a time instead of holding a copy of the whole checkpoint
88
+ const c = typeof chunk === 'function' ? await chunk() : chunk
89
+ const t1 = now()
90
+ const sha = await sha256Hex(c)
91
+ shaMs += now() - t1
92
+ writeAll(h, c, off)
93
+ entry.chunks.push({ len: c.length, sha256: sha })
94
+ off += c.length
95
+ const p = pad(off)
96
+ if (p) {
97
+ writeAll(h, new Uint8Array(p), off)
98
+ off += p
99
+ }
100
+ }
101
+ table.push(entry)
102
+ }
103
+ const full = { ...header, format: FORMAT_VERSION, sections: table, fileLen: off }
104
+ const hb = headerBlock(full)
105
+ writeAll(h, hb.subarray(8), 8) // version, length, json, its sha-256
106
+ h.flush()
107
+ writeAll(h, enc.encode(MAGIC), 0) // the commit
108
+ h.flush()
109
+ return { bytes: off, header: full, ms: now() - t0, shaMs }
110
+ }
111
+
112
+ const K256 = Uint32Array.from(
113
+ '428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2'
114
+ .split(' ')
115
+ .map(x => parseInt(x, 16)),
116
+ )
117
+
118
+ /** SHA-256 of a small buffer, synchronously (the header is under 64 KiB; the chunks use SubtleCrypto). */
119
+ export function sha256Sync(data) {
120
+ const H = Uint32Array.of(0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19)
121
+ const bits = data.length * 8
122
+ const padded = new Uint8Array(((data.length + 9 + 63) >> 6) << 6)
123
+ padded.set(data)
124
+ padded[data.length] = 0x80
125
+ const dv = new DataView(padded.buffer)
126
+ dv.setUint32(padded.length - 8, Math.floor(bits / 2 ** 32))
127
+ dv.setUint32(padded.length - 4, bits >>> 0)
128
+ const w = new Uint32Array(64)
129
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n))
130
+ for (let o = 0; o < padded.length; o += 64) {
131
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + i * 4)
132
+ for (let i = 16; i < 64; i++) {
133
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
134
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
135
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0
136
+ }
137
+ let [a, b, c, d, e, f, g, h] = H
138
+ for (let i = 0; i < 64; i++) {
139
+ const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K256[i] + w[i]) | 0
140
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
141
+ h = g
142
+ g = f
143
+ f = e
144
+ e = (d + t1) | 0
145
+ d = c
146
+ c = b
147
+ b = a
148
+ a = (t1 + t2) | 0
149
+ }
150
+ H[0] += a
151
+ H[1] += b
152
+ H[2] += c
153
+ H[3] += d
154
+ H[4] += e
155
+ H[5] += f
156
+ H[6] += g
157
+ H[7] += h
158
+ }
159
+ const out = new Uint8Array(32)
160
+ const odv = new DataView(out.buffer)
161
+ H.forEach((x, i) => odv.setUint32(i * 4, x))
162
+ return out
163
+ }
164
+
165
+ /** The header block's bytes after the magic's place: [8) version, [12) json length, [16) json, then its SHA-256 (32 bytes). */
166
+ export function headerBlock(full) {
167
+ const json = enc.encode(JSON.stringify(full))
168
+ if (json.length + 32 > HEADER_SPACE - 16) throw new CheckpointError('malformed', `checkpoint header is ${json.length} bytes; the limit is ${HEADER_SPACE - 48}`)
169
+ const sha = sha256Sync(json)
170
+ const hb = new Uint8Array(16 + json.length + 32)
171
+ new DataView(hb.buffer).setUint32(8, FORMAT_VERSION, true)
172
+ new DataView(hb.buffer).setUint32(12, json.length, true)
173
+ hb.set(json, 16)
174
+ hb.set(sha, 16 + json.length)
175
+ return hb
176
+ }
177
+
178
+ /** The validated header of a checkpoint file. */
179
+ export function readHeader(h) {
180
+ const size = h.getSize()
181
+ if (size < 16) throw new CheckpointError('incomplete', `not a checkpoint: the file is ${size} bytes`)
182
+ const top = readExact(h, 0, 16)
183
+ const magic = dec.decode(top.subarray(0, 8))
184
+ if (magic !== MAGIC) {
185
+ if (top.subarray(0, 8).every(b => b === 0)) throw new CheckpointError('incomplete', 'the checkpoint was never finished (its write did not complete: the page closed or the storage filled while it was written)')
186
+ throw new CheckpointError('foreign', `not a wmd-web checkpoint (magic ${JSON.stringify(magic.replace(/[^\x20-\x7e]/g, '?'))})`)
187
+ }
188
+ const dv = new DataView(top.buffer)
189
+ const version = dv.getUint32(8, true)
190
+ if (version !== FORMAT_VERSION) throw new CheckpointError('version', `checkpoint format ${version}; this build reads format ${FORMAT_VERSION}`)
191
+ const n = dv.getUint32(12, true)
192
+ if (n > HEADER_SPACE - 16) throw new CheckpointError('malformed', `checkpoint header length ${n} is not possible`)
193
+ let header
194
+ try {
195
+ const jsonBytes = readExact(h, 16, n)
196
+ const want = readExact(h, 16 + n, 32)
197
+ const got = sha256Sync(jsonBytes)
198
+ if (want.some((b, i) => b !== got[i])) throw new CheckpointError('corrupt', 'the checkpoint header does not match its SHA-256 (the section table or a count in it is damaged)')
199
+ header = JSON.parse(dec.decode(jsonBytes))
200
+ } catch (e) {
201
+ if (e instanceof CheckpointError) throw e
202
+ throw new CheckpointError('malformed', `checkpoint header is not JSON: ${e.message}`)
203
+ }
204
+ if (header.format !== FORMAT_VERSION || !Array.isArray(header.sections) || typeof header.fileLen !== 'number') {
205
+ throw new CheckpointError('malformed', 'checkpoint header is missing its section table')
206
+ }
207
+ if (size < header.fileLen) throw new CheckpointError('truncated', `checkpoint is cut short: ${size} bytes of ${header.fileLen}`)
208
+ for (const s of header.sections) {
209
+ let end = s.off
210
+ for (const c of s.chunks) end += c.len
211
+ if (s.off < HEADER_SPACE || end > header.fileLen) throw new CheckpointError('malformed', `section ${s.name} lies outside the file`)
212
+ }
213
+ return header
214
+ }
215
+
216
+ /** One section's chunks as Uint8Arrays (each its own buffer), verified against their digests unless verify is false. */
217
+ export async function readSection(h, header, name, { verify = true } = {}) {
218
+ const s = header.sections.find(x => x.name === name)
219
+ if (!s) throw new CheckpointError('malformed', `checkpoint has no section ${name}`)
220
+ const out = []
221
+ let off = s.off
222
+ for (let i = 0; i < s.chunks.length; i++) {
223
+ const c = s.chunks[i]
224
+ const b = readExact(h, off, c.len)
225
+ if (verify) {
226
+ const got = await sha256Hex(b)
227
+ if (got !== c.sha256) throw new CheckpointError('corrupt', `checkpoint section ${name}, chunk ${i} (bytes ${off}..${off + c.len}) does not match its SHA-256: the file is damaged`)
228
+ }
229
+ out.push(b)
230
+ off += c.len + pad(off + c.len)
231
+ }
232
+ return out
233
+ }
234
+
235
+ export const sectionBytes = (header, name) => header.sections.find(s => s.name === name)?.chunks.reduce((n, c) => n + c.len, 0) ?? 0
236
+
237
+ /** Concatenates chunks (for small sections). */
238
+ export function concat(chunks) {
239
+ if (chunks.length === 1) return chunks[0]
240
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
241
+ let p = 0
242
+ for (const c of chunks) {
243
+ out.set(c, p)
244
+ p += c.length
245
+ }
246
+ return out
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Versioned files: a save never touches the checkpoint it replaces
251
+ // ---------------------------------------------------------------------------
252
+ //
253
+ // <name>~<n>.ckpt, n counting up (a file named <name>.ckpt, from an earlier build, is version 0). A save writes version
254
+ // max+1 and removes the older ones only once the new file is complete; if the write fails (storage quota, a closed page
255
+ // leaves a file with no magic) the partial file is removed and the previous version is still the one a load finds. This
256
+ // needs no rename, which the browsers do not all offer for OPFS files in a Worker.
257
+ // "dir" is {names(): Promise<string[]>, open(file, create): Promise<handle>, remove(file): Promise, free?(): Promise<number|null>}.
258
+
259
+ const FILE_RE = /^(.+?)(?:~(\d+))?\.ckpt$/
260
+ export const versionsOf = (names, name) =>
261
+ names
262
+ .map(n => FILE_RE.exec(n))
263
+ .filter(m => m && m[1] === name)
264
+ .map(m => Number(m[2] ?? 0))
265
+ .sort((a, b) => a - b)
266
+ export const fileOf = (name, v) => (v ? `${name}~${v}.ckpt` : `${name}.ckpt`)
267
+ export const baseNames = names => [...new Set(names.map(n => FILE_RE.exec(n)).filter(Boolean).map(m => m[1]))]
268
+
269
+ const isQuota = e => e && (e.name === 'QuotaExceededError' || e.code === 22 || /quota/i.test(String(e.message)))
270
+
271
+ const saveChains = new Map() // dir.key (or the dir object) -> the save in progress: saves to one directory run one at a time
272
+
273
+ /** An OPFS handle a tab or operation holds exclusively (createSyncAccessHandle, removeEntry) is "in use". */
274
+ const isBusy = e => e && e.name === 'NoModificationAllowedError'
275
+ const busyError = (what, e) => new CheckpointError('busy', `${what} is in use by another tab or operation (${e.name}); try again when it is done. (Coordination between tabs is not done: a second tab saving the same name fails this way.)`)
276
+
277
+ /**
278
+ * Writes `name` as a new version; resolves with writeCheckpoint's result plus {version, removed, leftover}. Saves to one
279
+ * directory (one `dir.key`, or one dir object) run one after the other, in this module (two at once would both pick the next
280
+ * version number); across callers it is the Worker's `saving` guard (ckpt-save.js) that refuses a second save. `leftover` lists older versions that
281
+ * could not be removed (in use by a reader): the next save removes them.
282
+ */
283
+ export function saveVersioned(dir, name, header, sections, o = {}) {
284
+ const key = dir.key ?? dir // opfsDir() makes a new object per call: it names its directory in `key`
285
+ const prev = saveChains.get(key) || Promise.resolve()
286
+ const run = prev.catch(() => {}).then(() => saveVersionedNow(dir, name, header, sections, o))
287
+ const tail = run.catch(() => {})
288
+ saveChains.set(key, tail)
289
+ tail.then(() => saveChains.get(key) === tail && saveChains.delete(key))
290
+ return run
291
+ }
292
+
293
+ async function saveVersionedNow(dir, name, header, sections, o) {
294
+ const versions = versionsOf(await dir.names(), name)
295
+ const next = (versions.at(-1) ?? 0) + 1
296
+ const file = fileOf(name, next)
297
+ const need = HEADER_SPACE + sections.reduce((n, s) => n + s.chunks.reduce((m, c) => m + (typeof c === 'function' ? c.size : c.length) + ALIGN, 0), 0)
298
+ const mb = n => `${Math.round(n / 1048576)} MB`
299
+ const keep = versions.length ? `; the previous checkpoint ${JSON.stringify(name)} is unchanged and still usable` : ''
300
+ const free = dir.free ? await dir.free() : null
301
+ if (free !== null && free !== undefined && need > free) {
302
+ throw new CheckpointError('quota', `not enough browser storage for this checkpoint: it needs ${mb(need)} and ${mb(Math.max(0, free))} is free${keep}. Delete a checkpoint or free space, and try again`)
303
+ }
304
+ let h = null
305
+ try {
306
+ h = await dir.open(file, true)
307
+ const r = await writeCheckpoint(h, header, sections, o)
308
+ h.close()
309
+ h = null
310
+ const leftover = []
311
+ for (const v of versions) {
312
+ try {
313
+ await dir.remove(fileOf(name, v))
314
+ } catch (e) {
315
+ leftover.push(fileOf(name, v)) // a reader has it open: not lost, removed by the next save
316
+ }
317
+ }
318
+ return { ...r, version: next, removed: versions.length - leftover.length, leftover }
319
+ } catch (e) {
320
+ try {
321
+ h?.close()
322
+ } catch {}
323
+ await dir.remove(file).catch(() => {}) // no half-written checkpoint is left behind
324
+ if (isBusy(e)) throw busyError(`the checkpoint file ${file}`, e)
325
+ if (isQuota(e) || (e instanceof CheckpointError && e.code === 'truncated' && /storage full/.test(e.message))) {
326
+ throw new CheckpointError('quota', `the browser ran out of storage writing this checkpoint (${mb(need)} needed): the partial file was removed${keep}`)
327
+ }
328
+ throw e
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Calls fn(handle, version, note) on the newest version of `name` whose header is valid, closing the handle after.
334
+ * note: {skipped: [{version, code, message}]} - newer versions passed over (never finished, cut short) and why; the
335
+ * caller reports it, so a load that used an older checkpoint than the newest file says so.
336
+ */
337
+ export async function withNewest(dir, name, fn) {
338
+ const versions = versionsOf(await dir.names(), name)
339
+ if (!versions.length) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(name)} in this browser's storage`)
340
+ const skipped = []
341
+ for (const v of versions.reverse()) {
342
+ let h
343
+ try {
344
+ h = await dir.open(fileOf(name, v), false)
345
+ } catch (e) {
346
+ if (isBusy(e)) throw busyError(`the checkpoint file ${fileOf(name, v)}`, e)
347
+ throw e
348
+ }
349
+ try {
350
+ readHeader(h) // a newer file that never finished (the page closed mid-write) is skipped for the one before it
351
+ return await fn(h, v, { skipped })
352
+ } catch (e) {
353
+ if (!(e instanceof CheckpointError) || !['incomplete', 'truncated'].includes(e.code)) throw e
354
+ skipped.push({ version: v, code: e.code, message: e.message })
355
+ } finally {
356
+ h.close()
357
+ }
358
+ }
359
+ throw new CheckpointError(skipped.at(-1).code, `${skipped.at(-1).message} (every version of ${JSON.stringify(name)} was tried: ${skipped.map(x => `~${x.version} ${x.code}`).join(', ')})`)
360
+ }
361
+
362
+ /** A file in memory with the sync handle's shape (tests, and a store without OPFS). */
363
+ export function memoryFile(initial = new Uint8Array(0)) {
364
+ let buf = initial
365
+ let size = initial.length
366
+ return {
367
+ getSize: () => size,
368
+ truncate(n) {
369
+ if (n > buf.length) {
370
+ const nb = new Uint8Array(Math.max(n, buf.length * 2))
371
+ nb.set(buf.subarray(0, size))
372
+ buf = nb
373
+ } else buf.fill(0, n, size)
374
+ size = n
375
+ },
376
+ write(src, { at }) {
377
+ if (at + src.length > buf.length) {
378
+ const nb = new Uint8Array(Math.max(at + src.length, buf.length * 2))
379
+ nb.set(buf.subarray(0, size))
380
+ buf = nb
381
+ }
382
+ buf.set(src, at)
383
+ size = Math.max(size, at + src.length)
384
+ return src.length
385
+ },
386
+ read(dst, { at }) {
387
+ const n = Math.max(0, Math.min(dst.length, size - at))
388
+ dst.set(buf.subarray(at, at + n))
389
+ return n
390
+ },
391
+ flush() {},
392
+ close() {},
393
+ bytes: () => buf.subarray(0, size),
394
+ }
395
+ }
@@ -0,0 +1,207 @@
1
+ // P-B9: the checkpoint store's Worker: OPFS files through FileSystemSyncAccessHandle (the one
2
+ // OPFS API that all three browsers give a Worker). One file per checkpoint in the directory
3
+ // `wmd-ckpt` of the origin's private file system: <id>.ckpt.
4
+ //
5
+ // in: {id, op:'save', name, header, sections:[{name, lens:[n]}]} -> {ok, bytes, ms, shaMs, version, leftover}
6
+ // The bytes are pulled, one chunk at a time: when the save is ready for chunk (si, ci) the Worker posts
7
+ // {need:true, saveId:id, si, ci}; the page answers {op:'chunk', saveId, si, ci, buf}. At most one chunk (32 MiB)
8
+ // waits here, and a chunk is dropped as soon as it is written. One save at a time (a second is refused: busy).
9
+ // {id, op:'info', name} -> {ok, header} (header only: fast, no digests)
10
+ // {id, op:'load', name, sections:[names] | null (all), verify} -> {ok, header, sections:{name:[ArrayBuffer]}, ms}
11
+ // {id, op:'verify', name} -> {ok, header, ms} (every digest)
12
+ // {id, op:'list'} -> {ok, items:[{name, size, modified}]}
13
+ // {id, op:'delete', name} -> {ok}
14
+ // {id, op:'usage'} -> {ok, usage, quota}
15
+ // {id, op:'poke', name, at, xor} / {id, op:'cut', name, size} test hooks: damage a file
16
+ // out: {id, ok:false, code, error} on failure (code: CheckpointError.code, or 'io').
17
+ import { createSaver } from './ckpt-save.js'
18
+ import { CheckpointError, baseNames, fileOf, readHeader, readSection, saveVersioned, versionsOf, withNewest } from './ckpt-file.js'
19
+
20
+ const DIR = 'wmd-ckpt'
21
+ const now = () => performance.now()
22
+
23
+ async function dir() {
24
+ const root = await navigator.storage.getDirectory()
25
+ return root.getDirectoryHandle(DIR, { create: true })
26
+ }
27
+
28
+ const checkName = name => {
29
+ if (!/^[A-Za-z0-9._-]{1,80}$/.test(name) || name.startsWith('.')) throw new CheckpointError('malformed', `checkpoint name ${JSON.stringify(name)}: use letters, digits, . _ - (at most 80)`)
30
+ return name
31
+ }
32
+
33
+ /** The directory the versioned save works on (ckpt-file.js saveVersioned), over OPFS. */
34
+ async function opfsDir() {
35
+ const d = await dir()
36
+ return {
37
+ key: `opfs:${DIR}`, // saveVersioned serializes saves per key (a fresh object per call would defeat a per-object chain)
38
+ async names() {
39
+ const out = []
40
+ for await (const [n] of d.entries()) out.push(n)
41
+ return out
42
+ },
43
+ async open(file, create) {
44
+ try {
45
+ return await (await d.getFileHandle(file, { create })).createSyncAccessHandle()
46
+ } catch (e) {
47
+ if (e.name === 'NotFoundError') throw new CheckpointError('missing', `no such checkpoint file ${file}`)
48
+ throw e
49
+ }
50
+ },
51
+ remove: file => d.removeEntry(file),
52
+ async free() {
53
+ const e = await navigator.storage.estimate()
54
+ return typeof e.quota === 'number' && typeof e.usage === 'number' ? e.quota - e.usage : null
55
+ },
56
+ }
57
+ }
58
+
59
+ /** The newest version's file without validating it (the test hooks damage and repair files). */
60
+ async function rawNewest(name, fn) {
61
+ const d = await opfsDir()
62
+ const v = versionsOf(await d.names(), checkName(name)).at(-1)
63
+ if (v === undefined) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(name)} in this browser's storage`)
64
+ const h = await d.open(fileOf(name, v), false)
65
+ try {
66
+ return await fn(h)
67
+ } finally {
68
+ h.close()
69
+ }
70
+ }
71
+
72
+ const withHandle = async (name, fn) => withNewest(await opfsDir(), checkName(name), fn)
73
+
74
+ async function handle(m) {
75
+ switch (m.op) {
76
+ case 'save': {
77
+ // the chunks are pulled one at a time (startSave): the save asks for each when it is ready for it
78
+ try {
79
+ const r = await saveVersioned(await opfsDir(), checkName(m.name), m.header, m._sections, {})
80
+ return { bytes: r.bytes, ms: r.ms, shaMs: r.shaMs, version: r.version, leftover: r.leftover }
81
+ } finally {
82
+ saver.done() // success or failure, so the next save can start
83
+ }
84
+ }
85
+ case 'info':
86
+ return withHandle(m.name, async (h, version, note) => ({ header: readHeader(h), size: h.getSize(), version, skipped: note.skipped }))
87
+ case 'load':
88
+ return withHandle(m.name, async (h, version, note) => {
89
+ const t0 = now()
90
+ const header = readHeader(h)
91
+ const out = {}
92
+ const transfer = []
93
+ for (const name of m.sections || header.sections.map(x => x.name)) {
94
+ out[name] = (await readSection(h, header, name, { verify: m.verify !== false })).map(u => u.buffer)
95
+ for (const b of out[name]) transfer.push(b)
96
+ }
97
+ return { header, sections: out, ms: now() - t0, transfer, version, skipped: note.skipped }
98
+ })
99
+ case 'verify':
100
+ return withHandle(m.name, async h => {
101
+ const t0 = now()
102
+ const header = readHeader(h)
103
+ for (const s of header.sections) await readSection(h, header, s.name, { verify: true })
104
+ return { header, ms: now() - t0 }
105
+ })
106
+ case 'list': {
107
+ const d = await dir()
108
+ const names = []
109
+ const files = new Map()
110
+ for await (const [n, fh] of d.entries()) {
111
+ names.push(n)
112
+ files.set(n, fh)
113
+ }
114
+ const items = []
115
+ for (const base of baseNames(names)) {
116
+ const v = versionsOf(names, base).at(-1)
117
+ const f = await files.get(fileOf(base, v)).getFile()
118
+ items.push({ name: base, size: f.size, modified: f.lastModified })
119
+ }
120
+ return { items }
121
+ }
122
+ case 'delete': {
123
+ const d = await dir()
124
+ const names = []
125
+ for await (const [n] of d.entries()) names.push(n)
126
+ const vs = versionsOf(names, checkName(m.name))
127
+ if (!vs.length) throw new CheckpointError('missing', `no checkpoint named ${JSON.stringify(m.name)} in this browser's storage`)
128
+ for (const v of vs) await d.removeEntry(fileOf(m.name, v))
129
+ return {}
130
+ }
131
+ case 'poke': // test hook: XOR one byte of a checkpoint file (damage it)
132
+ return rawNewest(m.name, async h => {
133
+ const b = new Uint8Array(1)
134
+ h.read(b, { at: m.at })
135
+ b[0] ^= m.xor || 0xff
136
+ h.write(b, { at: m.at })
137
+ h.flush()
138
+ return {}
139
+ })
140
+ case 'cut': // test hook: truncate a checkpoint file to `size` bytes
141
+ return rawNewest(m.name, async h => {
142
+ h.truncate(m.size)
143
+ h.flush()
144
+ return {}
145
+ })
146
+ case 'probe': {
147
+ // test hook: how much one OPFS file takes in this browser, in 16 MiB writes, until it refuses (at most m.maxMB);
148
+ // an error says at which step (getDirectory, getFileHandle, createSyncAccessHandle, write)
149
+ let step = 'getDirectory'
150
+ let h = null
151
+ let d = null
152
+ let written = 0
153
+ let err = null
154
+ try {
155
+ d = await dir()
156
+ step = 'getFileHandle'
157
+ const fh = await d.getFileHandle('probe.tmp', { create: true })
158
+ step = 'createSyncAccessHandle'
159
+ h = await fh.createSyncAccessHandle()
160
+ step = 'write'
161
+ const block = new Uint8Array(16 << 20).fill(7)
162
+ while (written < (m.maxMB || 1024) * (1 << 20)) {
163
+ const n = h.write(block, { at: written })
164
+ if (!(n > 0)) {
165
+ err = `write returned ${n}`
166
+ break
167
+ }
168
+ written += n
169
+ }
170
+ step = 'flush'
171
+ h.flush()
172
+ } catch (e) {
173
+ err = `${e && e.name}: ${e && e.message}`
174
+ }
175
+ try {
176
+ h?.close()
177
+ await d?.removeEntry('probe.tmp')
178
+ } catch {}
179
+ return { writtenMB: Math.round(written / (1 << 20)), error: err && `${err} (at ${step})` }
180
+ }
181
+ case 'usage': {
182
+ const e = await navigator.storage.estimate()
183
+ return { usage: e.usage, quota: e.quota }
184
+ }
185
+ default:
186
+ throw new Error(`ckpt-io: unknown op ${m.op}`)
187
+ }
188
+ }
189
+
190
+ const saver = createSaver(msg => self.postMessage(msg))
191
+
192
+ self.onmessage = async e => {
193
+ const m = e.data
194
+ if (m.op === 'chunk') {
195
+ saver.chunk(m)
196
+ return
197
+ }
198
+ try {
199
+ if (m.op === 'save') m._sections = saver.start(m) // before anything is awaited: the page answers the first request at once
200
+ const r = await handle(m)
201
+ const transfer = r.transfer || []
202
+ delete r.transfer
203
+ self.postMessage({ id: m.id, ok: true, ...r }, transfer)
204
+ } catch (err) {
205
+ self.postMessage({ id: m.id, ok: false, code: err instanceof CheckpointError ? err.code : 'io', error: err && err.message ? `${err.name}: ${err.message}` : String(err) })
206
+ }
207
+ }