@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,68 @@
1
+ // W71: the page's side of pack-worker.js: one Worker that converts layers to packs with wmdpack.wasm.
2
+ //
3
+ // const conv = await createConverter(wmdpackWasm) // a URL, bytes or a WebAssembly.Module
4
+ // const p = await conv.convert(layerBytes, belowShas) // {sha, size, bytes, ms, fileBytes, wasmBytes}
5
+ // await conv.add(sha, bytes) // a pack the page already has, for later BELOWs
6
+ // conv.close()
7
+
8
+ let seq = 0
9
+
10
+ async function toModule(src, fetchFn) {
11
+ if (src instanceof WebAssembly.Module) return src
12
+ if (src instanceof Uint8Array || src instanceof ArrayBuffer) return WebAssembly.compile(src)
13
+ if (typeof src === 'string' || src instanceof URL) {
14
+ const r = await fetchFn(src)
15
+ if (!r.ok) throw new Error(`wmdpack.wasm: ${src}: HTTP ${r.status}`)
16
+ return WebAssembly.compile(await r.arrayBuffer())
17
+ }
18
+ throw new TypeError('createConverter: wmdpack.wasm as a URL, bytes or a WebAssembly.Module')
19
+ }
20
+
21
+ /**
22
+ * @param {string|URL|Uint8Array|ArrayBuffer|WebAssembly.Module} wasm wmdpack.wasm (`make wmdpack-wasm`)
23
+ * @param {{maxFileBytes?: number, fetch?: typeof fetch}} [o] maxFileBytes: the largest pack (default 4 GiB)
24
+ */
25
+ export async function createConverter(wasm, { maxFileBytes, fetch: fetchFn = globalThis.fetch } = {}) {
26
+ const module = await toModule(wasm, fetchFn)
27
+ const worker = new Worker(new URL('./pack-worker.js', import.meta.url), { type: 'module', name: 'wmdpack' })
28
+ const waiting = new Map()
29
+ let broken = null
30
+ const failAll = err => {
31
+ broken = err
32
+ for (const w of waiting.values()) w.reject(err)
33
+ waiting.clear()
34
+ }
35
+ worker.onmessage = ({ data: m }) => {
36
+ const w = waiting.get(m.id)
37
+ if (!w) return
38
+ waiting.delete(m.id)
39
+ if (m.t === 'error') w.reject(new Error(m.message))
40
+ else w.resolve(m)
41
+ }
42
+ worker.onerror = e => failAll(new Error(`the converter Worker failed: ${e.message || 'error'}`))
43
+ worker.postMessage({ t: 'init', module, maxFileBytes })
44
+ const call = (msg, transfer = []) => {
45
+ if (broken) return Promise.reject(broken)
46
+ const id = ++seq
47
+ return new Promise((resolve, reject) => {
48
+ waiting.set(id, { resolve, reject })
49
+ worker.postMessage({ ...msg, id }, transfer)
50
+ })
51
+ }
52
+ return {
53
+ /** Converts one layer (tar or gzip; its bytes are moved to the Worker) over the packs below it, top first. */
54
+ async convert(layer, below = []) {
55
+ const buf = layer instanceof ArrayBuffer ? layer : layer.byteOffset === 0 && layer.byteLength === layer.buffer.byteLength ? layer.buffer : layer.slice().buffer
56
+ const m = await call({ t: 'convert', layer: buf, below }, [buf])
57
+ return { sha: m.sha, size: m.size, bytes: m.bytes, ms: m.ms, fileBytes: m.fileBytes, wasmBytes: m.wasmBytes }
58
+ },
59
+ /** A pack the page already has (from its store), so a later layer can convert over it. */
60
+ async add(sha, bytes) {
61
+ await call({ t: 'add', sha, bytes })
62
+ },
63
+ close() {
64
+ failAll(new Error('the converter is closed'))
65
+ worker.terminate()
66
+ },
67
+ }
68
+ }
@@ -0,0 +1,221 @@
1
+ // W71: Docker Hub from the page, over a fetch the caller injects (in wmn's hosted editor,
2
+ // createRelayFetch: TLS in the page, the bytes through a wisp relay). The same rules as the
3
+ // registry proxy (web/registry/upstream.ts):
4
+ //
5
+ // - only Docker Hub's registry and auth hosts, and its blob CDN hosts for a blob GET reached by
6
+ // a redirect, are contacted (plus `local` host:port names, for a test registry);
7
+ // - redirects are followed by hand, and the token is never sent to another host;
8
+ // - anonymous bearer tokens only (a private image is refused);
9
+ // - every manifest and blob is checked against its digest and size, with size caps.
10
+ //
11
+ // The fetch must answer `redirect: 'manual'` with the 3xx response itself (createRelayFetch and
12
+ // Bun do; a browser's own fetch gives an opaque redirect, which is refused here).
13
+
14
+ import { ACCEPT_MANIFEST, hostAllowed, MAX_MANIFEST_BYTES, parseChallenge } from './oci.js'
15
+ import { DOCKER_HUB, isDigest } from './ref.js'
16
+
17
+ export const MAX_BLOB_BYTES = 2 * 2 ** 30 // one layer in the page's memory
18
+
19
+ export class RegistryError extends Error {
20
+ constructor(message, status = 502) {
21
+ super(message)
22
+ this.name = 'RegistryError'
23
+ this.status = status
24
+ }
25
+ }
26
+
27
+ const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
28
+ export const sha256 = async bytes => `sha256:${hex(await crypto.subtle.digest('SHA-256', bytes))}`
29
+
30
+ const timeoutSignal = (signal, ms) => {
31
+ const sigs = [signal, ms ? AbortSignal.timeout(ms) : null].filter(Boolean)
32
+ return sigs.length > 1 ? AbortSignal.any(sigs) : sigs[0]
33
+ }
34
+
35
+ /**
36
+ * The body, read into one buffer of at most `max` bytes (`exact`: it must be exactly that many).
37
+ * A lying or missing Content-Length cannot make it bigger.
38
+ */
39
+ async function readBody(res, max, what, { exact = false, onBytes, idleMs = 0 } = {}) {
40
+ const out = new Uint8Array(exact ? max : Math.min(max, Number(res.headers.get('content-length')) || 64 << 10))
41
+ let buf = out
42
+ let n = 0
43
+ const reader = res.body?.getReader()
44
+ if (!reader) {
45
+ const b = new Uint8Array(await res.arrayBuffer())
46
+ if (b.length > max) throw new RegistryError(`${what}: more than ${max} bytes, refused`)
47
+ return b
48
+ }
49
+ let timer = null
50
+ const arm = () => {
51
+ if (!idleMs) return
52
+ clearTimeout(timer)
53
+ timer = setTimeout(() => reader.cancel(new Error(`no bytes for ${idleMs} ms`)).catch(() => {}), idleMs)
54
+ }
55
+ try {
56
+ arm()
57
+ for (;;) {
58
+ const { done, value } = await reader.read()
59
+ if (done) break
60
+ arm()
61
+ if (n + value.length > max) {
62
+ reader.cancel().catch(() => {})
63
+ throw new RegistryError(`${what}: more than ${max} bytes, refused`)
64
+ }
65
+ if (n + value.length > buf.length) {
66
+ const g = new Uint8Array(Math.min(max, Math.max(buf.length * 2, n + value.length)))
67
+ g.set(buf.subarray(0, n))
68
+ buf = g
69
+ }
70
+ buf.set(value, n)
71
+ n += value.length
72
+ onBytes?.(value.length)
73
+ }
74
+ } finally {
75
+ clearTimeout(timer)
76
+ }
77
+ return n === buf.length ? buf : buf.slice(0, n)
78
+ }
79
+
80
+ /** One repository on Docker Hub (or a `local` test registry), with its anonymous pull token. */
81
+ export class HubRepo {
82
+ /**
83
+ * @param {{registry: string, repo: string}} ref a parsed reference (ref.js parseRef)
84
+ * @param {{fetch: typeof fetch, local?: string[]}} o
85
+ */
86
+ constructor(ref, { fetch: fetchFn, local = [] }) {
87
+ if (typeof fetchFn !== 'function') throw new TypeError('pullImage: pass a fetch (createRelayFetch in the hosted editor)')
88
+ this.fetch = fetchFn
89
+ this.local = local
90
+ this.repo = ref.repo
91
+ if (ref.registry === DOCKER_HUB) this.base = 'https://registry-1.docker.io'
92
+ else if (local.includes(ref.registry)) this.base = `http://${ref.registry}`
93
+ else throw new RegistryError(`registry ${ref.registry} is not allowed: the page pulls from Docker Hub (docker.io) only`, 403)
94
+ this.token = null
95
+ this.stats = { requests: 0, tokenFetches: 0, redirects: 0 }
96
+ }
97
+
98
+ async #fetch(url, accept, auth, o, blobCdn = false) {
99
+ if (!hostAllowed(url, this.local, blobCdn)) throw new RegistryError(`refusing to connect to ${url.host}: not a Docker Hub host`, 403)
100
+ const headers = {}
101
+ if (accept) headers.Accept = accept
102
+ if (auth && this.token) headers.Authorization = `Bearer ${this.token}`
103
+ this.stats.requests++
104
+ let res
105
+ try {
106
+ res = await this.fetch(url.href, { headers, redirect: 'manual', signal: o.signal })
107
+ } catch (e) {
108
+ if (o.signal?.aborted) throw o.signal.reason ?? e
109
+ throw new RegistryError(`${url.host}: ${e?.name === 'TimeoutError' ? 'timed out' : e?.message || e}`)
110
+ }
111
+ if (res.type === 'opaqueredirect') throw new RegistryError(`${url.host}: the fetch hid a redirect (it must answer redirect: 'manual' with the 3xx response)`)
112
+ return res
113
+ }
114
+
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}`)
118
+ for (let attempt = 0; attempt < 2; attempt++) {
119
+ let res = await this.#fetch(url, accept, true, o)
120
+ let cur = url
121
+ let hops = 0
122
+ while (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
123
+ if (++hops > 5) throw new RegistryError(`${url.pathname}: too many redirects`)
124
+ res.body?.cancel().catch(() => {})
125
+ const next = new URL(res.headers.get('location'), cur)
126
+ this.stats.redirects++
127
+ res = await this.#fetch(next, accept, next.host === url.host, o, path.startsWith('blobs/'))
128
+ cur = next
129
+ }
130
+ if (res.status === 401 && attempt === 0 && cur === url) {
131
+ const ch = parseChallenge(res.headers.get('www-authenticate') || '')
132
+ res.body?.cancel().catch(() => {})
133
+ if (ch.scheme !== 'bearer') throw new RegistryError(`${this.repo}: the registry wants ${ch.scheme || 'unknown'} auth; only anonymous bearer tokens are supported`, 401)
134
+ await this.#fetchToken(ch.params, o)
135
+ continue
136
+ }
137
+ return res
138
+ }
139
+ throw new RegistryError(`${this.repo}: unauthorized (anonymous pulls only; a private image is not served)`, 401)
140
+ }
141
+
142
+ async #fetchToken(p, o) {
143
+ if (!p.realm) throw new RegistryError('auth challenge without a realm')
144
+ let u
145
+ try {
146
+ u = new URL(p.realm)
147
+ } catch {
148
+ throw new RegistryError(`bad token realm ${JSON.stringify(p.realm)}`)
149
+ }
150
+ if (!hostAllowed(u, this.local)) throw new RegistryError(`refusing token realm ${u.host}: not a Docker Hub host`, 403)
151
+ if (p.service) u.searchParams.set('service', p.service)
152
+ u.searchParams.set('scope', p.scope || `repository:${this.repo}:pull`)
153
+ this.stats.tokenFetches++
154
+ const res = await this.#fetch(u, 'application/json', false, { signal: timeoutSignal(o.signal, 30_000) })
155
+ if (!res.ok) throw new RegistryError(`token from ${u.host}: HTTP ${res.status}`, res.status === 401 ? 401 : 502)
156
+ let j = {}
157
+ try {
158
+ j = JSON.parse(new TextDecoder().decode(await readBody(res, MAX_MANIFEST_BYTES, `token from ${u.host}`)))
159
+ } catch (e) {
160
+ if (e instanceof RegistryError) throw e
161
+ }
162
+ const t = j.token || j.access_token
163
+ if (typeof t !== 'string' || !t) throw new RegistryError(`token from ${u.host}: no token in the reply`)
164
+ this.token = t
165
+ }
166
+
167
+ /** A manifest or index: {body, doc, mediaType, digest}. With `want` (a digest), bytes that hash otherwise are refused. */
168
+ 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
+ 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}`)
181
+ })
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
+ }
195
+
196
+ /**
197
+ * A blob into memory, checked against its digest and size.
198
+ * @param {{digest: string, size: number}} desc
199
+ * @param {{signal?: AbortSignal, onBytes?: (n: number) => void, max?: number, idleMs?: number}} [o]
200
+ */
201
+ async blob(desc, o = {}) {
202
+ const max = o.max ?? MAX_BLOB_BYTES
203
+ const short = desc.digest?.slice?.(0, 19)
204
+ if (!isDigest(desc.digest)) throw new RegistryError(`bad digest ${desc.digest}`)
205
+ 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
+ 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}`)
215
+ })
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
+ }
221
+ }
@@ -0,0 +1,76 @@
1
+ // W71: the OCI and Docker distribution pieces the registry proxy (web/registry/upstream.ts,
2
+ // pull.ts) and the page's own pull (pull.js) share. Plain JS that imports only ref.js.
3
+
4
+ import { isDigest, platformMatches, platformString } from './ref.js'
5
+
6
+ export const MT = {
7
+ ociIndex: 'application/vnd.oci.image.index.v1+json',
8
+ ociManifest: 'application/vnd.oci.image.manifest.v1+json',
9
+ dockerList: 'application/vnd.docker.distribution.manifest.list.v2+json',
10
+ dockerManifest: 'application/vnd.docker.distribution.manifest.v2+json',
11
+ }
12
+ export const ACCEPT_MANIFEST = [MT.ociIndex, MT.ociManifest, MT.dockerList, MT.dockerManifest].join(', ')
13
+ export const isIndexType = mt => mt === MT.ociIndex || mt === MT.dockerList
14
+ export const isManifestType = mt => mt === MT.ociManifest || mt === MT.dockerManifest
15
+
16
+ const TAR = ['application/vnd.oci.image.layer.v1.tar', 'application/vnd.docker.image.rootfs.diff.tar']
17
+ const GZIP = ['application/vnd.oci.image.layer.v1.tar+gzip', 'application/vnd.docker.image.rootfs.diff.tar.gzip']
18
+ const ZSTD = ['application/vnd.oci.image.layer.v1.tar+zstd']
19
+
20
+ /** Layer media types a converter takes. zstd: the proxy's converter reads it, the page's (wmdpack.wasm) does not. */
21
+ export function isLayerType(mt, { zstd = true } = {}) {
22
+ return TAR.includes(mt) || GZIP.includes(mt) || (zstd && ZSTD.includes(mt))
23
+ }
24
+ export const isZstdLayer = mt => ZSTD.includes(mt)
25
+
26
+ export const MAX_MANIFEST_BYTES = 4 << 20
27
+ export const MAX_LAYERS = 256
28
+
29
+ // Exact names only (W35): a suffix match on .docker.io / .docker.com would let a token realm
30
+ // or a redirect send a pull to any Docker subdomain.
31
+ export const HUB_HOSTS = ['registry-1.docker.io', 'auth.docker.io']
32
+ // Blob CDN hosts. Observed 2026-10-09 with curl against the Hub API: blob GETs for alpine,
33
+ // python, node, bash, ubuntu, debian and busybox answer 307 to production.cloudfront.docker.com;
34
+ // production.cloudflare.docker.com is the older Hub blob CDN. These are allowed only for a blob
35
+ // GET reached by a redirect from an allowed registry host, never as a realm or a first hop.
36
+ // No R2 host was seen, so none is listed; add an exact name here if the Hub starts using one.
37
+ export const BLOB_CDN_HOSTS = ['production.cloudfront.docker.com', 'production.cloudflare.docker.com']
38
+
39
+ /** Whether a pull may connect to this URL. `local`: extra host:port names (plain http too); `blobCdn`: a blob GET redirected from an allowed host. */
40
+ export function hostAllowed(u, local = [], blobCdn = false) {
41
+ if (local.includes(u.host)) return u.protocol === 'http:' || u.protocol === 'https:'
42
+ if (u.protocol !== 'https:') return false
43
+ if (u.port) return false
44
+ return HUB_HOSTS.includes(u.hostname) || (blobCdn && BLOB_CDN_HOSTS.includes(u.hostname))
45
+ }
46
+
47
+ /** `Bearer realm="...",service="...",scope="..."` -> {scheme, params}. */
48
+ export function parseChallenge(h) {
49
+ const m = (h || '').match(/^\s*(\w+)\s+(.*)$/)
50
+ const params = {}
51
+ if (!m) return { scheme: '', params }
52
+ for (const x of m[2].matchAll(/([a-zA-Z_]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]*))\s*,?/g)) {
53
+ params[x[1].toLowerCase()] = x[2] !== undefined ? x[2].replace(/\\(.)/g, '$1') : x[3]
54
+ }
55
+ return { scheme: m[1].toLowerCase(), params }
56
+ }
57
+
58
+ export const isAttestation = m =>
59
+ (m.platform?.os === 'unknown' && m.platform?.architecture === 'unknown') || m.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest'
60
+
61
+ /**
62
+ * The index entry for the first of `wants` (parsed platforms, in order of preference) the index
63
+ * has, as {desc, platform}; a nested index without a platform when none matches; else null
64
+ * and the platforms it has.
65
+ */
66
+ export function pickPlatform(index, wants) {
67
+ const ms = Array.isArray(index?.manifests) ? index.manifests : []
68
+ const usable = ms.filter(m => m && isDigest(m.digest) && !isAttestation(m))
69
+ for (const want of wants) {
70
+ const hit = usable.find(m => m.platform && platformMatches(want, m.platform))
71
+ if (hit) return { desc: hit, platform: want }
72
+ }
73
+ const nested = usable.find(m => isIndexType(m.mediaType || '') && !m.platform)
74
+ if (nested) return { desc: nested, platform: null }
75
+ return { desc: null, platform: null, have: [...new Set(usable.filter(m => m.platform).map(m => platformString(m.platform)))] }
76
+ }
@@ -0,0 +1,70 @@
1
+ // W71: the Worker that turns image layers into packs with wmdpack.wasm (wmd's own converter,
2
+ // union.PackLayer, built for wasip1: `make wmdpack-wasm`). converter.js drives it.
3
+ //
4
+ // Messages in: {t: 'init', module: WebAssembly.Module, maxFileBytes}
5
+ // {t: 'add', id, sha, bytes} a pack the page already has (a cached lower layer)
6
+ // {t: 'convert', id, layer: ArrayBuffer, below: [sha...]} below: the lower layers' packs, top first
7
+ // Messages out: {t: 'done', id, sha, size, bytes, ms, fileBytes, wasmBytes} bytes: the pack (shared when the
8
+ // page is cross-origin isolated, so the Worker and the page hold one copy); wasmBytes: the
9
+ // converter's wasm memory at its end
10
+ // {t: 'error', id, message}
11
+ //
12
+ // The Worker keeps every pack it made or was given, by sha256: a later layer's BELOW reads them in place.
13
+
14
+ import { MemFs, runWasi } from './wasi-mem.js'
15
+
16
+ let module = null
17
+ let maxFileBytes = 4 * 2 ** 30
18
+ const packs = new Map() // 'sha256:<hex>' -> Uint8Array
19
+
20
+ const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
21
+ const shared = typeof SharedArrayBuffer !== 'undefined' && globalThis.crossOriginIsolated === true
22
+
23
+ function convert({ id, layer, below }) {
24
+ const fs = new MemFs({ maxFileBytes })
25
+ const args = ['wmdpack', '/out.pack']
26
+ below.forEach((sha, i) => {
27
+ const b = packs.get(sha)
28
+ if (!b) throw new Error(`the pack ${sha.slice(0, 19)} below this layer is not in the converter`)
29
+ fs.addFile(`below/${i}.pack`, b)
30
+ args.push(`/below/${i}.pack`)
31
+ })
32
+ const t0 = performance.now()
33
+ const r = runWasi(module, { args, stdin: new Uint8Array(layer), fs })
34
+ const ms = Math.round(performance.now() - t0)
35
+ if (r.code !== 0) throw new Error(`converting the layer: ${r.stderr.trim() || `exit ${r.code}`}`)
36
+ const out = fs.take('out.pack')
37
+ if (!out) throw new Error('the converter wrote no pack')
38
+ return { out, ms, fileBytes: Number(r.stdout.trim()) || 0, wasmBytes: r.memBytes }
39
+ }
40
+
41
+ self.onmessage = async ({ data: m }) => {
42
+ try {
43
+ if (m.t === 'init') {
44
+ module = m.module
45
+ if (m.maxFileBytes) maxFileBytes = m.maxFileBytes
46
+ return
47
+ }
48
+ if (m.t === 'add') {
49
+ packs.set(m.sha, m.bytes)
50
+ self.postMessage({ t: 'added', id: m.id })
51
+ return
52
+ }
53
+ if (m.t === 'convert') {
54
+ const { out, ms, fileBytes, wasmBytes } = convert(m)
55
+ // WebCrypto refuses views of shared memory: hash before the shared copy
56
+ const sha = `sha256:${hex(await crypto.subtle.digest('SHA-256', out))}`
57
+ let bytes = out
58
+ if (shared) {
59
+ bytes = new Uint8Array(new SharedArrayBuffer(out.length))
60
+ bytes.set(out)
61
+ }
62
+ packs.set(sha, bytes)
63
+ self.postMessage({ t: 'done', id: m.id, sha, size: bytes.length, bytes: shared ? bytes : bytes.slice(), ms, fileBytes, wasmBytes })
64
+ return
65
+ }
66
+ throw new Error(`unknown message ${m.t}`)
67
+ } catch (e) {
68
+ self.postMessage({ t: 'error', id: m.id, message: e?.message || String(e) })
69
+ }
70
+ }