@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.
- package/README.md +44 -0
- package/RELEASE.json +39 -54
- package/guest/arm64/Image +2 -2
- package/guest/arm64/Image-4k +1 -1
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +4 -4
- package/relay/hosted.ts +10 -8
- package/relay/main.ts +2 -2
- package/relay/relay.ts +1 -0
- package/relay/token.ts +34 -5
- package/relay/wisp.ts +37 -5
- package/src/boot.js +25 -0
- package/src/build/builder.js +791 -0
- package/src/build/context.js +349 -0
- package/src/build/dockerfile.js +1055 -0
- package/src/build/dockerignore.js +270 -0
- package/src/build/fast.js +1187 -0
- package/src/build/inspect.js +78 -0
- package/src/build/pin.js +6 -0
- package/src/build/tar.js +304 -0
- package/src/host/ctl.js +17 -0
- package/src/host/disk-map.js +330 -0
- package/src/host/disk-worker.js +125 -0
- package/src/host/disk.js +219 -0
- package/src/host/wmd-imports.js +3 -3
- package/src/image-files.js +2 -1
- package/src/registry/hub.js +113 -44
- package/src/registry/load-tar.js +473 -0
- package/src/registry/pull.js +5 -3
- package/src/worker.js +2 -0
- package/wmdpack.wasm +0 -0
package/src/host/wmd-imports.js
CHANGED
|
@@ -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
|
|
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
|
package/src/image-files.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/registry/hub.js
CHANGED
|
@@ -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)
|
|
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
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
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,
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
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
|
}
|