@threadedstack/wmd-web 0.0.0-stage → 0.1.13
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 +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
package/page/sw.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// P-B7: the service worker of the pack host (served at /sw.js, scope /).
|
|
2
|
+
//
|
|
3
|
+
// - /blobs/sha256/<hex> (packs): cache first, forever. A pack is put in the cache only after
|
|
4
|
+
// its bytes hash to the name in its URL; a response that does not is refused (the page
|
|
5
|
+
// gets a 502 with the reason) and nothing is cached.
|
|
6
|
+
// - image indexes: network first, cached on every 200, the cache when the network fails.
|
|
7
|
+
// - the page, its modules, the Workers' scripts, vm.wasm, kernel, initramfs: VERSIONED. A version is
|
|
8
|
+
// a cache (wmd-app-<n>). A navigation that finds the host's page different from the cached one
|
|
9
|
+
// (or a background refresh that found changed files) starts version n+1 (the changed files, the
|
|
10
|
+
// rest from the network on first use); the page load is pinned to the version that served its
|
|
11
|
+
// navigation, and so are its Workers (a Worker's script request names its creator, its imports
|
|
12
|
+
// name the Worker). Everything a page and its Workers load comes from one version; a version
|
|
13
|
+
// stays until no client uses it. Offline: the cache of the version the page is pinned to.
|
|
14
|
+
// - POST /pull (the proxy) and other non-GET requests pass through, never cached.
|
|
15
|
+
// The page's own document and modules load before this worker controls the page, so the
|
|
16
|
+
// page sends their URLs once it is controlled ({t:'warm'}) and they are cached then.
|
|
17
|
+
|
|
18
|
+
const BLOBS = 'wmd-blobs-v1'
|
|
19
|
+
const APP_PREFIX = 'wmd-app-' // a version of the page and its files: wmd-app-<n>
|
|
20
|
+
const NEXT = 'wmd-app-next' // files the background refresh found changed, moved into the next version
|
|
21
|
+
const META = 'wmd-meta' // {latest, clients:{clientId: version}} in one cache entry (this worker is stopped when idle)
|
|
22
|
+
|
|
23
|
+
self.addEventListener('install', () => self.skipWaiting())
|
|
24
|
+
|
|
25
|
+
self.addEventListener('activate', e => {
|
|
26
|
+
e.waitUntil(
|
|
27
|
+
(async () => {
|
|
28
|
+
// the old single-cache layout (wmd-app-v1) is dropped: it has no version number
|
|
29
|
+
for (const k of await caches.keys()) if (k === 'wmd-app-v1' || k === 'wmd-app-next-v1') await caches.delete(k)
|
|
30
|
+
await self.clients.claim()
|
|
31
|
+
})(),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
let metaP = null
|
|
36
|
+
const loadMeta = () =>
|
|
37
|
+
(metaP ??= (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const r = await (await caches.open(META)).match('/__meta')
|
|
40
|
+
const m = r ? await r.json() : null
|
|
41
|
+
if (m && typeof m.latest === 'number') {
|
|
42
|
+
for (const [id, v] of Object.entries(m.clients || {})) if (typeof v === 'number') m.clients[id] = { v, t: Date.now(), miss: 0 }
|
|
43
|
+
m.clients ||= {}
|
|
44
|
+
return m
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
return { latest: 0, clients: {} }
|
|
48
|
+
})())
|
|
49
|
+
async function saveMeta() {
|
|
50
|
+
const m = await loadMeta()
|
|
51
|
+
const ids = Object.keys(m.clients)
|
|
52
|
+
for (const k of ids.slice(0, Math.max(0, ids.length - 300))) delete m.clients[k] // bounded
|
|
53
|
+
try {
|
|
54
|
+
await (await caches.open(META)).put('/__meta', new Response(JSON.stringify(m)))
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
const PIN_MIN_MS = 30_000 // a pin younger than this is never dropped: a new page is not listed by clients.matchAll for a while
|
|
58
|
+
const pin = (m, id, v) => (m.clients[id] = { v, t: Date.now(), miss: 0 })
|
|
59
|
+
const bucketName = n => `${APP_PREFIX}${n}`
|
|
60
|
+
|
|
61
|
+
// Drops versions no client uses (and no longer the latest). Where clients cannot be listed, drops nothing.
|
|
62
|
+
async function sweep() {
|
|
63
|
+
const m = await loadMeta()
|
|
64
|
+
let live
|
|
65
|
+
try {
|
|
66
|
+
live = new Set((await self.clients.matchAll({ includeUncontrolled: true, type: 'all' })).map(c => c.id))
|
|
67
|
+
} catch {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
// a pin goes when its client was absent from two sweeps in a row and the pin is not young
|
|
71
|
+
for (const [id, p] of Object.entries(m.clients)) {
|
|
72
|
+
if (live.has(id)) p.miss = 0
|
|
73
|
+
else if (Date.now() - p.t > PIN_MIN_MS && ++p.miss >= 2) delete m.clients[id]
|
|
74
|
+
}
|
|
75
|
+
const used = new Set([m.latest, ...Object.values(m.clients).map(p => p.v)])
|
|
76
|
+
for (const k of await caches.keys()) {
|
|
77
|
+
const n = k.startsWith(APP_PREFIX) && k !== NEXT ? Number(k.slice(APP_PREFIX.length)) : NaN
|
|
78
|
+
if (Number.isInteger(n) && !used.has(n)) await caches.delete(k)
|
|
79
|
+
}
|
|
80
|
+
await saveMeta()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The version a request is served from: its client's (a page, or a Worker resolved to its page), else the latest. */
|
|
84
|
+
async function versionOf(e) {
|
|
85
|
+
const m = await loadMeta()
|
|
86
|
+
const id = e.clientId
|
|
87
|
+
if (id && m.clients[id]) return m.clients[id].v
|
|
88
|
+
return m.latest
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
92
|
+
const fail = (status, msg) => new Response(`${msg}\n`, { status, headers: { 'Content-Type': 'text/plain', 'X-Wmd-Sw': 'refused' } })
|
|
93
|
+
// marks where a response came from (the page records it per pack)
|
|
94
|
+
const tag = (r, src) => {
|
|
95
|
+
const h = new Headers(r.headers)
|
|
96
|
+
h.set('X-Wmd-Source', src)
|
|
97
|
+
return new Response(r.body, { status: r.status, statusText: r.statusText, headers: h })
|
|
98
|
+
}
|
|
99
|
+
// A full cache (quota) must not break a visit: the response is served when it cannot be kept.
|
|
100
|
+
async function keep(cache, url, res) {
|
|
101
|
+
try {
|
|
102
|
+
await cache.put(url, res)
|
|
103
|
+
return true
|
|
104
|
+
} catch {
|
|
105
|
+
stats.putFailed++
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const stats = { versions: 0, putFailed: 0, blobHits: 0, blobFetches: 0, blobGzip: 0, blobRefused: 0, appNet: 0, appCache: 0, appMiss: 0 }
|
|
110
|
+
|
|
111
|
+
async function blob(req, want) {
|
|
112
|
+
const cache = await caches.open(BLOBS)
|
|
113
|
+
const hit = await cache.match(req.url)
|
|
114
|
+
if (hit) {
|
|
115
|
+
stats.blobHits++
|
|
116
|
+
return tag(hit, 'cache')
|
|
117
|
+
}
|
|
118
|
+
stats.blobFetches++
|
|
119
|
+
let res
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(req.url, { cache: 'no-store' })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return fail(504, `pack ${want.slice(0, 12)}: not cached and the host is unreachable (${e?.message || e})`)
|
|
124
|
+
}
|
|
125
|
+
if (!res.ok) return res
|
|
126
|
+
if (res.headers.get('content-encoding') === 'gzip') stats.blobGzip++
|
|
127
|
+
let buf
|
|
128
|
+
try {
|
|
129
|
+
buf = await res.arrayBuffer()
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return fail(502, `pack ${want.slice(0, 12)}: the transfer broke (${e?.message || e}); nothing cached`)
|
|
132
|
+
}
|
|
133
|
+
const got = hex(await crypto.subtle.digest('SHA-256', buf))
|
|
134
|
+
if (got !== want) {
|
|
135
|
+
stats.blobRefused++
|
|
136
|
+
return fail(502, `pack ${want.slice(0, 12)}: the host sent bytes that hash to ${got.slice(0, 12)}; refused, nothing cached`)
|
|
137
|
+
}
|
|
138
|
+
const headers = new Headers(res.headers)
|
|
139
|
+
headers.set('X-Wmd-Verified', `sha256:${got}`)
|
|
140
|
+
headers.set('Content-Length', String(buf.byteLength))
|
|
141
|
+
headers.delete('Content-Encoding') // the body is decoded already
|
|
142
|
+
const ok = new Response(buf, { status: 200, headers })
|
|
143
|
+
await keep(cache, req.url, ok.clone())
|
|
144
|
+
return tag(ok, 'network')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sameBytes = async (a, b) => {
|
|
148
|
+
if (a.byteLength !== b.byteLength) return false
|
|
149
|
+
return hex(await crypto.subtle.digest('SHA-256', a)) === hex(await crypto.subtle.digest('SHA-256', b))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Background refresh of a file served from version n: a changed file goes to NEXT (the version a later
|
|
153
|
+
// navigation starts), an unchanged one is dropped.
|
|
154
|
+
async function refresh(req, hit) {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(req, { cache: 'no-store' })
|
|
157
|
+
if (res.status !== 200 || res.type !== 'basic') return
|
|
158
|
+
stats.appNet++
|
|
159
|
+
const [a, b] = await Promise.all([hit.clone().arrayBuffer(), res.clone().arrayBuffer()])
|
|
160
|
+
if (!(await sameBytes(a, b))) await keep(await caches.open(NEXT), req.url, res.clone())
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A file of the page's version: its cache, else the network (kept in that version).
|
|
165
|
+
async function versioned(e) {
|
|
166
|
+
const req = e.request
|
|
167
|
+
const n = await versionOf(e)
|
|
168
|
+
const m = await loadMeta()
|
|
169
|
+
const cache = await caches.open(bucketName(n || (m.latest = 1, await saveMeta(), 1)))
|
|
170
|
+
// a Worker's script: its own client (the resulting one) is pinned to its creator's version
|
|
171
|
+
if (e.resultingClientId && (req.destination === 'worker' || req.destination === 'sharedworker')) {
|
|
172
|
+
pin(m, e.resultingClientId, n || 1)
|
|
173
|
+
await saveMeta()
|
|
174
|
+
}
|
|
175
|
+
const hit = await cache.match(req.url)
|
|
176
|
+
if (hit) {
|
|
177
|
+
stats.appCache++
|
|
178
|
+
if (n === m.latest) e.waitUntil(refresh(req, hit))
|
|
179
|
+
return hit
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch(req, { cache: 'no-store' })
|
|
183
|
+
if (res.status === 200 && res.type === 'basic') {
|
|
184
|
+
stats.appNet++
|
|
185
|
+
await keep(cache, req.url, res.clone())
|
|
186
|
+
}
|
|
187
|
+
return res
|
|
188
|
+
} catch {
|
|
189
|
+
stats.appMiss++
|
|
190
|
+
return fail(504, `offline and not cached: ${new URL(req.url).pathname}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Image indexes: network first (current whenever the host is up), the latest version's cache offline.
|
|
195
|
+
async function index(req) {
|
|
196
|
+
const m = await loadMeta()
|
|
197
|
+
const cache = await caches.open(bucketName(m.latest || 1))
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(req, { cache: 'no-store' })
|
|
200
|
+
if (res.status === 200 && res.type === 'basic') {
|
|
201
|
+
stats.appNet++
|
|
202
|
+
await keep(cache, req.url, res.clone())
|
|
203
|
+
}
|
|
204
|
+
return res
|
|
205
|
+
} catch {
|
|
206
|
+
// an index is data, not code: the newest version that has it
|
|
207
|
+
const names = (await caches.keys()).filter(k => k.startsWith(APP_PREFIX) && k !== NEXT).sort((a, b) => Number(b.slice(APP_PREFIX.length)) - Number(a.slice(APP_PREFIX.length)))
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
const hit = await (await caches.open(name)).match(req.url)
|
|
210
|
+
if (hit) {
|
|
211
|
+
stats.appCache++
|
|
212
|
+
return hit
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
stats.appMiss++
|
|
216
|
+
return fail(504, `offline and not cached: ${new URL(req.url).pathname}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A navigation. The host's page is compared with the latest version's: if it differs, or a refresh
|
|
221
|
+
// found changed files, a new version starts (the changed files; the rest come from the network on
|
|
222
|
+
// first use), so the page never loads next to modules of another version. The new page load is
|
|
223
|
+
// pinned to the version that served it.
|
|
224
|
+
// Builds version n from the one before: every file of the previous version is revalidated (a
|
|
225
|
+
// conditional request when it has an ETag or Last-Modified, so unchanged files cost no download),
|
|
226
|
+
// kept as it was when the host does not answer, dropped when the host says 404. The page goes in
|
|
227
|
+
// last. Only a complete version is switched to (m.latest): if anything cannot be stored the new
|
|
228
|
+
// version is deleted and the old one keeps serving.
|
|
229
|
+
async function startVersion(m, req, res) {
|
|
230
|
+
const old = m.latest || 0
|
|
231
|
+
const n = old + 1
|
|
232
|
+
const cache = await caches.open(bucketName(n))
|
|
233
|
+
try {
|
|
234
|
+
if (old) {
|
|
235
|
+
const oldCache = await caches.open(bucketName(old))
|
|
236
|
+
const keys = (await oldCache.keys()).filter(k => new URL(k.url).pathname !== new URL(req.url).pathname)
|
|
237
|
+
let i = 0
|
|
238
|
+
const work = async () => {
|
|
239
|
+
for (let k = keys[i++]; k; k = keys[i++]) {
|
|
240
|
+
const was = await oldCache.match(k.url)
|
|
241
|
+
if (!was || (was.headers.get('content-type') || '').startsWith('text/html')) continue
|
|
242
|
+
const h = new Headers()
|
|
243
|
+
if (was.headers.get('etag')) h.set('If-None-Match', was.headers.get('etag'))
|
|
244
|
+
else if (was.headers.get('last-modified')) h.set('If-Modified-Since', was.headers.get('last-modified'))
|
|
245
|
+
let r = null
|
|
246
|
+
try {
|
|
247
|
+
r = await fetch(k.url, { cache: 'no-store', headers: h })
|
|
248
|
+
} catch {}
|
|
249
|
+
if (r && r.status === 404) continue
|
|
250
|
+
if (r && r.status === 200 && r.type === 'basic') {
|
|
251
|
+
stats.appNet++
|
|
252
|
+
if (!(await keep(cache, k.url, r))) throw new Error('cache full')
|
|
253
|
+
} else if (!(await keep(cache, k.url, was))) throw new Error('cache full') // 304, or the host did not answer
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await Promise.all([work(), work(), work(), work()])
|
|
257
|
+
}
|
|
258
|
+
if (!(await keep(cache, req.url, res.clone()))) throw new Error('cache full')
|
|
259
|
+
} catch {
|
|
260
|
+
await caches.delete(bucketName(n))
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
m.latest = n
|
|
264
|
+
stats.versions++
|
|
265
|
+
const next = await caches.open(NEXT)
|
|
266
|
+
for (const k of await next.keys()) await next.delete(k) // what the refresh found is in the version now
|
|
267
|
+
return n
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A navigation. The host's page is compared with the latest version's: if it differs, or a refresh
|
|
271
|
+
// found changed files, a new version is built (startVersion) before the page is handed out, so the page
|
|
272
|
+
// never loads next to modules of another version. The page load is pinned to the version that served it.
|
|
273
|
+
async function navigate(e) {
|
|
274
|
+
const req = e.request
|
|
275
|
+
const m = await loadMeta()
|
|
276
|
+
let res = null
|
|
277
|
+
try {
|
|
278
|
+
res = await fetch(req)
|
|
279
|
+
} catch {}
|
|
280
|
+
let n = m.latest || 0
|
|
281
|
+
if (res && res.status === 200 && res.type === 'basic') {
|
|
282
|
+
stats.appNet++
|
|
283
|
+
const have = n ? await (await caches.open(bucketName(n))).match(req.url, { ignoreSearch: true }) : null // the same page with another query is the same version
|
|
284
|
+
const nextKeys = await (await caches.open(NEXT)).keys()
|
|
285
|
+
const changed = !have || !(await sameBytes(await have.clone().arrayBuffer(), await res.clone().arrayBuffer()))
|
|
286
|
+
if (!n || changed || nextKeys.length) {
|
|
287
|
+
const made = await startVersion(m, req, res)
|
|
288
|
+
if (made) n = made
|
|
289
|
+
else if (have) {
|
|
290
|
+
// the update could not be stored: the old version keeps serving, page included
|
|
291
|
+
stats.appCache++
|
|
292
|
+
if (e.resultingClientId) pin(m, e.resultingClientId, n)
|
|
293
|
+
await saveMeta()
|
|
294
|
+
return have
|
|
295
|
+
}
|
|
296
|
+
} else await keep(await caches.open(bucketName(n)), req.url, res.clone())
|
|
297
|
+
} else if (!res) {
|
|
298
|
+
n = n || 1
|
|
299
|
+
const cache = await caches.open(bucketName(n))
|
|
300
|
+
const hit = (await cache.match(req.url)) || (await cache.match(req.url, { ignoreSearch: true }))
|
|
301
|
+
if (hit) {
|
|
302
|
+
stats.appCache++
|
|
303
|
+
if (e.resultingClientId) (pin(m, e.resultingClientId, n), await saveMeta())
|
|
304
|
+
return hit
|
|
305
|
+
}
|
|
306
|
+
stats.appMiss++
|
|
307
|
+
return fail(504, `offline and not cached: ${new URL(req.url).pathname}`)
|
|
308
|
+
}
|
|
309
|
+
if (e.resultingClientId) pin(m, e.resultingClientId, n)
|
|
310
|
+
await saveMeta()
|
|
311
|
+
e.waitUntil(sweep())
|
|
312
|
+
return res
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
self.addEventListener('fetch', e => {
|
|
316
|
+
const req = e.request
|
|
317
|
+
const url = new URL(req.url)
|
|
318
|
+
if (url.origin !== self.location.origin || req.method !== 'GET') return
|
|
319
|
+
const m = url.pathname.match(/^\/blobs\/sha256\/([0-9a-f]{64})$/)
|
|
320
|
+
if (m) return e.respondWith(blob(req, m[1]))
|
|
321
|
+
if (url.pathname === '/sw.js' || url.pathname === '/_store') return
|
|
322
|
+
if (req.mode === 'navigate') return e.respondWith(navigate(e))
|
|
323
|
+
if (url.pathname.startsWith('/idx/')) return e.respondWith(index(req))
|
|
324
|
+
e.respondWith(versioned(e))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// The page asks for its module graph to be cached, for the stats, and to empty the caches.
|
|
328
|
+
self.addEventListener('message', e => {
|
|
329
|
+
const { t, urls, id } = e.data || {}
|
|
330
|
+
const reply = x => e.source?.postMessage({ id, ...x })
|
|
331
|
+
if (t === 'warm') {
|
|
332
|
+
e.waitUntil(
|
|
333
|
+
(async () => {
|
|
334
|
+
const m = await loadMeta()
|
|
335
|
+
const ver = (e.source && m.clients[e.source.id]?.v) || m.latest || 1
|
|
336
|
+
if (!m.latest) {
|
|
337
|
+
m.latest = 1
|
|
338
|
+
await saveMeta()
|
|
339
|
+
}
|
|
340
|
+
const cache = await caches.open(bucketName(ver))
|
|
341
|
+
let n = 0
|
|
342
|
+
for (const u of urls || []) {
|
|
343
|
+
const url = new URL(u, self.location.origin)
|
|
344
|
+
if (url.origin !== self.location.origin || url.pathname.startsWith('/blobs/')) continue
|
|
345
|
+
try {
|
|
346
|
+
const r = await fetch(url, { cache: 'no-store' })
|
|
347
|
+
if (r.status === 200) {
|
|
348
|
+
// a file the page's version already has is not replaced under it
|
|
349
|
+
if (!(await cache.match(url.href)) && (await keep(cache, url.href, r))) n++
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
}
|
|
353
|
+
reply({ t: 'warmed', n })
|
|
354
|
+
})(),
|
|
355
|
+
)
|
|
356
|
+
} else if (t === 'stats') {
|
|
357
|
+
e.waitUntil(
|
|
358
|
+
(async () => {
|
|
359
|
+
const out = { ...stats, caches: {} }
|
|
360
|
+
for (const name of [BLOBS, bucketName((await loadMeta()).latest || 1)]) {
|
|
361
|
+
const c = await caches.open(name)
|
|
362
|
+
const keys = await c.keys()
|
|
363
|
+
let bytes = 0
|
|
364
|
+
for (const k of keys) {
|
|
365
|
+
const r = await c.match(k)
|
|
366
|
+
bytes += Number(r?.headers.get('content-length') || 0) || (await r.arrayBuffer()).byteLength
|
|
367
|
+
}
|
|
368
|
+
out.caches[name] = { entries: keys.length, bytes }
|
|
369
|
+
}
|
|
370
|
+
reply({ t: 'stats', stats: out })
|
|
371
|
+
})(),
|
|
372
|
+
)
|
|
373
|
+
} else if (t === 'clear') {
|
|
374
|
+
e.waitUntil(
|
|
375
|
+
(async () => {
|
|
376
|
+
for (const k of await caches.keys()) await caches.delete(k)
|
|
377
|
+
reply({ t: 'cleared' })
|
|
378
|
+
})(),
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
})
|
package/relay/control.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// W59 S7: the control socket of a hosted relay (plan §15, H8): `wmd-relay ctl stop-new|resume|drain|status`.
|
|
2
|
+
//
|
|
3
|
+
// A Unix socket, never TCP, in a directory only the relay's user can enter (0700, owned by it) with the socket at 0600;
|
|
4
|
+
// those permissions are the whole guarantee. The directory is never under /tmp (or any shared temp directory): another
|
|
5
|
+
// user could have planted it. Peer uid: Bun's socket object exposes no peer credentials (its fields are fd, localAddress,
|
|
6
|
+
// remoteAddress, ...; no SO_PEERCRED / getpeereid), so there is no uid check beyond the permissions (H12, recorded
|
|
7
|
+
// in docs/relay-hosted.md). One request per connection: a line with the command, a line of JSON back.
|
|
8
|
+
|
|
9
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, rmSync, statSync } from 'node:fs'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const COMMANDS = ['stop-new', 'resume', 'drain', 'status'] as const
|
|
13
|
+
export type Command = (typeof COMMANDS)[number]
|
|
14
|
+
const SOCKET = 'ctl.sock'
|
|
15
|
+
const TMP_ROOTS = ['/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp']
|
|
16
|
+
|
|
17
|
+
export class ControlError extends Error {}
|
|
18
|
+
|
|
19
|
+
/** The directory is refused (in words) when it could be tampered with; made 0700 when it does not exist. */
|
|
20
|
+
export function checkControlDir(dir: string): string {
|
|
21
|
+
dir = resolve(dir)
|
|
22
|
+
// the real location of the nearest existing ancestor, so a symlink into /tmp is not a way round
|
|
23
|
+
let anc = dir
|
|
24
|
+
while (!existsSync(anc) && dirname(anc) !== anc) anc = dirname(anc)
|
|
25
|
+
if (existsSync(anc) && !statSync(anc).isDirectory()) {
|
|
26
|
+
throw new ControlError(`the control directory ${dir} cannot be made: ${anc} is a file, not a directory; give a path that is a directory (for example /run/wmd-relay)`)
|
|
27
|
+
}
|
|
28
|
+
const real = join(realpathSync(anc), dir.slice(anc.length))
|
|
29
|
+
for (const t of TMP_ROOTS) {
|
|
30
|
+
if (real === t || real.startsWith(`${t}/`) || dir === t || dir.startsWith(`${t}/`)) {
|
|
31
|
+
throw new ControlError(`the control directory ${dir} is under ${t}, where other users can plant files; use a directory of the relay's user (for example /run/wmd-relay)`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
35
|
+
const st = statSync(dir)
|
|
36
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
|
|
37
|
+
throw new ControlError(`the control directory ${dir} belongs to another user (uid ${st.uid}); it must belong to the relay's user`)
|
|
38
|
+
}
|
|
39
|
+
if (st.mode & 0o077) {
|
|
40
|
+
throw new ControlError(`the control directory ${dir} is open to other users (mode ${(st.mode & 0o777).toString(8)}); run: chmod 700 ${dir}`)
|
|
41
|
+
}
|
|
42
|
+
return join(dir, SOCKET)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ControlHandlers = Record<Command, () => unknown | Promise<unknown>>
|
|
46
|
+
|
|
47
|
+
/** Listen on the control socket. Resolves to its path and a stop function. */
|
|
48
|
+
export async function startControl(dir: string, handlers: ControlHandlers): Promise<{ path: string; stop: () => void }> {
|
|
49
|
+
const path = checkControlDir(dir)
|
|
50
|
+
if (path.length > 100) throw new ControlError(`the control socket path is too long for a Unix socket (${path.length} characters); pick a shorter --control-dir`)
|
|
51
|
+
if (existsSync(path)) {
|
|
52
|
+
if (!lstatSync(path).isSocket()) throw new ControlError(`${path} exists and is not a socket`)
|
|
53
|
+
const alive = await ctl(path, 'status').then(() => true, () => false)
|
|
54
|
+
if (alive) throw new ControlError(`another wmd-relay is using ${path}`)
|
|
55
|
+
rmSync(path) // left by a relay that was killed
|
|
56
|
+
}
|
|
57
|
+
const um = process.umask(0o177)
|
|
58
|
+
let listener: any
|
|
59
|
+
try {
|
|
60
|
+
listener = Bun.listen({
|
|
61
|
+
unix: path,
|
|
62
|
+
socket: {
|
|
63
|
+
open(sock: any) {
|
|
64
|
+
sock.data = ''
|
|
65
|
+
},
|
|
66
|
+
async data(sock: any, chunk: Uint8Array) {
|
|
67
|
+
sock.data += Buffer.from(chunk).toString()
|
|
68
|
+
if (sock.data.length > 256) return sock.end()
|
|
69
|
+
const nl = sock.data.indexOf('\n')
|
|
70
|
+
if (nl < 0) return
|
|
71
|
+
const cmd = sock.data.slice(0, nl).trim()
|
|
72
|
+
sock.data = ''
|
|
73
|
+
let out: unknown
|
|
74
|
+
try {
|
|
75
|
+
if (!(COMMANDS as readonly string[]).includes(cmd)) out = { ok: false, error: `unknown command '${cmd}' (${COMMANDS.join(', ')})` }
|
|
76
|
+
else out = { ok: true, ...((await handlers[cmd as Command]()) as object) }
|
|
77
|
+
} catch (e: any) {
|
|
78
|
+
out = { ok: false, error: String(e?.message || e) }
|
|
79
|
+
}
|
|
80
|
+
sock.write(JSON.stringify(out) + '\n')
|
|
81
|
+
sock.end()
|
|
82
|
+
},
|
|
83
|
+
error() {},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
} finally {
|
|
87
|
+
process.umask(um)
|
|
88
|
+
}
|
|
89
|
+
chmodSync(path, 0o600)
|
|
90
|
+
return {
|
|
91
|
+
path,
|
|
92
|
+
stop() {
|
|
93
|
+
listener.stop(true)
|
|
94
|
+
rmSync(path, { force: true })
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One command to the socket at `path`; the reply object. Rejects when nothing listens or the socket cannot be opened. */
|
|
100
|
+
export function ctl(path: string, cmd: Command | string): Promise<any> {
|
|
101
|
+
return new Promise((res, rej) => {
|
|
102
|
+
let buf = ''
|
|
103
|
+
let done = false
|
|
104
|
+
const fin = (f: () => void) => {
|
|
105
|
+
if (!done) {
|
|
106
|
+
done = true
|
|
107
|
+
clearTimeout(t)
|
|
108
|
+
f()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const t = setTimeout(() => fin(() => rej(new ControlError('no answer from the relay in 10 s'))), 10_000)
|
|
112
|
+
Bun.connect({
|
|
113
|
+
unix: path,
|
|
114
|
+
socket: {
|
|
115
|
+
open(s: any) {
|
|
116
|
+
s.write(`${cmd}\n`)
|
|
117
|
+
},
|
|
118
|
+
data(_s: any, chunk: Uint8Array) {
|
|
119
|
+
buf += Buffer.from(chunk).toString()
|
|
120
|
+
},
|
|
121
|
+
close() {
|
|
122
|
+
fin(() => {
|
|
123
|
+
try {
|
|
124
|
+
res(JSON.parse(buf))
|
|
125
|
+
} catch {
|
|
126
|
+
rej(new ControlError('the relay sent no answer'))
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
error(_s: any, e: Error) {
|
|
131
|
+
fin(() => rej(e))
|
|
132
|
+
},
|
|
133
|
+
connectError(_s: any, e: Error) {
|
|
134
|
+
fin(() => rej(e))
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}).catch(e => fin(() => rej(e)))
|
|
138
|
+
})
|
|
139
|
+
}
|