@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,570 @@
1
+ // W59: the browser side of Wisp (wisp.js), a transport for the network Worker with the same
2
+ // interface as RelayClient (relay-client.js): the stack (stack.js) calls connect / data / end /
3
+ // abort / credit / dns and is called back with onConnected, onRelayData, onRelayEnd,
4
+ // onRelayAbort, onRelayCredit, onDnsReply, onRelayDown. NetStack does not change; the Wisp
5
+ // specifics live here:
6
+ //
7
+ // - upload: Wisp counts packets, not bytes. The stack gets WINDOW bytes of credit when a stream
8
+ // opens and gets back each packet's bytes when the packet leaves here; packets past the
9
+ // stream's counter wait in its queue until a CONTINUE resets the counter (plan §3).
10
+ // - download: with wmd-credit (0xE1) the stack's credit goes to the relay as CREDIT; without it
11
+ // the relay sends as fast as it reads and a stream holding more than DOWN_CAP undelivered
12
+ // bytes (or the session, SESSION_CAP) is reset with a log line.
13
+ // - half-close: with wmd-halfclose (0xE2) a guest FIN is SHUTDOWN; without it the FIN is held
14
+ // until the remote closes. A remote SHUTDOWN, or CLOSE 0x02, is a FIN; every other CLOSE is a reset.
15
+ // - names: the guest's lookups get placeholder addresses from 198.18.0.0/15; a connect to one
16
+ // is a CONNECT to the name (plan §4). With wmd-dns (0xE4) the relay answers first, so
17
+ // NXDOMAIN and refusals reach the guest; without it every valid name gets a placeholder.
18
+ // - status: onStatus({state, relay, reason, words}) for the page (plan §6).
19
+ //
20
+ // A drop of the WebSocket fails every stream (onRelayDown), as with v1.
21
+
22
+ import { E, RCODE as V1_RCODE, WINDOW } from './proto.js'
23
+ import {
24
+ CLOSE, EXT, PT, RCODE, SUBPROTOCOL, ClientHandshake, answerAddrs, closePacket, closeWords, connectPacket, creditPacket,
25
+ dataPacket, ext, lookupPacket, parse, shutdownPacket,
26
+ } from './wisp.js'
27
+
28
+ /** `relay: {url: 'local'}`: a `wmd-relay` started with no arguments on this computer. */
29
+ export const LOCAL_RELAY_URL = 'ws://127.0.0.1:8780/wisp/'
30
+ /** `relay: {url: 'hosted'}`: the public relay we run (`wmd-relay --hosted`, docs/relay-hosted.md). A page that uses it
31
+ needs `wss://relay.threadedstack.com` in its CSP connect-src. Set to '' and 'hosted' is unreachable, in words. */
32
+ export const HOSTED_RELAY_URL = 'wss://relay.threadedstack.com/wisp/'
33
+
34
+ const BACKOFF_MIN_MS = 250
35
+ const BACKOFF_MAX_MS = 8000
36
+ const CLOSE_MAINTENANCE = 1001 // a hosted relay's drain (WebSocket 1001 "the relay is closed for maintenance")
37
+ const CLOSE_DENIED = 4011 // a hosted relay's operator denied this address or page origin
38
+ const OPEN_TIMEOUT_MS = 10_000
39
+ const QUEUE_MAX_MS = 8000
40
+ export const DOWN_CAP = 64 << 20 // bytes delivered and not yet taken by the guest, per stream, without wmd-credit
41
+ export const SESSION_CAP = 256 << 20 // the same, for every stream together
42
+ export const NAME_CAP = 65536 // placeholder addresses per machine; never recycled
43
+ const PH_BASE = (198 << 24) | (18 << 16) // 198.18.0.0/15
44
+ const PH_MASK = 0xfffe0000
45
+ const NAME_TTL = 60
46
+ const CREDIT_SLACK = 64 << 10 // bytes a relay with wmd-credit may run past the credit it was given before it is cut off
47
+ const LOOKUPS_MAX = 1024 // lookups waiting for the relay's answer
48
+ const MOTD_MAX = 400 // characters of the relay's message shown in the status
49
+ const MALFORMED_LOGGED = 8 // malformed packets logged in full; after that every 1000th
50
+ const DEGRADED = { [EXT.CONFIRM]: 'open confirmation', [EXT.WMD_CREDIT]: 'download flow control', [EXT.WMD_HALFCLOSE]: 'half-close', [EXT.WMD_DNS]: 'name lookups' }
51
+
52
+ /** The URL a relay config names ('local', 'hosted', or a ws(s) URL); '' when none is configured. */
53
+ export function relayUrl(url) {
54
+ if (url === 'local') return LOCAL_RELAY_URL
55
+ if (url === 'hosted') return HOSTED_RELAY_URL
56
+ return url || ''
57
+ }
58
+
59
+ const ipU32 = (s) => {
60
+ const p = s.split('.')
61
+ if (p.length !== 4) return -1
62
+ let v = 0
63
+ for (const x of p) {
64
+ if (!/^\d{1,3}$/.test(x) || +x > 255) return -1
65
+ v = v * 256 + +x
66
+ }
67
+ return v >>> 0
68
+ }
69
+ const u32Ip = (v) => `${v >>> 24}.${(v >>> 16) & 255}.${(v >>> 8) & 255}.${v & 255}`
70
+ export const isPlaceholder = (ip) => {
71
+ const v = ipU32(ip)
72
+ return v >= 0 && ((v & PH_MASK) >>> 0) === PH_BASE >>> 0
73
+ }
74
+
75
+ /** A host name the guest could look up (RFC 1123 labels); a bad one is NXDOMAIN without asking anyone. */
76
+ export function validName(n) {
77
+ if (!n || n.length > 253 || n.includes('..')) return false
78
+ return n.split('.').every((l) => /^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$/i.test(l))
79
+ }
80
+
81
+ /** A Wisp CLOSE reason as the stack's E code. */
82
+ function eCode(reason) {
83
+ switch (reason) {
84
+ case CLOSE.INVALID: return E.BAD
85
+ case CLOSE.UNREACHABLE: return E.UNREACHABLE
86
+ case CLOSE.TIMEOUT: case CLOSE.DATA_TIMEOUT: return E.TIMEOUT
87
+ case CLOSE.REFUSED: return E.REFUSED
88
+ case CLOSE.BLOCKED: return E.BLOCKED
89
+ case CLOSE.THROTTLED: return E.LIMIT
90
+ case CLOSE.AUTH_BAD_PASSWORD: case CLOSE.AUTH_BAD_SIGNATURE: case CLOSE.AUTH_REQUIRED: return E.GONE
91
+ default: return E.RESET
92
+ }
93
+ }
94
+
95
+ const KNOWN_RCODES = new Set([RCODE.SERVFAIL, RCODE.NXDOMAIN, RCODE.REFUSED]) // any other rcode from a relay is a failure of the lookup
96
+
97
+ const hexBytes = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16))
98
+
99
+ export class WispClient {
100
+ /**
101
+ * @param {object} o
102
+ * @param {string} o.url ws(s)://host:port/wisp/, 'local' or 'hosted'
103
+ * @param {string} [o.token] bundled relay: the session token (subprotocol "t.<token>")
104
+ * @param {string} [o.tokenUrl] fetch a fresh token before each open (JSON {token}), with ?instance=
105
+ * @param {{id: string, secret: string}} [o.key] an owner key (Wisp password auth)
106
+ * @param {string} [o.instance] this machine's id, 32 hex digits (kept across restores; plan §3)
107
+ * @param {[string, string][]} [o.names] the placeholder map so far (name, address), from the page
108
+ * @param {(name: string, ip: string) => void} [o.onName] a new placeholder (the page keeps the map)
109
+ * @param {(s: object) => void} [o.onStatus]
110
+ * @param {typeof WebSocket} [o.WebSocket]
111
+ * @param {(line: string) => void} [o.log]
112
+ */
113
+ constructor(o) {
114
+ this.url = relayUrl(o.url)
115
+ this.configured = o.url
116
+ this.token = o.token || ''
117
+ this.tokenUrl = o.tokenUrl || ''
118
+ this.key = o.key || null
119
+ this.instance = /^[0-9a-f]{32}$/.test(o.instance || '') ? o.instance : Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('')
120
+ this.WS = o.WebSocket || globalThis.WebSocket
121
+ this.log = o.log || (() => {})
122
+ this.now = o.now || (() => performance.now())
123
+ this.openTimeoutMs = o.openTimeoutMs || OPEN_TIMEOUT_MS
124
+ this.onName = o.onName || (() => {})
125
+ this.onStatus = o.onStatus || (() => {})
126
+ this.stack = null
127
+ this.ws = null
128
+ this.up = false
129
+ this.connecting = false
130
+ this.queue = [] // {kind: 'connect' | 'dns', id, ..., at} waiting for the socket
131
+ this.backoff = 0
132
+ this.nextAt = 0
133
+ this.timer = null
134
+ this.hs = null // the handshake's result: {version, buffer, server, common, motd}
135
+ this.server = null // the last relay's extensions (known after a first handshake)
136
+ this.streams = new Map() // id -> {confirmed, counter, held: [], ended, shut, owed}
137
+ this.held = 0 // bytes delivered without wmd-credit and not yet taken, all streams
138
+ this.names = new Map() // name -> ip
139
+ this.ips = new Map() // ip -> name
140
+ this.nextName = 1
141
+ this.lookups = new Map() // id -> {name, at} sent to the relay, not yet answered
142
+ for (const [n, ip] of o.names || []) if (this.names.size < NAME_CAP) this.#learn(n, ip)
143
+ this.status = null
144
+ this.stats = { opens: 0, openFailures: 0, drops: 0, sent: 0, received: 0, lastClose: null, lastError: null, v1Fallbacks: 0, downCapResets: 0, creditViolations: 0, malformed: 0, unknownPlaceholders: 0 }
145
+ if (!this.url) this.#status('unreachable', CLOSE.UNKNOWN, o.url === 'hosted' ? 'no hosted relay is configured' : 'no relay URL is configured')
146
+ }
147
+
148
+ attach(stack) {
149
+ this.stack = stack
150
+ }
151
+
152
+ #status(state, reason, words) {
153
+ let relay = ''
154
+ try {
155
+ relay = this.url ? new URL(this.url).origin.replace(/^http/, 'ws') : ''
156
+ } catch {}
157
+ this.status = { state, relay, reason: reason ?? 0, words: words || '' }
158
+ this.onStatus(this.status)
159
+ }
160
+
161
+ #learn(name, ip) {
162
+ // only an address of 198.18.0.0/15 that no other name has is a placeholder (a bad entry would move nextName out of range)
163
+ if (typeof name !== 'string' || typeof ip !== 'string' || !isPlaceholder(ip) || this.ips.has(ip) || this.names.has(name)) return
164
+ this.names.set(name, ip)
165
+ this.ips.set(ip, name)
166
+ const v = ipU32(ip)
167
+ if (v >= 0) this.nextName = Math.max(this.nextName, ((v - PH_BASE) >>> 0) + 1)
168
+ }
169
+
170
+ /** The placeholder for a name (made now if new); null when every address is used. */
171
+ #placeholder(name) {
172
+ let ip = this.names.get(name)
173
+ if (ip) return ip
174
+ if (this.names.size >= NAME_CAP || this.nextName >= 1 << 17) {
175
+ if (!this.namesFull) {
176
+ this.namesFull = true
177
+ this.log(`net: ${NAME_CAP} names looked up: no addresses left for new names until the machine restarts`)
178
+ this.#status('degraded', 0, `${NAME_CAP} names looked up: new names fail until the machine restarts`)
179
+ }
180
+ return null
181
+ }
182
+ ip = u32Ip((PH_BASE + this.nextName++) >>> 0)
183
+ this.#learn(name, ip)
184
+ this.onName(name, ip)
185
+ return ip
186
+ }
187
+
188
+ /** The placeholder map, for a checkpoint. */
189
+ nameList() {
190
+ return [...this.names]
191
+ }
192
+
193
+ #raw(b) {
194
+ this.stats.sent += b.length
195
+ this.ws.send(b)
196
+ }
197
+
198
+ /** Open the socket if it is not open (now, or when the backoff allows). */
199
+ wake() {
200
+ if (!this.url || this.up || this.connecting || this.timer) return
201
+ const wait = this.nextAt - this.now()
202
+ if (wait > 0) {
203
+ this.timer = setTimeout(() => {
204
+ this.timer = null
205
+ this.#open()
206
+ }, wait)
207
+ return
208
+ }
209
+ this.#open()
210
+ }
211
+
212
+ async #open() {
213
+ if (this.up || this.connecting) return
214
+ this.connecting = true
215
+ this.#status('connecting')
216
+ let token = this.token
217
+ try {
218
+ if (this.tokenUrl) {
219
+ const u = new URL(this.tokenUrl, globalThis.location?.href)
220
+ u.searchParams.set('instance', this.instance)
221
+ const r = await fetch(u, { cache: 'no-store', signal: AbortSignal.timeout(this.openTimeoutMs) })
222
+ if (!r.ok) throw new Error(`token: GET ${this.tokenUrl}: ${r.status}`)
223
+ token = (await r.json()).token
224
+ if (token !== undefined && typeof token !== 'string') throw new Error(`token: GET ${this.tokenUrl}: the answer has no text token`)
225
+ }
226
+ } catch (e) {
227
+ return this.#failed(e.message)
228
+ }
229
+ // B8: offer wisp-v2; a server that fails the WebSocket handshake for it gets one retry as
230
+ // Wisp v1 (no subprotocol). A token needs its subprotocol, so there is no retry with one.
231
+ this.#socket(token ? [SUBPROTOCOL, `t.${token}`] : [SUBPROTOCOL], !token)
232
+ }
233
+
234
+ #socket(protocols, mayRetry) {
235
+ let ws
236
+ try {
237
+ ws = protocols.length ? new this.WS(this.url, protocols) : new this.WS(this.url)
238
+ } catch (e) {
239
+ return this.#failed(e.message)
240
+ }
241
+ ws.binaryType = 'arraybuffer'
242
+ this.ws = ws
243
+ let opened = false
244
+ const hs = new ClientHandshake({
245
+ exts: [ext.confirm(), ext.wmdCredit(WINDOW), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance(hexBytes(this.instance))],
246
+ password: this.key ? { user: this.key.id, pass: this.key.secret } : null,
247
+ })
248
+ const openTimer = setTimeout(() => !this.up && this.ws === ws && ws.close(), this.openTimeoutMs)
249
+ let authFail = null
250
+ ws.onopen = () => {
251
+ if (this.ws === ws) opened = true
252
+ }
253
+ ws.onmessage = (e) => {
254
+ if (this.ws !== ws || typeof e.data === 'string') return
255
+ const u8 = new Uint8Array(e.data)
256
+ this.stats.received += u8.length
257
+ if (!this.up) {
258
+ const r = hs.step(parse(u8))
259
+ for (const b of r.send) this.#raw(b)
260
+ if (r.fail) {
261
+ authFail = r.fail
262
+ ws.close(1000, 'handshake failed')
263
+ } else if (r.done) {
264
+ clearTimeout(openTimer)
265
+ this.#ready(r.done)
266
+ }
267
+ return
268
+ }
269
+ this.#dispatch(parse(u8))
270
+ }
271
+ ws.onerror = () => {
272
+ this.stats.lastError = 'websocket error'
273
+ }
274
+ ws.onclose = (e) => {
275
+ clearTimeout(openTimer)
276
+ if (this.ws !== ws) return
277
+ const wasUp = this.up
278
+ this.ws = null
279
+ this.up = false
280
+ this.stats.lastClose = { code: e.code, reason: e.reason, at: Math.round(this.now()) }
281
+ if (wasUp) {
282
+ this.stats.drops++
283
+ this.streams.clear()
284
+ this.lookups.clear() // the stack fails its lookups in onRelayDown
285
+ this.held = 0
286
+ // a relay that closed us on purpose (maintenance, 1001; denied by its operator, 4011) is not back in a moment: wait the longest
287
+ this.backoff = e.code === CLOSE_MAINTENANCE || e.code === CLOSE_DENIED ? BACKOFF_MAX_MS : BACKOFF_MIN_MS
288
+ this.nextAt = this.now() + this.backoff
289
+ const why = `relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''})`
290
+ this.log(`net: the relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''}): every connection is reset`)
291
+ this.#status('down', 0, why)
292
+ this.stack?.onRelayDown(why)
293
+ return
294
+ }
295
+ if (!opened && mayRetry) {
296
+ this.stats.v1Fallbacks++
297
+ this.log('net: the relay refused the wisp-v2 subprotocol; trying Wisp v1')
298
+ return this.#socket([], false)
299
+ }
300
+ if (authFail) {
301
+ const st = ['auth-required', 'auth-refused', 'version'].includes(authFail.state) ? authFail.state : 'unreachable'
302
+ this.backoff = BACKOFF_MAX_MS // a wrong or missing key does not fix itself: no tight loop
303
+ return this.#failed(authFail.words, st, authFail.reason)
304
+ }
305
+ this.#failed(`could not connect to the relay (${e.code}${e.reason ? ` ${e.reason}` : ''})`)
306
+ }
307
+ }
308
+
309
+ #ready(done) {
310
+ this.connecting = false
311
+ this.up = true
312
+ this.backoff = 0
313
+ this.stats.opens++
314
+ this.hs = done
315
+ this.server = done.common
316
+ const motd = (done.motd || '').slice(0, MOTD_MAX)
317
+ const missing = Object.keys(DEGRADED).map(Number).filter((id) => !done.common.has(id))
318
+ if (missing.length) {
319
+ const words = `this relay does not support ${missing.map((id) => DEGRADED[id]).join(', ')} (Wisp ${done.version}${motd ? `; ${motd}` : ''})`
320
+ this.log(`net: ${words}`)
321
+ this.#status('degraded', 0, words)
322
+ } else this.#status('connected', 0, motd)
323
+ const q = this.queue
324
+ this.queue = []
325
+ for (const x of q) {
326
+ if (x.kind === 'connect') this.#connectNow(x.id, x.host, x.port)
327
+ else this.#dnsNow(x.id, x.name)
328
+ }
329
+ }
330
+
331
+ #failed(why, state = 'unreachable', reason = 0) {
332
+ this.connecting = false
333
+ this.ws = null
334
+ this.stats.openFailures++
335
+ this.stats.lastError = why
336
+ this.backoff = Math.min(Math.max(this.backoff * 2, BACKOFF_MIN_MS), BACKOFF_MAX_MS)
337
+ this.nextAt = this.now() + this.backoff
338
+ this.log(`net: ${why}; next try in ${this.backoff} ms`)
339
+ this.#status(state, reason, why)
340
+ const t = this.now()
341
+ const keep = []
342
+ const hopeless = state !== 'unreachable' // a missing or wrong key, or another protocol version, does not fix itself by waiting
343
+ for (const x of this.queue) {
344
+ if (!hopeless && t - x.at < QUEUE_MAX_MS) keep.push(x)
345
+ else if (x.kind === 'connect') this.stack?.onConnected(x.id, E.GONE, 0, why)
346
+ else this.stack?.onDnsReply(x.id, V1_RCODE.SERVFAIL, 0, [], why)
347
+ }
348
+ this.queue = keep
349
+ if (keep.length) this.wake()
350
+ }
351
+
352
+ // ---- packets from the relay ----
353
+
354
+ #dispatch(m) {
355
+ const s = this.stack
356
+ if (!s || m.error) {
357
+ if (m.error && (this.stats.malformed++ < MALFORMED_LOGGED || this.stats.malformed % 1000 === 0)) this.log(`net: a malformed packet from the relay (${m.error}); ignored (${this.stats.malformed} so far)`)
358
+ return
359
+ }
360
+ switch (m.type) {
361
+ case PT.CONTINUE: {
362
+ const st = this.streams.get(m.id)
363
+ if (!st) return
364
+ st.counter = m.n
365
+ if (!st.confirmed) {
366
+ st.confirmed = true
367
+ s.onConnected(m.id, E.OK, WINDOW, '')
368
+ }
369
+ this.#flush(m.id, st)
370
+ break
371
+ }
372
+ case PT.DATA: {
373
+ const st = this.streams.get(m.id)
374
+ if (!st || !m.payload.length) return
375
+ if (!st.confirmed) return this.#violation(m.id, st, E.BAD, 'data before the connection was confirmed')
376
+ if (this.server.has(EXT.WMD_CREDIT)) {
377
+ st.allow -= m.payload.length // the relay may send what the guest has taken so far, plus the window it was given
378
+ if (st.allow < -CREDIT_SLACK) return this.#violation(m.id, st, E.LIMIT, 'it sent more than the credit it was given')
379
+ } else {
380
+ st.owed += m.payload.length
381
+ this.held += m.payload.length
382
+ if (st.owed > DOWN_CAP || this.held > SESSION_CAP) {
383
+ this.stats.downCapResets++
384
+ const why = st.owed > DOWN_CAP ? `more than ${DOWN_CAP >> 20} MiB waiting for the guest on one connection` : `more than ${SESSION_CAP >> 20} MiB waiting for the guest`
385
+ this.log(`net: stream ${m.id}: ${why} (this relay has no download flow control): reset`)
386
+ this.#forget(m.id, st)
387
+ this.#raw(closePacket(m.id, CLOSE.CLIENT_ERROR))
388
+ s.onRelayAbort(m.id, E.LIMIT, why)
389
+ return
390
+ }
391
+ }
392
+ s.onRelayData(m.id, m.payload.slice())
393
+ break
394
+ }
395
+ case PT.SHUTDOWN: {
396
+ const st = this.streams.get(m.id)
397
+ if (!st || st.ended) return
398
+ st.ended = true
399
+ s.onRelayEnd(m.id)
400
+ break
401
+ }
402
+ case PT.CLOSE: {
403
+ const st = this.streams.get(m.id)
404
+ if (!st) return
405
+ this.#forget(m.id, st)
406
+ const words = closeWords(m.reason)
407
+ if (!st.confirmed) {
408
+ // unconfirmed (open confirmation) or opened on trust (no confirmation): a refused SYN either way
409
+ if (this.server.has(EXT.CONFIRM)) return s.onConnected(m.id, m.reason === CLOSE.VOLUNTARY ? E.RESET : eCode(m.reason), 0, words)
410
+ }
411
+ if (m.reason === CLOSE.VOLUNTARY) {
412
+ if (!st.ended) s.onRelayEnd(m.id) // the remote's FIN after its last byte
413
+ return
414
+ }
415
+ s.onRelayAbort(m.id, eCode(m.reason), words)
416
+ break
417
+ }
418
+ case PT.ANSWER: {
419
+ const d = this.lookups.get(m.id)
420
+ if (!d) return
421
+ this.lookups.delete(m.id)
422
+ const { addrs, reason } = answerAddrs(m, 1)
423
+ if (m.rcode !== RCODE.NOERROR) return s.onDnsReply(m.id, KNOWN_RCODES.has(m.rcode) ? m.rcode : RCODE.SERVFAIL, m.ttl, [], reason)
424
+ if (!addrs.length) return s.onDnsReply(m.id, RCODE.NOERROR, m.ttl, [], reason)
425
+ this.#answer(m.id, d.name, Math.min(m.ttl, NAME_TTL))
426
+ break
427
+ }
428
+ }
429
+ }
430
+
431
+ #answer(id, name, ttl) {
432
+ const ip = this.#placeholder(name)
433
+ if (!ip) return this.stack.onDnsReply(id, V1_RCODE.SERVFAIL, 0, [], 'no addresses left for new names')
434
+ const v = ipU32(ip)
435
+ this.stack.onDnsReply(id, RCODE.NOERROR, ttl, [Uint8Array.of(v >>> 24, (v >>> 16) & 255, (v >>> 8) & 255, v & 255)], '')
436
+ }
437
+
438
+ /** The relay broke the flow rules for a stream: reset it, in words. */
439
+ #violation(id, st, code, what) {
440
+ this.stats.creditViolations++
441
+ const why = `the relay broke the flow rules: ${what}`
442
+ this.log(`net: stream ${id}: ${why}: reset`)
443
+ this.#forget(id, st)
444
+ this.#raw(closePacket(id, CLOSE.CLIENT_ERROR))
445
+ this.stack.onRelayAbort(id, code, why)
446
+ }
447
+
448
+ #forget(id, st) {
449
+ this.streams.delete(id)
450
+ this.held -= st.owed
451
+ }
452
+
453
+ /** Send a stream's held packets while its counter allows; the stack gets their bytes back as credit. */
454
+ #flush(id, st) {
455
+ while (st.held.length && st.counter > 0) {
456
+ const b = st.held.shift()
457
+ st.counter--
458
+ this.#raw(dataPacket(id, b))
459
+ this.stack.onRelayCredit(id, b.length)
460
+ }
461
+ if (!st.held.length && st.shut && !st.shutSent) {
462
+ st.shutSent = true
463
+ this.#raw(shutdownPacket(id))
464
+ }
465
+ }
466
+
467
+ #connectNow(id, host, port) {
468
+ if (this.streams.has(id)) return
469
+ const st = { confirmed: false, counter: this.server.has(EXT.CONFIRM) ? 0 : this.hs.buffer, allow: WINDOW, held: [], ended: false, shut: false, shutSent: false, owed: 0 }
470
+ this.streams.set(id, st)
471
+ this.#raw(connectPacket(id, host, port))
472
+ if (!this.server.has(EXT.CONFIRM)) {
473
+ // no open confirmation: the stream is open on trust; a failure comes as a CLOSE (a RST after open)
474
+ st.confirmed = true
475
+ queueMicrotask(() => this.streams.get(id) === st && this.stack?.onConnected(id, E.OK, WINDOW, ''))
476
+ }
477
+ }
478
+
479
+ #dnsNow(id, name) {
480
+ if (!this.server.has(EXT.WMD_DNS)) return this.#answer(id, name, NAME_TTL)
481
+ if (this.lookups.size >= LOOKUPS_MAX) this.lookups.delete(this.lookups.keys().next().value) // the oldest: the stack gave up on it long ago
482
+ this.lookups.set(id, { name, at: this.now() })
483
+ this.#raw(lookupPacket(id, name, 1))
484
+ }
485
+
486
+ // ---- the stack's interface ----
487
+
488
+ connect(id, host, port) {
489
+ if (isPlaceholder(host)) {
490
+ const name = this.ips.get(host)
491
+ if (!name) {
492
+ this.stats.unknownPlaceholders++
493
+ const why = `${host} is a name placeholder this machine does not know (from before a restore without its names?)`
494
+ this.log(`net: ${why}: reset`)
495
+ return queueMicrotask(() => this.stack?.onConnected(id, E.UNREACHABLE, 0, why))
496
+ }
497
+ host = name
498
+ }
499
+ if (this.up) return this.#connectNow(id, host, port)
500
+ if (!this.url) return queueMicrotask(() => this.stack?.onConnected(id, E.UNREACHABLE, 0, this.status.words))
501
+ this.queue.push({ kind: 'connect', id, host, port, at: this.now() })
502
+ this.wake()
503
+ }
504
+
505
+ data(id, u8) {
506
+ const st = this.up && this.streams.get(id)
507
+ if (!st) return
508
+ st.held.push(u8)
509
+ this.#flush(id, st)
510
+ }
511
+
512
+ end(id) {
513
+ const st = this.up && this.streams.get(id)
514
+ if (!st || st.shut) return
515
+ if (!this.server.has(EXT.WMD_HALFCLOSE)) return // held until the remote closes (stated, plan §3)
516
+ st.shut = true
517
+ this.#flush(id, st)
518
+ }
519
+
520
+ abort(id) {
521
+ const st = this.up && this.streams.get(id)
522
+ if (!st) return
523
+ this.#forget(id, st)
524
+ this.#raw(closePacket(id, CLOSE.VOLUNTARY))
525
+ }
526
+
527
+ /** The guest took n bytes: the relay may send n more (wmd-credit), or they are no longer held here. */
528
+ credit(id, n) {
529
+ const st = this.up && this.streams.get(id)
530
+ if (!st || n <= 0) return
531
+ if (this.server.has(EXT.WMD_CREDIT)) {
532
+ st.allow += n
533
+ return this.#raw(creditPacket(id, n))
534
+ }
535
+ const k = Math.min(n, st.owed)
536
+ st.owed -= k
537
+ this.held -= k
538
+ }
539
+
540
+ /** A stream that ended cleanly on both sides (the stack's FIN exchange is done). */
541
+ closed(id) {
542
+ const st = this.streams.get(id)
543
+ if (st) this.#forget(id, st)
544
+ }
545
+
546
+ dns(id, name) {
547
+ name = String(name).toLowerCase().replace(/\.$/, '')
548
+ if (!validName(name)) return queueMicrotask(() => this.stack?.onDnsReply(id, RCODE.NXDOMAIN, 0, [], 'not a valid host name'))
549
+ if (this.names.has(name)) return queueMicrotask(() => this.stack && this.#answer(id, name, NAME_TTL))
550
+ if (this.up) return this.#dnsNow(id, name)
551
+ // a relay known to lack wmd-dns: answer without waking it (plan §4)
552
+ if (this.server && !this.server.has(EXT.WMD_DNS)) return queueMicrotask(() => this.stack && this.#answer(id, name, NAME_TTL))
553
+ if (!this.url) return queueMicrotask(() => this.stack?.onDnsReply(id, V1_RCODE.SERVFAIL, 0, [], this.status.words))
554
+ this.queue.push({ kind: 'dns', id, name, at: this.now() })
555
+ this.wake()
556
+ }
557
+
558
+ /** Test hook: drop the WebSocket as a network failure would. */
559
+ drop() {
560
+ this.ws?.close(4000, 'dropped by the page (test hook)')
561
+ }
562
+
563
+ snapshot() {
564
+ return {
565
+ protocol: 'wisp', url: this.url, up: this.up, connecting: this.connecting, status: this.status,
566
+ version: this.hs?.version ?? null, extensions: this.server ? [...this.server] : null, motd: this.hs?.motd ?? '',
567
+ streams: this.streams.size, names: this.names.size, ...this.stats,
568
+ }
569
+ }
570
+ }