@threadedstack/wmd-web 0.1.17 → 0.1.18

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.
@@ -3,7 +3,7 @@
3
3
  // P-B1 runs the interpreter with no channels, so only ram_map is ever called. The
4
4
  // rest are stubs with the values the guest treats as "not available":
5
5
  // ram_map → 1 (RAM left as plain wasm memory; return value ignored, main_wasm.c:562)
6
- // blk_rw → -38 (no swap disk: the browser never passes --swap, so vm.wasm makes no device, D-497)
6
+ // blk_rw → -38 without a disk (bootWmd's o.disk passes --swap and the disk's mailbox: host/disk.js)
7
7
  // win_init → 1 (address windows off, win.c:226-253); other win_* → 0 (never called)
8
8
  // snapshot → -1 ("continuing without it")
9
9
  // ctl_attach → -1 (no runner control socket: the guest starts no ctl thread, W3.3)
@@ -26,7 +26,7 @@ const EBADF = -9
26
26
  const ECONNREFUSED = -111
27
27
  const ENOSYS = -38
28
28
 
29
- export function createWmdImports({ jit = {}, chan = {}, counters, ckpt = null, ram = null } = {}) {
29
+ export function createWmdImports({ jit = {}, chan = {}, counters, ckpt = null, ram = null, blk = null } = {}) {
30
30
  const note = counters ? name => noteHostCall(counters, name) : () => {}
31
31
  return {
32
32
  // channels (P-B3)
@@ -54,7 +54,7 @@ export function createWmdImports({ jit = {}, chan = {}, counters, ckpt = null, r
54
54
  ram_map2: ram?.ram_map2 || ((_off, _lowLen, _total) => -1),
55
55
  ram_rw: ram?.ram_rw || ((_off, _ptr, _len, _write) => -1),
56
56
  ram_cas: ram?.ram_cas || ((_off, _size, _expect, _desired) => 0n),
57
- blk_rw: (_op, _off, _ptr, _len) => ENOSYS,
57
+ blk_rw: blk?.blk_rw || ((_op, _off, _ptr, _len) => ENOSYS), // a disk in OPFS (host/disk.js), when bootWmd has one
58
58
 
59
59
  // W3.3 checkpoints: no control socket and no checkpoint in the browser
60
60
  // P-B9: with `ckpt` (host/ckpt-point.js) the guest starts its ctl thread and takes checkpoints
@@ -53,7 +53,8 @@ export class ImageFiles {
53
53
  /** A file's text (UTF-8), or null when it does not exist. */
54
54
  readText(path) {
55
55
  const b = this.readFile(path)
56
- return b && new TextDecoder().decode(b)
56
+ // a view on shared memory (wasm) is copied first: browsers' TextDecoder refuses it
57
+ return b && new TextDecoder().decode(typeof SharedArrayBuffer !== 'undefined' && b.buffer instanceof SharedArrayBuffer ? b.slice() : b)
57
58
  }
58
59
 
59
60
  /** {type ('file', 'dir', 'symlink', ...), mode (permission bits), uid, gid, size, mtime (BigInt ns), target?}, or null. */
@@ -24,6 +24,20 @@ export class RegistryError extends Error {
24
24
  }
25
25
  }
26
26
 
27
+ /** A mirror URL 'http(s)://host[:port]' (nothing after it) -> URL; anything else is refused. */
28
+ export function parseMirror(m) {
29
+ let u
30
+ try {
31
+ u = new URL(m)
32
+ } catch {
33
+ throw new TypeError(`bad mirror ${JSON.stringify(m)}: http(s)://host[:port]`)
34
+ }
35
+ if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) {
36
+ throw new TypeError(`bad mirror ${JSON.stringify(m)}: http(s)://host[:port]`)
37
+ }
38
+ return u
39
+ }
40
+
27
41
  const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
28
42
  export const sha256 = async bytes => `sha256:${hex(await crypto.subtle.digest('SHA-256', bytes))}`
29
43
 
@@ -81,14 +95,26 @@ async function readBody(res, max, what, { exact = false, onBytes, idleMs = 0 } =
81
95
  export class HubRepo {
82
96
  /**
83
97
  * @param {{registry: string, repo: string}} ref a parsed reference (ref.js parseRef)
84
- * @param {{fetch: typeof fetch, local?: string[]}} o
98
+ * @param {{fetch: typeof fetch, local?: string[], mirror?: string}} o mirror: 'http(s)://host[:port]', a Docker Hub mirror
99
+ * tried first for Docker Hub references (BuildKit's mirror semantics: the upstream answers whatever the mirror cannot);
100
+ * its host is allowed to be contacted because the caller named it. mirrorWaitMs: how long the mirror may take to answer
101
+ * before the upstream is asked instead.
85
102
  */
86
- constructor(ref, { fetch: fetchFn, local = [] }) {
103
+ constructor(ref, { fetch: fetchFn, local = [], mirror = null, mirrorWaitMs = 15_000 }) {
87
104
  if (typeof fetchFn !== 'function') throw new TypeError('pullImage: pass a fetch (createRelayFetch in the hosted editor)')
88
105
  this.fetch = fetchFn
89
106
  this.local = local
107
+ this.mirrorBase = null
108
+ this.mirrorWaitMs = mirrorWaitMs
90
109
  this.repo = ref.repo
91
- if (ref.registry === DOCKER_HUB) this.base = 'https://registry-1.docker.io'
110
+ if (ref.registry === DOCKER_HUB) {
111
+ this.base = 'https://registry-1.docker.io'
112
+ if (mirror) {
113
+ const u = parseMirror(mirror)
114
+ this.mirrorBase = u.origin
115
+ this.local = [...local, u.host]
116
+ }
117
+ }
92
118
  else if (local.includes(ref.registry)) this.base = `http://${ref.registry}`
93
119
  else throw new RegistryError(`registry ${ref.registry} is not allowed: the page pulls from Docker Hub (docker.io) only`, 403)
94
120
  this.token = null
@@ -112,11 +138,52 @@ export class HubRepo {
112
138
  return res
113
139
  }
114
140
 
115
- /** GET with the token, the challenge answered once, redirects followed by hand (no auth header to another host). */
116
- async get(path, accept, o = {}) {
117
- const url = new URL(`${this.base}/v2/${this.repo}/${path}`)
141
+ /**
142
+ * GET from the mirror first, then from the upstream, and `read` the answer. Any mirror failure goes on to the upstream,
143
+ * as BuildKit does: no connection, no answer within mirrorWaitMs, 404, 401, 5xx, or bytes `read` refuses (a wrong
144
+ * digest, a short body). The mirror never gets a token. A caller abort ends the request with no fallback.
145
+ * @param {(res: Response) => Promise<any>} read takes the upstream answer whatever its status; a mirror's only when ok
146
+ * @param {AbortSignal} [signal] the caller's
147
+ * @param {number} [ms] a time limit on each source's request and read
148
+ */
149
+ async #fromEither(path, accept, signal, ms, read) {
150
+ if (signal?.aborted) throw signal.reason
151
+ if (this.mirrorBase && !this.mirrorDown) {
152
+ const ac = new AbortController()
153
+ const onAbort = () => ac.abort(signal.reason)
154
+ signal?.addEventListener('abort', onAbort, { once: true })
155
+ const wait = setTimeout(() => ac.abort(new RegistryError(`mirror ${this.mirrorBase}: no answer in ${this.mirrorWaitMs} ms`)), this.mirrorWaitMs)
156
+ try {
157
+ const res = await this.#getFrom(this.mirrorBase, path, accept, { signal: timeoutSignal(ac.signal, ms) }, false)
158
+ clearTimeout(wait)
159
+ if (res.ok) {
160
+ const out = await read(res)
161
+ this.stats.mirrorHits = (this.stats.mirrorHits || 0) + 1
162
+ return out
163
+ }
164
+ res.body?.cancel().catch(() => {})
165
+ } catch (e) {
166
+ if (signal?.aborted) throw signal.reason ?? e
167
+ // a mirror that cannot be reached, hangs or sends bad bytes is not asked again for this repository
168
+ this.mirrorDown = true
169
+ } finally {
170
+ clearTimeout(wait)
171
+ signal?.removeEventListener('abort', onAbort)
172
+ }
173
+ this.stats.mirrorMisses = (this.stats.mirrorMisses || 0) + 1
174
+ }
175
+ const o = { signal: timeoutSignal(signal, ms) }
176
+ return read(await this.#getFrom(this.base, path, accept, o, true))
177
+ }
178
+
179
+ /**
180
+ * GET with the token (`tokens`; never for a mirror), the challenge answered once, redirects followed by hand (no auth
181
+ * header to another host).
182
+ */
183
+ async #getFrom(base, path, accept, o, tokens) {
184
+ const url = new URL(`${base}/v2/${this.repo}/${path}`)
118
185
  for (let attempt = 0; attempt < 2; attempt++) {
119
- let res = await this.#fetch(url, accept, true, o)
186
+ let res = await this.#fetch(url, accept, tokens, o)
120
187
  let cur = url
121
188
  let hops = 0
122
189
  while (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
@@ -124,10 +191,10 @@ export class HubRepo {
124
191
  res.body?.cancel().catch(() => {})
125
192
  const next = new URL(res.headers.get('location'), cur)
126
193
  this.stats.redirects++
127
- res = await this.#fetch(next, accept, next.host === url.host, o, path.startsWith('blobs/'))
194
+ res = await this.#fetch(next, accept, tokens && next.host === url.host, o, path.startsWith('blobs/'))
128
195
  cur = next
129
196
  }
130
- if (res.status === 401 && attempt === 0 && cur === url) {
197
+ if (tokens && res.status === 401 && attempt === 0 && cur === url) {
131
198
  const ch = parseChallenge(res.headers.get('www-authenticate') || '')
132
199
  res.body?.cancel().catch(() => {})
133
200
  if (ch.scheme !== 'bearer') throw new RegistryError(`${this.repo}: the registry wants ${ch.scheme || 'unknown'} auth; only anonymous bearer tokens are supported`, 401)
@@ -166,31 +233,32 @@ export class HubRepo {
166
233
 
167
234
  /** A manifest or index: {body, doc, mediaType, digest}. With `want` (a digest), bytes that hash otherwise are refused. */
168
235
  async manifest(reference, want = null, o = {}) {
169
- const res = await this.get(`manifests/${reference}`, ACCEPT_MANIFEST, { signal: timeoutSignal(o.signal, 60_000) })
170
- if (res.status === 404) {
171
- res.body?.cancel().catch(() => {})
172
- throw new RegistryError(`${this.repo}:${reference}: not found (or private)`, 404)
173
- }
174
- if (!res.ok) {
175
- res.body?.cancel().catch(() => {})
176
- throw new RegistryError(`${this.repo} manifest ${reference}: HTTP ${res.status}`, res.status === 429 ? 429 : 502)
177
- }
178
236
  const what = `${this.repo} manifest ${reference}`
179
- const body = await readBody(res, MAX_MANIFEST_BYTES, what).catch(e => {
180
- throw e instanceof RegistryError ? e : new RegistryError(`${what}: the transfer broke: ${e?.message || e}`)
237
+ return this.#fromEither(`manifests/${reference}`, ACCEPT_MANIFEST, o.signal, 60_000, async res => {
238
+ if (res.status === 404) {
239
+ res.body?.cancel().catch(() => {})
240
+ throw new RegistryError(`${this.repo}:${reference}: not found (or private)`, 404)
241
+ }
242
+ if (!res.ok) {
243
+ res.body?.cancel().catch(() => {})
244
+ throw new RegistryError(`${this.repo} manifest ${reference}: HTTP ${res.status}`, res.status === 429 ? 429 : 502)
245
+ }
246
+ const body = await readBody(res, MAX_MANIFEST_BYTES, what).catch(e => {
247
+ throw e instanceof RegistryError ? e : new RegistryError(`${what}: the transfer broke: ${e?.message || e}`)
248
+ })
249
+ const digest = await sha256(body)
250
+ const expect = want || (isDigest(reference) ? reference : null)
251
+ if (expect && digest !== expect) throw new RegistryError(`${this.repo} manifest: the registry sent bytes with digest ${digest}, not ${expect}; refused`)
252
+ let doc
253
+ try {
254
+ doc = JSON.parse(new TextDecoder().decode(body))
255
+ } catch {
256
+ throw new RegistryError(`${what}: not JSON`)
257
+ }
258
+ if (!doc || typeof doc !== 'object') throw new RegistryError(`${what}: not an object`)
259
+ const mediaType = doc.mediaType || (res.headers.get('content-type') || '').split(';')[0].trim()
260
+ return { body, doc, mediaType, digest }
181
261
  })
182
- const digest = await sha256(body)
183
- const expect = want || (isDigest(reference) ? reference : null)
184
- if (expect && digest !== expect) throw new RegistryError(`${this.repo} manifest: the registry sent bytes with digest ${digest}, not ${expect}; refused`)
185
- let doc
186
- try {
187
- doc = JSON.parse(new TextDecoder().decode(body))
188
- } catch {
189
- throw new RegistryError(`${what}: not JSON`)
190
- }
191
- if (!doc || typeof doc !== 'object') throw new RegistryError(`${what}: not an object`)
192
- const mediaType = doc.mediaType || (res.headers.get('content-type') || '').split(';')[0].trim()
193
- return { body, doc, mediaType, digest }
194
262
  }
195
263
 
196
264
  /**
@@ -203,19 +271,20 @@ export class HubRepo {
203
271
  const short = desc.digest?.slice?.(0, 19)
204
272
  if (!isDigest(desc.digest)) throw new RegistryError(`bad digest ${desc.digest}`)
205
273
  if (!(Number.isSafeInteger(desc.size) && desc.size >= 0 && desc.size <= max)) throw new RegistryError(`blob ${short}: ${desc.size} bytes refused (at most ${max})`)
206
- const res = await this.get(`blobs/${desc.digest}`, null, { signal: o.signal })
207
- if (!res.ok) {
208
- res.body?.cancel().catch(() => {})
209
- throw new RegistryError(`${this.repo} blob ${short}: HTTP ${res.status}`)
210
- }
211
274
  const idleMs = o.idleMs ?? 60_000
212
- const body = await readBody(res, desc.size, `blob ${short}`, { exact: true, onBytes: o.onBytes, idleMs }).catch(e => {
213
- if (o.signal?.aborted) throw o.signal.reason ?? e
214
- throw e instanceof RegistryError ? e : new RegistryError(`blob ${short}: download failed: ${e?.message || e}`)
275
+ return this.#fromEither(`blobs/${desc.digest}`, null, o.signal, 0, async res => {
276
+ if (!res.ok) {
277
+ res.body?.cancel().catch(() => {})
278
+ throw new RegistryError(`${this.repo} blob ${short}: HTTP ${res.status}`)
279
+ }
280
+ const body = await readBody(res, desc.size, `blob ${short}`, { exact: true, onBytes: o.onBytes, idleMs }).catch(e => {
281
+ if (o.signal?.aborted) throw o.signal.reason ?? e
282
+ throw e instanceof RegistryError ? e : new RegistryError(`blob ${short}: download failed: ${e?.message || e}`)
283
+ })
284
+ if (body.length !== desc.size) throw new RegistryError(`blob ${short}: the connection ended after ${body.length} of ${desc.size} bytes`)
285
+ const d = await sha256(body)
286
+ if (d !== desc.digest) throw new RegistryError(`blob ${desc.digest}: the bytes received hash to ${d}; refused`)
287
+ return body
215
288
  })
216
- if (body.length !== desc.size) throw new RegistryError(`blob ${short}: the connection ended after ${body.length} of ${desc.size} bytes`)
217
- const d = await sha256(body)
218
- if (d !== desc.digest) throw new RegistryError(`blob ${desc.digest}: the bytes received hash to ${d}; refused`)
219
- return body
220
289
  }
221
290
  }