@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/src/net/wisp.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// W59: the Wisp protocol (v2, github.com/MercuryWorkshop/wisp-protocol, CC-BY-4.0), shared by the
|
|
2
|
+
// browser's network Worker (wisp-client.js) and the relay server (web/relay/wisp.ts).
|
|
3
|
+
//
|
|
4
|
+
// One WebSocket carries every TCP stream of a guest. Binary messages only:
|
|
5
|
+
//
|
|
6
|
+
// type u8 | stream id u32 LE | payload (all integers little-endian; stream 0 = handshake)
|
|
7
|
+
//
|
|
8
|
+
// CONNECT 0x01 stream type u8 | port u16 | host utf8 client -> server, open a stream
|
|
9
|
+
// DATA 0x02 bytes both ways
|
|
10
|
+
// CONTINUE 0x03 buffer u32 server -> client: packets the server can
|
|
11
|
+
// buffer for the stream; it RESETS the
|
|
12
|
+
// client's counter (not added to it)
|
|
13
|
+
// CLOSE 0x04 reason u8 both ways, the stream is gone
|
|
14
|
+
// INFO 0x05 major u8 | minor u8 | extensions handshake, both ways (v2 only)
|
|
15
|
+
//
|
|
16
|
+
// An extension entry is id u8 | length u32 | metadata. A request without Sec-WebSocket-Protocol
|
|
17
|
+
// is Wisp v1: the server skips INFO and sends CONTINUE on stream 0 at once.
|
|
18
|
+
//
|
|
19
|
+
// wmd's private extensions (docs/wisp-wmd-extensions.md), used only when both sides list them:
|
|
20
|
+
// 0xE1 wmd-credit packet 0xE0 CREDIT n u32 client -> server: the server may send
|
|
21
|
+
// n more bytes on the stream (download flow)
|
|
22
|
+
// 0xE2 wmd-halfclose packet 0xE3 SHUTDOWN - either way: no more data from the sender
|
|
23
|
+
// (a FIN), the other direction stays open
|
|
24
|
+
// 0xE4 wmd-dns packet 0xE5 LOOKUP qtype u16 | name utf8 client -> server
|
|
25
|
+
// packet 0xE6 ANSWER rcode u8 | ttl u32 | count u8 | addresses | reason utf8
|
|
26
|
+
// 0xE7 wmd-instance (no packet) client metadata: a 16-byte instance id
|
|
27
|
+
|
|
28
|
+
export const SUBPROTOCOL = 'wisp-v2'
|
|
29
|
+
export const VERSION = { major: 2, minor: 0 }
|
|
30
|
+
/** The wmd private extensions' own version, in each one's metadata byte 0. */
|
|
31
|
+
export const WMD_EXT_VERSION = 1
|
|
32
|
+
export const INSTANCE_BYTES = 16
|
|
33
|
+
|
|
34
|
+
export const PT = {
|
|
35
|
+
CONNECT: 0x01, DATA: 0x02, CONTINUE: 0x03, CLOSE: 0x04, INFO: 0x05,
|
|
36
|
+
CREDIT: 0xe0, SHUTDOWN: 0xe3, LOOKUP: 0xe5, ANSWER: 0xe6,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const EXT = {
|
|
40
|
+
UDP: 0x01, PASSWORD: 0x02, KEY: 0x03, MOTD: 0x04, CONFIRM: 0x05,
|
|
41
|
+
WMD_CREDIT: 0xe1, WMD_HALFCLOSE: 0xe2, WMD_DNS: 0xe4, WMD_INSTANCE: 0xe7,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const STREAM = { TCP: 0x01, UDP: 0x02 }
|
|
45
|
+
|
|
46
|
+
/** CLOSE reasons (spec, plus the extension-specified auth reasons). */
|
|
47
|
+
export const CLOSE = {
|
|
48
|
+
UNKNOWN: 0x01, VOLUNTARY: 0x02, NETWORK: 0x03, INCOMPATIBLE: 0x04,
|
|
49
|
+
INVALID: 0x41, UNREACHABLE: 0x42, TIMEOUT: 0x43, REFUSED: 0x44,
|
|
50
|
+
DATA_TIMEOUT: 0x47, BLOCKED: 0x48, THROTTLED: 0x49,
|
|
51
|
+
CLIENT_ERROR: 0x81,
|
|
52
|
+
AUTH_BAD_PASSWORD: 0xc0, AUTH_BAD_SIGNATURE: 0xc1, AUTH_REQUIRED: 0xc2,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CLOSE_WORDS = {
|
|
56
|
+
0x01: 'closed for an unknown reason',
|
|
57
|
+
0x02: 'closed by the other side',
|
|
58
|
+
0x03: 'closed by a network error',
|
|
59
|
+
0x04: 'the relay and this page support no common protocol extensions',
|
|
60
|
+
0x41: 'the relay refused the destination as invalid',
|
|
61
|
+
0x42: 'the destination is unreachable or its name did not resolve',
|
|
62
|
+
0x43: 'the destination did not answer in time',
|
|
63
|
+
0x44: 'the destination refused the connection',
|
|
64
|
+
0x47: 'the connection was idle too long',
|
|
65
|
+
0x48: 'the relay blocks this destination',
|
|
66
|
+
0x49: 'the relay limited this connection (too many, too fast or too much data)',
|
|
67
|
+
0x81: 'the page could not take more data',
|
|
68
|
+
0xc0: 'the relay refused the key (wrong id or secret)',
|
|
69
|
+
0xc1: 'the relay refused the key signature',
|
|
70
|
+
0xc2: 'the relay needs a key and none was given',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A close reason in words, for logs and the page's status. */
|
|
74
|
+
export function closeWords(reason) {
|
|
75
|
+
return CLOSE_WORDS[reason] || `closed (reason 0x${(reason >>> 0).toString(16)})`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** DNS rcodes in ANSWER (RFC 1035), the same as wmd-relay.v1. */
|
|
79
|
+
export const RCODE = { NOERROR: 0, SERVFAIL: 2, NXDOMAIN: 3, REFUSED: 5 }
|
|
80
|
+
|
|
81
|
+
const enc = new TextEncoder()
|
|
82
|
+
const dec = new TextDecoder('utf-8', { fatal: true })
|
|
83
|
+
|
|
84
|
+
export function packet(type, id, payload) {
|
|
85
|
+
const n = payload ? payload.length : 0
|
|
86
|
+
const b = new Uint8Array(5 + n)
|
|
87
|
+
b[0] = type
|
|
88
|
+
new DataView(b.buffer).setUint32(1, id >>> 0, true)
|
|
89
|
+
if (n) b.set(payload, 5)
|
|
90
|
+
return b
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function u32(n) {
|
|
94
|
+
const p = new Uint8Array(4)
|
|
95
|
+
new DataView(p.buffer).setUint32(0, n >>> 0, true)
|
|
96
|
+
return p
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function connectPacket(id, host, port, type = STREAM.TCP) {
|
|
100
|
+
const h = enc.encode(host)
|
|
101
|
+
const p = new Uint8Array(3 + h.length)
|
|
102
|
+
p[0] = type
|
|
103
|
+
p[1] = port & 255
|
|
104
|
+
p[2] = (port >> 8) & 255
|
|
105
|
+
p.set(h, 3)
|
|
106
|
+
return packet(PT.CONNECT, id, p)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const dataPacket = (id, bytes) => packet(PT.DATA, id, bytes)
|
|
110
|
+
export const continuePacket = (id, buffer) => packet(PT.CONTINUE, id, u32(buffer))
|
|
111
|
+
export const closePacket = (id, reason) => packet(PT.CLOSE, id, Uint8Array.of(reason & 255))
|
|
112
|
+
export const creditPacket = (id, n) => packet(PT.CREDIT, id, u32(n))
|
|
113
|
+
export const shutdownPacket = (id) => packet(PT.SHUTDOWN, id)
|
|
114
|
+
|
|
115
|
+
export function lookupPacket(id, name, qtype) {
|
|
116
|
+
const h = enc.encode(name)
|
|
117
|
+
const p = new Uint8Array(2 + h.length)
|
|
118
|
+
p[0] = qtype & 255
|
|
119
|
+
p[1] = (qtype >> 8) & 255
|
|
120
|
+
p.set(h, 2)
|
|
121
|
+
return packet(PT.LOOKUP, id, p)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** addrs: arrays of 4 (A) or 16 (AAAA) bytes. */
|
|
125
|
+
export function answerPacket(id, rcode, ttl, addrs = [], reason = '') {
|
|
126
|
+
const d = enc.encode(reason)
|
|
127
|
+
const alen = addrs.reduce((s, a) => s + a.length, 0)
|
|
128
|
+
const p = new Uint8Array(6 + alen + d.length)
|
|
129
|
+
p[0] = rcode
|
|
130
|
+
new DataView(p.buffer).setUint32(1, ttl >>> 0, true)
|
|
131
|
+
p[5] = addrs.length
|
|
132
|
+
let o = 6
|
|
133
|
+
for (const a of addrs) {
|
|
134
|
+
p.set(a, o)
|
|
135
|
+
o += a.length
|
|
136
|
+
}
|
|
137
|
+
p.set(d, o)
|
|
138
|
+
return packet(PT.ANSWER, id, p)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** exts: [{id, data?: Uint8Array}] */
|
|
142
|
+
export function infoPacket(exts = [], version = VERSION) {
|
|
143
|
+
const len = exts.reduce((s, e) => s + 5 + (e.data ? e.data.length : 0), 0)
|
|
144
|
+
const p = new Uint8Array(2 + len)
|
|
145
|
+
const dv = new DataView(p.buffer)
|
|
146
|
+
p[0] = version.major
|
|
147
|
+
p[1] = version.minor
|
|
148
|
+
let o = 2
|
|
149
|
+
for (const e of exts) {
|
|
150
|
+
const d = e.data || new Uint8Array(0)
|
|
151
|
+
p[o] = e.id
|
|
152
|
+
dv.setUint32(o + 1, d.length, true)
|
|
153
|
+
p.set(d, o + 5)
|
|
154
|
+
o += 5 + d.length
|
|
155
|
+
}
|
|
156
|
+
return packet(PT.INFO, 0, p)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Extension metadata ------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export const ext = {
|
|
162
|
+
passwordServer: (required) => ({ id: EXT.PASSWORD, data: Uint8Array.of(required ? 1 : 0) }),
|
|
163
|
+
passwordClient(user, pass) {
|
|
164
|
+
const u = enc.encode(user)
|
|
165
|
+
const w = enc.encode(pass)
|
|
166
|
+
if (u.length > 255) throw new RangeError('wisp: a key id is at most 255 bytes')
|
|
167
|
+
const d = new Uint8Array(1 + u.length + w.length)
|
|
168
|
+
d[0] = u.length
|
|
169
|
+
d.set(u, 1)
|
|
170
|
+
d.set(w, 1 + u.length)
|
|
171
|
+
return { id: EXT.PASSWORD, data: d }
|
|
172
|
+
},
|
|
173
|
+
motd: (text) => ({ id: EXT.MOTD, data: enc.encode(text) }),
|
|
174
|
+
confirm: () => ({ id: EXT.CONFIRM }),
|
|
175
|
+
/** window: the bytes the server may send on each new stream before the first CREDIT. */
|
|
176
|
+
wmdCredit(window) {
|
|
177
|
+
const d = new Uint8Array(5)
|
|
178
|
+
d[0] = WMD_EXT_VERSION
|
|
179
|
+
new DataView(d.buffer).setUint32(1, window >>> 0, true)
|
|
180
|
+
return { id: EXT.WMD_CREDIT, data: d }
|
|
181
|
+
},
|
|
182
|
+
wmdHalfclose: () => ({ id: EXT.WMD_HALFCLOSE, data: Uint8Array.of(WMD_EXT_VERSION) }),
|
|
183
|
+
wmdDns: () => ({ id: EXT.WMD_DNS, data: Uint8Array.of(WMD_EXT_VERSION) }),
|
|
184
|
+
/** The client sends its 16-byte id; the server lists the extension with no id. */
|
|
185
|
+
wmdInstance(idBytes) {
|
|
186
|
+
if (!idBytes) return { id: EXT.WMD_INSTANCE, data: Uint8Array.of(WMD_EXT_VERSION) }
|
|
187
|
+
if (idBytes.length !== INSTANCE_BYTES) throw new RangeError('wisp: an instance id is 16 bytes')
|
|
188
|
+
const d = new Uint8Array(1 + INSTANCE_BYTES)
|
|
189
|
+
d[0] = WMD_EXT_VERSION
|
|
190
|
+
d.set(idBytes, 1)
|
|
191
|
+
return { id: EXT.WMD_INSTANCE, data: d }
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read one extension's metadata into fields. Returns {error} when it is malformed.
|
|
197
|
+
* `from` is 'server' or 'client' (the password and instance formats differ by side).
|
|
198
|
+
*/
|
|
199
|
+
export function readExt(e, from) {
|
|
200
|
+
const d = e.data
|
|
201
|
+
switch (e.id) {
|
|
202
|
+
case EXT.PASSWORD:
|
|
203
|
+
if (from === 'server') {
|
|
204
|
+
if (d.length < 1) return { error: 'password extension without its flag' }
|
|
205
|
+
return { required: d[0] !== 0 }
|
|
206
|
+
} else {
|
|
207
|
+
if (d.length < 1 || d.length < 1 + d[0]) return { error: 'password extension too short' }
|
|
208
|
+
const user = text(d.subarray(1, 1 + d[0]))
|
|
209
|
+
const pass = text(d.subarray(1 + d[0]))
|
|
210
|
+
if (user === null || pass === null) return { error: 'password extension is not UTF-8' }
|
|
211
|
+
return { user, pass }
|
|
212
|
+
}
|
|
213
|
+
case EXT.KEY:
|
|
214
|
+
if (from === 'server') {
|
|
215
|
+
if (d.length < 2) return { error: 'key extension too short' }
|
|
216
|
+
return { required: d[0] !== 0, algorithms: d[1], challenge: d.subarray(2) }
|
|
217
|
+
}
|
|
218
|
+
return { raw: d }
|
|
219
|
+
case EXT.MOTD: {
|
|
220
|
+
const t = text(d)
|
|
221
|
+
return t === null ? { error: 'MOTD is not UTF-8' } : { motd: t }
|
|
222
|
+
}
|
|
223
|
+
case EXT.WMD_CREDIT:
|
|
224
|
+
if (d.length < 1) return { error: 'wmd-credit without a version' }
|
|
225
|
+
if (from === 'client') {
|
|
226
|
+
if (d.length < 5) return { error: 'wmd-credit without a window' }
|
|
227
|
+
return { version: d[0], window: new DataView(d.buffer, d.byteOffset, d.byteLength).getUint32(1, true) }
|
|
228
|
+
}
|
|
229
|
+
return { version: d[0] }
|
|
230
|
+
case EXT.WMD_HALFCLOSE:
|
|
231
|
+
case EXT.WMD_DNS:
|
|
232
|
+
if (d.length < 1) return { error: 'wmd extension without a version' }
|
|
233
|
+
return { version: d[0] }
|
|
234
|
+
case EXT.WMD_INSTANCE:
|
|
235
|
+
if (d.length < 1) return { error: 'wmd-instance without a version' }
|
|
236
|
+
if (from === 'client') {
|
|
237
|
+
// a later version may append fields: read the 16 bytes version 1 defines
|
|
238
|
+
if (d.length < 1 + INSTANCE_BYTES) return { error: 'wmd-instance id is shorter than 16 bytes' }
|
|
239
|
+
return { version: d[0], instance: d.slice(1, 1 + INSTANCE_BYTES) }
|
|
240
|
+
}
|
|
241
|
+
return { version: d[0] }
|
|
242
|
+
default:
|
|
243
|
+
return {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function text(b) {
|
|
248
|
+
try {
|
|
249
|
+
return dec.decode(b)
|
|
250
|
+
} catch {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse one packet (a Uint8Array or an ArrayBuffer). Never throws: returns {error, type?, id?}
|
|
257
|
+
* when the packet is malformed.
|
|
258
|
+
* Unknown packet types parse with just type, id and payload (the caller ignores or refuses them).
|
|
259
|
+
*/
|
|
260
|
+
export function parse(u8) {
|
|
261
|
+
if (u8 instanceof ArrayBuffer) u8 = new Uint8Array(u8)
|
|
262
|
+
if (!(u8 instanceof Uint8Array)) return { error: 'not a binary packet' }
|
|
263
|
+
if (u8.length < 5) return { error: 'packet shorter than 5 bytes' }
|
|
264
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength)
|
|
265
|
+
const type = u8[0]
|
|
266
|
+
const id = dv.getUint32(1, true)
|
|
267
|
+
const p = u8.subarray(5)
|
|
268
|
+
const m = { type, id, payload: p }
|
|
269
|
+
const bad = (error) => ({ error, type, id })
|
|
270
|
+
switch (type) {
|
|
271
|
+
case PT.CONNECT: {
|
|
272
|
+
if (p.length < 3) return bad('CONNECT too short')
|
|
273
|
+
m.streamType = p[0]
|
|
274
|
+
m.port = p[1] | (p[2] << 8)
|
|
275
|
+
const h = text(p.subarray(3))
|
|
276
|
+
if (h === null) return bad('CONNECT host is not UTF-8')
|
|
277
|
+
m.host = h
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
case PT.CONTINUE: // as wisp-mux: at least 4 bytes, the rest ignored
|
|
281
|
+
if (p.length < 4) return bad('CONTINUE payload shorter than 4 bytes')
|
|
282
|
+
m.n = dv.getUint32(5, true)
|
|
283
|
+
break
|
|
284
|
+
case PT.CREDIT:
|
|
285
|
+
if (p.length !== 4) return bad('CREDIT payload is not 4 bytes')
|
|
286
|
+
m.n = dv.getUint32(5, true)
|
|
287
|
+
break
|
|
288
|
+
case PT.CLOSE:
|
|
289
|
+
if (p.length < 1) return bad('CLOSE without a reason')
|
|
290
|
+
m.reason = p[0]
|
|
291
|
+
break
|
|
292
|
+
case PT.SHUTDOWN:
|
|
293
|
+
if (p.length !== 0) return bad('SHUTDOWN with a payload')
|
|
294
|
+
break
|
|
295
|
+
case PT.LOOKUP: {
|
|
296
|
+
if (p.length < 3) return bad('LOOKUP too short')
|
|
297
|
+
m.qtype = p[0] | (p[1] << 8)
|
|
298
|
+
const n = text(p.subarray(2))
|
|
299
|
+
if (n === null) return bad('LOOKUP name is not UTF-8')
|
|
300
|
+
m.name = n
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
case PT.ANSWER:
|
|
304
|
+
if (p.length < 6) return bad('ANSWER too short')
|
|
305
|
+
m.rcode = p[0]
|
|
306
|
+
m.ttl = dv.getUint32(6, true)
|
|
307
|
+
m.count = p[5]
|
|
308
|
+
m.rest = p.subarray(6) // the addresses (their size depends on the query), then the reason
|
|
309
|
+
break
|
|
310
|
+
case PT.INFO: {
|
|
311
|
+
if (p.length < 2) return bad('INFO too short')
|
|
312
|
+
m.version = { major: p[0], minor: p[1] }
|
|
313
|
+
m.exts = []
|
|
314
|
+
let o = 2
|
|
315
|
+
// as wisp-mux: fewer than 5 trailing bytes are ignored
|
|
316
|
+
while (p.length - o >= 5) {
|
|
317
|
+
const eid = p[o]
|
|
318
|
+
const len = dv.getUint32(5 + o + 1, true)
|
|
319
|
+
if (len > p.length - o - 5) return bad('INFO extension longer than the packet')
|
|
320
|
+
m.exts.push({ id: eid, data: p.subarray(o + 5, o + 5 + len) })
|
|
321
|
+
o += 5 + len
|
|
322
|
+
}
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return m
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The addresses of an ANSWER for a query of qtype (1: 4 bytes each, 28: 16). */
|
|
330
|
+
export function answerAddrs(m, qtype) {
|
|
331
|
+
const size = qtype === 28 ? 16 : 4
|
|
332
|
+
const addrs = []
|
|
333
|
+
for (let i = 0; i < m.count; i++) {
|
|
334
|
+
const a = m.rest.subarray(i * size, (i + 1) * size)
|
|
335
|
+
if (a.length !== size) return { addrs, reason: 'short answer' }
|
|
336
|
+
addrs.push(a)
|
|
337
|
+
}
|
|
338
|
+
return { addrs, reason: text(m.rest.subarray(m.count * size)) ?? '' }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Handshakes -------------------------------------------------------------------------------
|
|
342
|
+
//
|
|
343
|
+
// Both machines are pure: feed packets in, get packets to send and an outcome out. The caller
|
|
344
|
+
// owns the WebSocket and the timers.
|
|
345
|
+
|
|
346
|
+
const WMD_EXTS = new Set([EXT.WMD_CREDIT, EXT.WMD_HALFCLOSE, EXT.WMD_DNS, EXT.WMD_INSTANCE])
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The client side. `exts` are the extensions the client offers (built with `ext.*`, without
|
|
350
|
+
* password auth); `password` is {user, pass} or null.
|
|
351
|
+
*
|
|
352
|
+
* step(pkt) returns {send: Uint8Array[], done?: {version: 1|2, buffer, server: Map, common: Set,
|
|
353
|
+
* motd}, fail?: {reason, words, state}}. state is one of 'auth-required', 'auth-refused',
|
|
354
|
+
* 'incompatible', 'protocol'.
|
|
355
|
+
*/
|
|
356
|
+
export class ClientHandshake {
|
|
357
|
+
constructor({ exts = [], password = null } = {}) {
|
|
358
|
+
this.exts = exts
|
|
359
|
+
this.password = password
|
|
360
|
+
this.state = 'wait-info'
|
|
361
|
+
this.server = null
|
|
362
|
+
this.version = 1
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
step(m) {
|
|
366
|
+
const fail = (state, reason, words, send = []) => {
|
|
367
|
+
this.state = 'failed'
|
|
368
|
+
return { send, fail: { state, reason, words } }
|
|
369
|
+
}
|
|
370
|
+
if (m.error) return fail('protocol', CLOSE.UNKNOWN, `the relay sent a malformed packet: ${m.error}`)
|
|
371
|
+
if (this.state === 'wait-info') {
|
|
372
|
+
if (m.type === PT.CONTINUE && m.id === 0) {
|
|
373
|
+
this.state = 'done'
|
|
374
|
+
return { send: [], done: { version: 1, buffer: m.n, server: new Map(), common: new Set(), motd: '' } }
|
|
375
|
+
}
|
|
376
|
+
if (m.type === PT.CLOSE && m.id === 0) return fail(closeState(m.reason), m.reason, closeWords(m.reason))
|
|
377
|
+
if (m.type !== PT.INFO || m.id !== 0) return fail('protocol', CLOSE.UNKNOWN, 'the relay did not start with INFO or CONTINUE')
|
|
378
|
+
// A newer relay is asked for v2 (the INFO reply carries the lower version); an older one fails.
|
|
379
|
+
if (m.version.major < 2) {
|
|
380
|
+
const words = `the relay speaks Wisp ${m.version.major}.${m.version.minor}; this page speaks Wisp 2`
|
|
381
|
+
return fail('version', CLOSE.INCOMPATIBLE, words, [closePacket(0, CLOSE.INCOMPATIBLE)])
|
|
382
|
+
}
|
|
383
|
+
this.server = new Map()
|
|
384
|
+
for (const e of m.exts) {
|
|
385
|
+
const r = readExt(e, 'server')
|
|
386
|
+
if (r.error) return fail('protocol', CLOSE.UNKNOWN, `the relay sent a malformed extension: ${r.error}`)
|
|
387
|
+
this.server.set(e.id, r)
|
|
388
|
+
}
|
|
389
|
+
const pw = this.server.get(EXT.PASSWORD)
|
|
390
|
+
const keyx = this.server.get(EXT.KEY)
|
|
391
|
+
// Only password auth is supported: it satisfies the relay when the relay lists it and does
|
|
392
|
+
// not require a signature key alone (both required: the client may choose, per the spec).
|
|
393
|
+
const keyOnly = keyx && keyx.required && !(pw && pw.required)
|
|
394
|
+
if (keyOnly) {
|
|
395
|
+
return fail('auth-required', CLOSE.AUTH_REQUIRED, 'the relay needs a signature key, which this page does not support', [closePacket(0, CLOSE.AUTH_REQUIRED)])
|
|
396
|
+
}
|
|
397
|
+
if (pw && pw.required && !this.password) {
|
|
398
|
+
return fail('auth-required', CLOSE.AUTH_REQUIRED, closeWords(CLOSE.AUTH_REQUIRED), [closePacket(0, CLOSE.AUTH_REQUIRED)])
|
|
399
|
+
}
|
|
400
|
+
if (pw && this.password) {
|
|
401
|
+
const { user, pass } = this.password
|
|
402
|
+
if (typeof user !== 'string' || typeof pass !== 'string' || enc.encode(user).length > 255) {
|
|
403
|
+
return fail('auth-refused', CLOSE.AUTH_BAD_PASSWORD, 'the key is malformed (its id must be text of at most 255 bytes)', [closePacket(0, CLOSE.AUTH_BAD_PASSWORD)])
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const mine = this.exts.filter((e) => this.server.has(e.id))
|
|
407
|
+
if (pw && this.password) mine.push(ext.passwordClient(this.password.user, this.password.pass))
|
|
408
|
+
this.state = 'wait-continue'
|
|
409
|
+
this.version = 2
|
|
410
|
+
const minor = m.version.major === 2 ? Math.min(m.version.minor, VERSION.minor) : VERSION.minor
|
|
411
|
+
return { send: [infoPacket(mine, { major: 2, minor })] }
|
|
412
|
+
}
|
|
413
|
+
if (this.state === 'wait-continue') {
|
|
414
|
+
if (m.id !== 0) return fail('protocol', CLOSE.UNKNOWN, 'the relay sent a stream packet before the handshake ended')
|
|
415
|
+
if (m.type === PT.CLOSE) return fail(closeState(m.reason), m.reason, closeWords(m.reason))
|
|
416
|
+
if (m.type !== PT.CONTINUE) return fail('protocol', CLOSE.UNKNOWN, 'the relay did not answer INFO with CONTINUE')
|
|
417
|
+
this.state = 'done'
|
|
418
|
+
const common = new Set(this.exts.filter((e) => this.server.has(e.id)).map((e) => e.id))
|
|
419
|
+
const motd = this.server.get(EXT.MOTD)?.motd || ''
|
|
420
|
+
return { send: [], done: { version: 2, buffer: m.n, server: this.server, common, motd } }
|
|
421
|
+
}
|
|
422
|
+
return fail('protocol', CLOSE.UNKNOWN, 'handshake packet after the handshake')
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function closeState(reason) {
|
|
427
|
+
if (reason === CLOSE.AUTH_REQUIRED) return 'auth-required'
|
|
428
|
+
if (reason === CLOSE.AUTH_BAD_PASSWORD || reason === CLOSE.AUTH_BAD_SIGNATURE) return 'auth-refused'
|
|
429
|
+
if (reason === CLOSE.INCOMPATIBLE) return 'incompatible'
|
|
430
|
+
return 'protocol'
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The server side. v2 is whether the request carried Sec-WebSocket-Protocol. `exts` are the
|
|
435
|
+
* extensions the server lists (built with `ext.*`, including `ext.passwordServer` when keys are
|
|
436
|
+
* on). start() returns the first packets; step(pkt) returns {send, client?} where `client` is the
|
|
437
|
+
* parsed client INFO ({exts: Map, common: Set, password, instance, version}) for the caller to
|
|
438
|
+
* accept() or reject(reason). For v1 the handshake is done after start().
|
|
439
|
+
*/
|
|
440
|
+
export class ServerHandshake {
|
|
441
|
+
constructor({ v2 = true, exts = [], buffer = 32 } = {}) {
|
|
442
|
+
this.v2 = v2
|
|
443
|
+
this.exts = exts
|
|
444
|
+
this.buffer = buffer
|
|
445
|
+
this.state = 'new'
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
start() {
|
|
449
|
+
if (!this.v2) {
|
|
450
|
+
this.state = 'done'
|
|
451
|
+
return { send: [continuePacket(0, this.buffer)], done: { version: 1, common: new Set() } }
|
|
452
|
+
}
|
|
453
|
+
this.state = 'wait-info'
|
|
454
|
+
return { send: [infoPacket(this.exts)] }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
step(m) {
|
|
458
|
+
if (this.state !== 'wait-info') return { send: [], error: 'packet out of handshake order' }
|
|
459
|
+
if (m.error) return this.#reject(CLOSE.UNKNOWN, `malformed packet: ${m.error}`)
|
|
460
|
+
if (m.id !== 0) return this.#reject(CLOSE.UNKNOWN, 'stream packet before the handshake ended')
|
|
461
|
+
if (m.type === PT.CLOSE) {
|
|
462
|
+
this.state = 'failed'
|
|
463
|
+
return { send: [], error: `the client closed the handshake: ${closeWords(m.reason)}`, reason: m.reason }
|
|
464
|
+
}
|
|
465
|
+
if (m.type !== PT.INFO) return this.#reject(CLOSE.UNKNOWN, 'expected the client INFO')
|
|
466
|
+
if (m.version.major < 2) return this.#reject(CLOSE.INCOMPATIBLE, `the client speaks Wisp ${m.version.major}`)
|
|
467
|
+
const mine = new Set(this.exts.map((e) => e.id))
|
|
468
|
+
const exts = new Map()
|
|
469
|
+
for (const e of m.exts) {
|
|
470
|
+
const r = readExt(e, 'client')
|
|
471
|
+
if (r.error) return this.#reject(CLOSE.UNKNOWN, `malformed client extension 0x${e.id.toString(16)}: ${r.error}`)
|
|
472
|
+
exts.set(e.id, r)
|
|
473
|
+
}
|
|
474
|
+
const common = new Set([...exts.keys()].filter((id) => mine.has(id) && id !== EXT.PASSWORD))
|
|
475
|
+
const pw = exts.get(EXT.PASSWORD)
|
|
476
|
+
const pwRequired = this.exts.some((e) => e.id === EXT.PASSWORD && e.data && e.data[0])
|
|
477
|
+
const keyRequired = this.exts.some((e) => e.id === EXT.KEY && e.data && e.data[0])
|
|
478
|
+
// a required method the client did not use is refused here, never left to the caller
|
|
479
|
+
if ((pwRequired || keyRequired) && !(pw && mine.has(EXT.PASSWORD))) return this.#reject(CLOSE.AUTH_REQUIRED, 'the client sent no key')
|
|
480
|
+
this.state = 'decide'
|
|
481
|
+
return {
|
|
482
|
+
send: [],
|
|
483
|
+
client: {
|
|
484
|
+
version: m.version,
|
|
485
|
+
exts,
|
|
486
|
+
common,
|
|
487
|
+
password: pw && mine.has(EXT.PASSWORD) ? { user: pw.user, pass: pw.pass } : null,
|
|
488
|
+
instance: common.has(EXT.WMD_INSTANCE) ? exts.get(EXT.WMD_INSTANCE).instance : null,
|
|
489
|
+
window: common.has(EXT.WMD_CREDIT) ? exts.get(EXT.WMD_CREDIT).window : 0,
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
accept() {
|
|
495
|
+
this.state = 'done'
|
|
496
|
+
return [continuePacket(0, this.buffer)]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
reject(reason) {
|
|
500
|
+
this.state = 'failed'
|
|
501
|
+
return [closePacket(0, reason)]
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#reject(reason, error) {
|
|
505
|
+
this.state = 'failed'
|
|
506
|
+
return { send: [closePacket(0, reason)], error }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** True for the wmd private extension ids (a peer that did not list one never gets its packets). */
|
|
511
|
+
export const isWmdExt = (id) => WMD_EXTS.has(id)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// P-B7: the page's side of the pack host and the registry proxy.
|
|
2
|
+
//
|
|
3
|
+
// const idx = await getImage('busybox', {pull: 'auto', onEvent}) // the image's index
|
|
4
|
+
// const packs = await fetchPacks(idx) // its packs, verified
|
|
5
|
+
//
|
|
6
|
+
// getImage asks the host for the image's index (/idx/...; through the service worker, so a
|
|
7
|
+
// cached index answers when the host is gone). When the host has none (404) and `pull`
|
|
8
|
+
// allows it, it asks the proxy to pull it (POST /pull, NDJSON events) and uses the index the
|
|
9
|
+
// pull ends with. fetchPacks downloads each pack (/blobs/sha256/<hex>) and checks that its
|
|
10
|
+
// bytes hash to its name.
|
|
11
|
+
|
|
12
|
+
import { blobPath, checkImageIndex, DEFAULT_PLATFORM, indexPath, parseRef } from './ref.js'
|
|
13
|
+
|
|
14
|
+
export class PullError extends Error {
|
|
15
|
+
constructor(message, kind) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'PullError'
|
|
18
|
+
this.kind = kind // 'unreachable' | 'interrupted' | 'refused' | 'registry' | 'not-found' | 'verify'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
23
|
+
|
|
24
|
+
/** The image's index for a platform: from the host, else (pull 'auto'/'always') through the proxy. */
|
|
25
|
+
export async function getImage(ref, { platform = DEFAULT_PLATFORM, pull = 'auto', base = '', onEvent = () => {}, signal } = {}) {
|
|
26
|
+
const r = parseRef(ref)
|
|
27
|
+
if (pull !== 'always') {
|
|
28
|
+
let res = null
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(`${base}/${indexPath(r, platform)}`, { cache: 'no-store', signal })
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (pull === 'never') throw new PullError(`${r.ref}: the host is unreachable and the index is not cached (${e.message})`, 'unreachable')
|
|
33
|
+
}
|
|
34
|
+
if (res?.ok) {
|
|
35
|
+
onEvent({ t: 'index', from: 'host' })
|
|
36
|
+
return checkImageIndex(await res.json())
|
|
37
|
+
}
|
|
38
|
+
if (res && res.status !== 404 && res.status !== 504) throw new PullError(`${r.ref}: the host answered ${res.status} for its index`, 'registry')
|
|
39
|
+
if (pull === 'never') throw new PullError(`${r.ref} (${platform}): not on this host`, 'not-found')
|
|
40
|
+
}
|
|
41
|
+
return pullThroughProxy(r.ref, { platform, base, onEvent, signal })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** POST /pull and follow its events to the index (or a PullError). */
|
|
45
|
+
export async function pullThroughProxy(ref, { platform = DEFAULT_PLATFORM, base = '', onEvent = () => {}, signal } = {}) {
|
|
46
|
+
let res
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(`${base}/pull?ref=${encodeURIComponent(ref)}&platform=${encodeURIComponent(platform)}`, { method: 'POST', cache: 'no-store', signal })
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new PullError(`${ref}: the registry proxy is unreachable (${e.message}); try again when it is back`, 'unreachable')
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const j = await res.json().catch(() => ({}))
|
|
54
|
+
throw new PullError(`${ref}: the proxy refused the pull: ${j.error || res.status}`, res.status === 503 ? 'unreachable' : 'refused')
|
|
55
|
+
}
|
|
56
|
+
const reader = res.body.getReader()
|
|
57
|
+
const dec = new TextDecoder()
|
|
58
|
+
let buf = ''
|
|
59
|
+
let events = 0
|
|
60
|
+
for (;;) {
|
|
61
|
+
let chunk
|
|
62
|
+
try {
|
|
63
|
+
chunk = await reader.read()
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw new PullError(`${ref}: the connection to the proxy broke during the pull (after ${events} events: ${e.message}); try again`, 'interrupted')
|
|
66
|
+
}
|
|
67
|
+
if (chunk.done) break
|
|
68
|
+
buf += dec.decode(chunk.value, { stream: true })
|
|
69
|
+
let nl
|
|
70
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
71
|
+
const line = buf.slice(0, nl)
|
|
72
|
+
buf = buf.slice(nl + 1)
|
|
73
|
+
if (!line.trim()) continue
|
|
74
|
+
const e = JSON.parse(line)
|
|
75
|
+
events++
|
|
76
|
+
onEvent(e)
|
|
77
|
+
if (e.t === 'done') return checkImageIndex(e.index)
|
|
78
|
+
// the proxy's messages name the image already
|
|
79
|
+
if (e.t === 'error') throw new PullError(e.message.includes(ref) ? e.message : `${ref}: ${e.message}`, e.status === 404 ? 'not-found' : 'registry')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new PullError(`${ref}: the proxy ended the pull without a result (after ${events} events); try again`, 'interrupted')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Downloads the image's packs (top first, as the index lists them) and checks each against
|
|
87
|
+
* its digest (verify: false skips the hash; the service worker checks before it caches).
|
|
88
|
+
* @returns {Promise<{packs: Uint8Array[], sources: string[], bytes: number, fetchMs: number, hashMs: number}>}
|
|
89
|
+
* (sources: 'cache' when the service worker answered from its cache, else 'network')
|
|
90
|
+
*/
|
|
91
|
+
export async function fetchPacks(index, { base = '', verify = true, signal, onPack = () => {} } = {}) {
|
|
92
|
+
const t0 = performance.now()
|
|
93
|
+
let hashMs = 0
|
|
94
|
+
const sources = []
|
|
95
|
+
const packs = await Promise.all(
|
|
96
|
+
index.layers.map(async (l, i) => {
|
|
97
|
+
let res
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(`${base}/${blobPath(l.pack)}`, { cache: 'no-store', signal })
|
|
100
|
+
} catch (e) {
|
|
101
|
+
throw new PullError(`pack ${i} (${l.pack.slice(7, 19)}): unreachable (${e.message})`, 'unreachable')
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) throw new PullError(`pack ${i} (${l.pack.slice(7, 19)}): ${res.status} ${(await res.text().catch(() => '')).trim()}`, res.status === 502 ? 'verify' : 'unreachable')
|
|
104
|
+
let buf
|
|
105
|
+
try {
|
|
106
|
+
buf = await res.arrayBuffer()
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new PullError(`pack ${i} (${l.pack.slice(7, 19)}): the transfer broke (${e.message})`, 'interrupted')
|
|
109
|
+
}
|
|
110
|
+
if (buf.byteLength !== l.size) throw new PullError(`pack ${i} (${l.pack.slice(7, 19)}): ${buf.byteLength} bytes, the index says ${l.size}`, 'verify')
|
|
111
|
+
if (verify) {
|
|
112
|
+
const h0 = performance.now()
|
|
113
|
+
const got = hex(await crypto.subtle.digest('SHA-256', buf))
|
|
114
|
+
hashMs += performance.now() - h0
|
|
115
|
+
if (`sha256:${got}` !== l.pack) throw new PullError(`pack ${i}: bytes hash to sha256:${got.slice(0, 12)}, not ${l.pack.slice(0, 19)}; refused`, 'verify')
|
|
116
|
+
}
|
|
117
|
+
sources[i] = res.headers.get('X-Wmd-Source') || 'network'
|
|
118
|
+
onPack(i, buf.byteLength)
|
|
119
|
+
return new Uint8Array(buf)
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
return { packs, sources, bytes: packs.reduce((a, p) => a + p.length, 0), fetchMs: Math.round(performance.now() - t0), hashMs: Math.round(hashMs) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Registers the service worker (scope /) and waits until it controls this page, then asks it
|
|
127
|
+
* to cache the page's own files (they loaded before it was in control).
|
|
128
|
+
*/
|
|
129
|
+
export async function useServiceWorker({ url = '/sw.js', timeoutMs = 30_000 } = {}) {
|
|
130
|
+
if (!('serviceWorker' in navigator)) throw new Error('no service worker support in this browser')
|
|
131
|
+
const reg = await navigator.serviceWorker.register(url, { scope: '/' })
|
|
132
|
+
if (!navigator.serviceWorker.controller) {
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const t = setTimeout(() => reject(new Error(`the service worker did not take control within ${timeoutMs} ms`)), timeoutMs)
|
|
135
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
136
|
+
clearTimeout(t)
|
|
137
|
+
resolve()
|
|
138
|
+
}, { once: true })
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
const urls = [location.href, ...performance.getEntriesByType('resource').map(e => e.name)].filter(u => {
|
|
142
|
+
const p = new URL(u, location.href)
|
|
143
|
+
return p.origin === location.origin && !p.pathname.startsWith('/blobs/') && p.pathname !== '/pull' && !p.pathname.startsWith('/idx/')
|
|
144
|
+
})
|
|
145
|
+
const warmed = await swCall({ t: 'warm', urls })
|
|
146
|
+
// Ask the browser not to evict the caches (Safari deletes a site's script-writable storage
|
|
147
|
+
// after 7 days without a visit unless the site is installed or persistent; Chromium and Firefox
|
|
148
|
+
// may evict under disk pressure). The answer is recorded; the app works either way (a first
|
|
149
|
+
// visit online fills the caches again).
|
|
150
|
+
// Firefox may show a permission prompt and not answer until the user does: never wait for it
|
|
151
|
+
// for long (2 s), and let the answer come on its own.
|
|
152
|
+
let persisted = null
|
|
153
|
+
try {
|
|
154
|
+
persisted = await Promise.race([navigator.storage?.persist?.() ?? null, new Promise(r => setTimeout(() => r('pending'), 2000))])
|
|
155
|
+
} catch {}
|
|
156
|
+
return { reg, warmed: warmed.n, persisted }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let swSeq = 0
|
|
160
|
+
/** A request to the controlling service worker, answered by id. */
|
|
161
|
+
export function swCall(msg, timeoutMs = 120_000) {
|
|
162
|
+
const sw = navigator.serviceWorker.controller
|
|
163
|
+
if (!sw) return Promise.reject(new Error('no controlling service worker'))
|
|
164
|
+
const id = ++swSeq
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const t = setTimeout(() => reject(new Error(`service worker: no answer to ${msg.t}`)), timeoutMs)
|
|
167
|
+
const f = e => {
|
|
168
|
+
if (e.data?.id !== id) return
|
|
169
|
+
clearTimeout(t)
|
|
170
|
+
navigator.serviceWorker.removeEventListener('message', f)
|
|
171
|
+
resolve(e.data)
|
|
172
|
+
}
|
|
173
|
+
navigator.serviceWorker.addEventListener('message', f)
|
|
174
|
+
sw.postMessage({ ...msg, id })
|
|
175
|
+
})
|
|
176
|
+
}
|