@skillpie/dsh-remote 0.0.0-stage → 1.1.0

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/lib/index.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * dsh-remote — Cordis entry (both roles in one activation).
3
+ *
4
+ * Host role (default on): dials the configured relay origin (e.g.
5
+ * wss://dsh.skillpie.cn) over one outbound WebSocket and registers this
6
+ * machine's 8-digit code. Visitors opening `https://<relay>/<code>/` use
7
+ * this machine's real DSH Web GUI through the tunnel (`mode: 'gui'`,
8
+ * default) or a lightweight read/control console (`mode: 'console'`).
9
+ * With `direct: true` the same surface also mounts locally at `/<code>`
10
+ * (LAN / loopback use and end-to-end testing without a relay).
11
+ *
12
+ * Relay role (`relay.enabled`, for a server-side profile such as
13
+ * dsh.skillpie.cn): mounts `/dsh-remote/host` for host registrations and
14
+ * serves `/<code>/` for registered hosts. The registry keeps token hashes
15
+ * only; GUI traffic rides spliced frames the relay never interprets.
16
+ *
17
+ * Identity (code / hostId / token) persists as a credential record
18
+ * `dsh-remote/identity` (GrantRecord payload); an explicit `code` in the
19
+ * row config pins it instead.
20
+ *
21
+ * Owner panel: same-origin JSON API at /dsh-remote/api behind the web GUI's
22
+ * own session auth (status / regenerate / toggles), consumed by the client
23
+ * half's sidebar panel. Host roles only — a relay-only server row mounts no
24
+ * owner API.
25
+ */
26
+ import os from 'node:os'
27
+ import { HOST_UPGRADE_PATH, OWNER_API_PATH, PROTOCOL_VERSION, generateCode, generateId, isValidCode } from './protocol.js'
28
+ import { HostEngine } from './engine.js'
29
+ import { GuiBridge } from './bridge.js'
30
+ import { RelayLink } from './remote.js'
31
+ import { RelayCore, serveServiceWorker } from './relay-core.js'
32
+ import { registerFileRoute, fileRouteKey } from './file-route.js'
33
+ import { consoleHtml } from './console.js'
34
+ import { createPipePair } from './pipe.js'
35
+
36
+ const DEFAULT_RELAY_URL = 'wss://dsh.skillpie.cn'
37
+ const CREDENTIAL_KEY = 'dsh-remote/identity'
38
+ const MAX_BODY = 32 * 1024
39
+
40
+ /* ── config ──────────────────────────────────────────────────────────── */
41
+
42
+ /** One issue in the Standard Schema config vocabulary. */
43
+ const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
44
+
45
+ function bool(value, fallback) {
46
+ if (value === undefined || value === null) return fallback
47
+ if (typeof value === 'boolean') return value
48
+ return null
49
+ }
50
+
51
+ export const Config = {
52
+ '~standard': {
53
+ version: 1,
54
+ vendor: 'dsh-remote',
55
+ validate(input) {
56
+ if (input === undefined || input === null) return { value: {} }
57
+ if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
58
+ const issues = []
59
+ const value = {}
60
+ const bools = [
61
+ ['enabled', true],
62
+ ['allowControl', true],
63
+ ['direct', true],
64
+ ]
65
+ for (const [key, fallback] of bools) {
66
+ const parsed = bool(input[key], fallback)
67
+ if (parsed === null) issues.push(issue('expected a boolean', key))
68
+ else value[key] = parsed
69
+ }
70
+ if (input.mode !== undefined) {
71
+ if (input.mode !== 'gui' && input.mode !== 'console') {
72
+ issues.push(issue("expected 'gui' or 'console'", 'mode'))
73
+ } else value.mode = input.mode
74
+ }
75
+ if (input.relayUrl !== undefined) {
76
+ if (typeof input.relayUrl !== 'string' || !input.relayUrl.trim()) {
77
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
78
+ } else {
79
+ try {
80
+ const url = new URL(input.relayUrl.trim())
81
+ if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol) || (url.pathname && url.pathname !== '/')) {
82
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
83
+ } else {
84
+ value.relayUrl = url.origin
85
+ }
86
+ } catch {
87
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
88
+ }
89
+ }
90
+ }
91
+ for (const key of ['name', 'hostId', 'token']) {
92
+ const raw = input[key]
93
+ if (raw === undefined) continue
94
+ if (typeof raw !== 'string' || !raw.trim()) {
95
+ issues.push(issue('expected a non-empty string', key))
96
+ continue
97
+ }
98
+ value[key] = raw.trim()
99
+ }
100
+ if (input.code !== undefined) {
101
+ if (!isValidCode(input.code)) issues.push(issue('expected an 8-digit code (no leading zero)', 'code'))
102
+ else value.code = input.code
103
+ }
104
+ if (input.relay !== undefined) {
105
+ if (typeof input.relay !== 'object' || input.relay === null || Array.isArray(input.relay)) {
106
+ issues.push(issue('expected an object', 'relay'))
107
+ } else {
108
+ const relay = {}
109
+ const relayEnabled = bool(input.relay.enabled, false)
110
+ if (relayEnabled === null) issues.push(issue('expected a boolean', 'relay', 'enabled'))
111
+ else relay.enabled = relayEnabled
112
+ if (input.relay.mode !== undefined) {
113
+ if (input.relay.mode !== 'gui' && input.relay.mode !== 'console') {
114
+ issues.push(issue("expected 'gui' or 'console'", 'relay', 'mode'))
115
+ } else relay.mode = input.relay.mode
116
+ }
117
+ if (input.relay.maxVisitorsPerHost !== undefined) {
118
+ const n = input.relay.maxVisitorsPerHost
119
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 64) {
120
+ issues.push(issue('expected an integer between 1 and 64', 'relay', 'maxVisitorsPerHost'))
121
+ } else relay.maxVisitorsPerHost = n
122
+ }
123
+ value.relay = relay
124
+ }
125
+ }
126
+ return issues.length ? { issues } : { value }
127
+ },
128
+ },
129
+ }
130
+
131
+ /* ── apply ───────────────────────────────────────────────────────────── */
132
+
133
+ export default {
134
+ name: '@skillpie/dsh-remote',
135
+ Config,
136
+ inject: [],
137
+ apply(ctx, rawConfig = null) {
138
+ const cfg = rawConfig ?? {}
139
+ const enabled = cfg.enabled ?? true
140
+ const allowControl = cfg.allowControl ?? true
141
+ const direct = cfg.direct ?? true
142
+ const mode = cfg.mode === 'console' ? 'console' : 'gui'
143
+ const relayUrl = cfg.relayUrl ?? DEFAULT_RELAY_URL
144
+ const relayFaceEnabled = cfg.relay?.enabled === true
145
+ const relayMode = cfg.relay?.mode === 'console' ? 'console' : 'gui'
146
+ const maxVisitorsPerHost = cfg.relay?.maxVisitorsPerHost ?? 8
147
+
148
+ const logRing = []
149
+ const logLine = (kind, text) => {
150
+ logRing.push({ time: Date.now(), kind, text })
151
+ if (logRing.length > 200) logRing.shift()
152
+ }
153
+
154
+ // Provisional identity; boot() may replace it with the stored record
155
+ // before any route or connection mounts.
156
+ const identity = {
157
+ code: isValidCode(cfg.code) ? cfg.code : generateCode(),
158
+ hostId: typeof cfg.hostId === 'string' && cfg.hostId ? cfg.hostId : generateId(),
159
+ token: typeof cfg.token === 'string' ? cfg.token : '',
160
+ name: cfg.name ?? os.hostname(),
161
+ dsh: `node ${process.version}`,
162
+ }
163
+
164
+ // Everything boot() builds registers its disposer here; the effect is
165
+ // registered synchronously so teardown is guaranteed even when boot has
166
+ // not finished.
167
+ const state = { peer: undefined, link: undefined, directCore: undefined, relayCore: undefined }
168
+ const disposers = []
169
+ ctx.effect(() => () => {
170
+ state.link?.stop()
171
+ if (mode === 'gui') state.peer?.detach?.()
172
+ else state.peer?.close?.()
173
+ for (const dispose of disposers.splice(0)) {
174
+ try { dispose() } catch { /* best-effort teardown */ }
175
+ }
176
+ logLine('boot', 'dsh-remote 已卸载')
177
+ }, 'dsh-remote: lifetime')
178
+
179
+ // Services arrive through optional injection; boot() starts the parts
180
+ // that need them as they appear.
181
+ const services = { sessionController: undefined, webServer: undefined, webServerOrigin: undefined }
182
+ let webServerWaiter = undefined
183
+ const whenWebServer = () => services.webServer
184
+ ? Promise.resolve()
185
+ : new Promise((resolve) => { webServerWaiter = resolve })
186
+
187
+ ctx.inject(['sessionController'], (child) => {
188
+ services.sessionController = child.sessionController
189
+ })
190
+ ctx.inject(['webServer'], (child) => {
191
+ services.webServer = child.webServer
192
+ try { services.webServerOrigin = child.webServer.getOrigin() } catch { services.webServerOrigin = undefined }
193
+ if (webServerWaiter) { const resolve = webServerWaiter; webServerWaiter = undefined; resolve() }
194
+ })
195
+
196
+ void boot().catch((error) => {
197
+ logLine('boot', `启动失败: ${error?.message ?? error}`)
198
+ })
199
+
200
+ async function boot() {
201
+ // 1. Adopt the stored identity unless the row config pins one.
202
+ const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
203
+ let stored = undefined
204
+ if (credentials) {
205
+ try {
206
+ const record = await credentials.readRecord(CREDENTIAL_KEY)
207
+ if (record?.kind === 'grant' && record.payload && typeof record.payload === 'object') {
208
+ stored = record.payload
209
+ }
210
+ } catch (error) {
211
+ logLine('identity', `凭据读取失败: ${error?.message ?? error}`)
212
+ }
213
+ if (stored && !cfg.code) {
214
+ if (isValidCode(stored.code)) identity.code = stored.code
215
+ if (typeof stored.hostId === 'string' && stored.hostId) identity.hostId = stored.hostId
216
+ if (typeof stored.token === 'string') identity.token = stored.token
217
+ }
218
+ if (!cfg.code || !cfg.hostId) {
219
+ // Persist the (possibly fresh) identity; no restart involved.
220
+ try {
221
+ await saveIdentity(credentials, { code: identity.code, hostId: identity.hostId, token: identity.token })
222
+ } catch (error) {
223
+ logLine('identity', `身份持久化失败(重启后会再次生成): ${error?.message ?? error}`)
224
+ }
225
+ }
226
+ } else {
227
+ logLine('identity', '凭据服务不可用,身份仅本次会话有效')
228
+ }
229
+
230
+ // 2. The web surface carries everything below.
231
+ await whenWebServer()
232
+ if (!services.webServer) return
233
+ const webServer = services.webServer
234
+
235
+ // 3. Build the visitor-facing peer: the GUI bridge (gui mode) or the
236
+ // lightweight command engine (console mode).
237
+ let peer
238
+ if (mode === 'gui') {
239
+ peer = new GuiBridge({
240
+ services: {
241
+ webServer: () => services.webServer,
242
+ connection: () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined),
243
+ },
244
+ log: logLine,
245
+ })
246
+ } else {
247
+ const engine = new HostEngine({
248
+ services: { sessionController: () => services.sessionController },
249
+ host: { get name() { return identity.name }, get dsh() { return identity.dsh }, get code() { return identity.code } },
250
+ log: logLine,
251
+ })
252
+ engine.allowControl = allowControl
253
+ disposers.push(engine.observeSessionEvents((name, handler) => ctx.on(name, handler)))
254
+ peer = engine
255
+ }
256
+ state.peer = peer
257
+
258
+ // 4. Outbound relay link.
259
+ if (enabled) {
260
+ const link = new RelayLink({
261
+ relayUrl,
262
+ identity,
263
+ peer,
264
+ log: logLine,
265
+ onToken: (token) => {
266
+ identity.token = token
267
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
268
+ if (creds) saveIdentity(creds, { token }).catch(() => {})
269
+ },
270
+ })
271
+ state.link = link
272
+ link.start()
273
+ logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},模式 ${mode})`)
274
+ } else {
275
+ logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},远程访问已关闭)`)
276
+ }
277
+
278
+ // 5. Owner panel API (same-origin, behind the GUI session auth). Host
279
+ // roles only — a relay-only server row mounts no owner API.
280
+ if (enabled || direct) {
281
+ disposers.push(webServer.register({
282
+ kind: 'exact',
283
+ path: OWNER_API_PATH,
284
+ handler: (req, res) => { void ownerApi(req, res) },
285
+ }))
286
+ logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
287
+ }
288
+
289
+ // 5.5 Visitor file downloads: stream files from this host to the
290
+ // remote browser with a save dialog (see lib/file-route.js).
291
+ // Host roles only; the key derives from the host id, which the
292
+ // relay also derives from the hello to stamp the visitor shim.
293
+ if (enabled || direct) {
294
+ disposers.push(registerFileRoute({ webServer, key: fileRouteKey(identity.hostId), log: logLine }))
295
+ logLine('file-route', '访客文件下载路由已挂载(/dsh-remote-file)')
296
+ }
297
+
298
+ // 6. Direct mode: the same surface on the local web server.
299
+ if (direct) {
300
+ const [hostEnd, relayEnd] = createPipePair()
301
+ const directCore = new RelayCore({
302
+ webServer,
303
+ consoleHtml,
304
+ log: logLine,
305
+ maxVisitorsPerHost,
306
+ mode,
307
+ })
308
+ directCore.acceptHostSocket(relayEnd)
309
+ // The pipe replaces RelayLink's transport, so it speaks the same
310
+ // registration handshake before the peer attaches.
311
+ hostEnd.send({
312
+ t: 'hello',
313
+ v: PROTOCOL_VERSION,
314
+ hostId: identity.hostId,
315
+ code: identity.code,
316
+ token: identity.token ?? '',
317
+ name: identity.name,
318
+ dsh: identity.dsh,
319
+ })
320
+ peer.attach(hostEnd)
321
+ state.directCore = directCore
322
+ disposers.push(() => {
323
+ directCore.detachAll()
324
+ hostEnd.close()
325
+ })
326
+ logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
327
+ }
328
+
329
+ // 7. Visitor restore worker: conceals the code from the address bar and
330
+ // keeps the bare origin reopening the tunnel (see concealScript).
331
+ if (mode === 'gui' && (direct || relayFaceEnabled)) {
332
+ disposers.push(webServer.register({
333
+ kind: 'exact',
334
+ path: '/dsh-remote-sw.js',
335
+ handler: (req, res) => { try { serveServiceWorker(req, res) } catch { try { res.destroy() } catch { /* gone */ } } },
336
+ }))
337
+ logLine('sw', '访客恢复 Worker 已挂载(/dsh-remote-sw.js)')
338
+ }
339
+
340
+ // 8. Relay face (server deployments only).
341
+ if (relayFaceEnabled) {
342
+ const relayCore = new RelayCore({
343
+ webServer,
344
+ consoleHtml,
345
+ log: logLine,
346
+ maxVisitorsPerHost,
347
+ mode: relayMode,
348
+ })
349
+ state.relayCore = relayCore
350
+ disposers.push(webServer.registerUpgrade({
351
+ path: HOST_UPGRADE_PATH,
352
+ handler: (req, socket, head) => relayCore.acceptHostUpgrade(req, socket, head),
353
+ }))
354
+ disposers.push(() => relayCore.detachAll())
355
+ logLine('relay-face', `中转面已挂载(${HOST_UPGRADE_PATH},模式 ${relayMode})`)
356
+ }
357
+ }
358
+
359
+ async function ownerApi(req, res) {
360
+ const send = (status, payload) => {
361
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
362
+ res.end(JSON.stringify(payload))
363
+ }
364
+ const origin = req.headers?.origin
365
+ if (typeof origin === 'string' && origin !== '') {
366
+ let sameOrigin = false
367
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
368
+ if (!sameOrigin) return send(403, { ok: false, error: 'cross-origin request refused' })
369
+ }
370
+ let body = {}
371
+ if (req.method === 'POST') {
372
+ body = await readBody(req).catch(() => null)
373
+ if (body === null) return send(200, { ok: false, error: 'invalid JSON body' })
374
+ } else if (req.method !== 'GET') {
375
+ return send(200, { ok: true, service: 'dsh-remote', protocol: PROTOCOL_VERSION })
376
+ }
377
+ try {
378
+ send(200, await handleOwnerAction(body))
379
+ } catch (error) {
380
+ send(200, { ok: false, error: error?.message ?? String(error) })
381
+ }
382
+ }
383
+
384
+ async function handleOwnerAction(body) {
385
+ const action = typeof body?.action === 'string' ? body.action : 'status'
386
+ switch (action) {
387
+ case 'status':
388
+ return { ok: true, status: statusSnapshot() }
389
+ case 'regenerate': {
390
+ const code = generateCode()
391
+ identity.code = code
392
+ identity.token = ''
393
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
394
+ if (creds) await saveIdentity(creds, { code, hostId: identity.hostId, token: '' }).catch(() => {})
395
+ logLine('owner', `属主重新生成远程码 → ${code}`)
396
+ return { ok: true, code }
397
+ }
398
+ case 'set-enabled': {
399
+ const next = body.value === true
400
+ if (next && !state.link) {
401
+ const link = new RelayLink({
402
+ relayUrl,
403
+ identity,
404
+ peer: state.peer,
405
+ log: logLine,
406
+ onToken: (token) => {
407
+ identity.token = token
408
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
409
+ if (creds) saveIdentity(creds, { token }).catch(() => {})
410
+ },
411
+ })
412
+ state.link = link
413
+ link.start()
414
+ } else if (!next && state.link) {
415
+ state.link.stop()
416
+ state.link = undefined
417
+ }
418
+ logLine('owner', `属主${next ? '开启' : '关闭'}远程控制`)
419
+ return { ok: true, enabled: next }
420
+ }
421
+ case 'set-control': {
422
+ const next = body.value === true
423
+ if (state.peer?.allowControl !== undefined) {
424
+ state.peer.allowControl = next
425
+ broadcastHostUpdate()
426
+ }
427
+ logLine('owner', `属主${next ? '允许' : '禁止'}访客控制会话`)
428
+ return { ok: true, allowControl: next }
429
+ }
430
+ default:
431
+ throw new Error(`unknown action: ${action}`)
432
+ }
433
+ }
434
+
435
+ function broadcastHostUpdate() {
436
+ const peer = state.peer
437
+ if (!peer?.visitors) return
438
+ const frame = { t: 'host-updated', host: { name: identity.name, dsh: identity.dsh, allowControl: peer.allowControl } }
439
+ for (const visitorId of peer.visitors.keys()) peer.pushTo(visitorId, frame)
440
+ }
441
+
442
+ function statusSnapshot() {
443
+ const link = state.link
444
+ return {
445
+ protocol: PROTOCOL_VERSION,
446
+ mode,
447
+ enabled: Boolean(link),
448
+ allowControl: state.peer?.allowControl ?? allowControl,
449
+ direct,
450
+ relayUrl,
451
+ name: identity.name,
452
+ hostId: identity.hostId,
453
+ code: identity.code,
454
+ relay: link ? link.status() : { connected: false, relayUrl },
455
+ directUrl: direct && services.webServerOrigin ? `${services.webServerOrigin}/${identity.code}/` : undefined,
456
+ relayUrlPublic: relayUrl.replace(/^ws/, 'http') + '/' + identity.code + '/',
457
+ visitors: state.peer?.status ? state.peer.status().visitors : [],
458
+ relayFace: relayFaceEnabled ? (state.relayCore?.status() ?? { hosts: [] }) : undefined,
459
+ log: logRing.slice(-80),
460
+ }
461
+ }
462
+ },
463
+ }
464
+
465
+ /* ── helpers ─────────────────────────────────────────────────────────── */
466
+
467
+ async function saveIdentity(credentials, patch) {
468
+ await credentials.modifyRecord(CREDENTIAL_KEY, (current) => {
469
+ const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
470
+ ? { ...current.payload }
471
+ : {}
472
+ return { kind: 'grant', payload: { ...payload, ...patch } }
473
+ })
474
+ }
475
+
476
+ function readBody(req) {
477
+ return new Promise((resolvePromise, rejectPromise) => {
478
+ let data = ''
479
+ req.on('data', (chunk) => {
480
+ data += chunk
481
+ if (data.length > MAX_BODY) {
482
+ req.destroy()
483
+ rejectPromise(new Error('request body too large'))
484
+ }
485
+ })
486
+ req.on('end', () => {
487
+ if (!data.trim()) return resolvePromise({})
488
+ try {
489
+ resolvePromise(JSON.parse(data))
490
+ } catch {
491
+ rejectPromise(new Error('invalid JSON body'))
492
+ }
493
+ })
494
+ req.on('error', rejectPromise)
495
+ })
496
+ }
package/lib/pipe.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dsh-remote — in-process ws-like pipe pair.
3
+ *
4
+ * Direct mode reuses the exact relay code path on the local web server: the
5
+ * engine or GUI bridge speaks the same frames to one end of a pipe while a
6
+ * RelayCore instance serves the other end. JSON text frames cross
7
+ * serialization (so framing bugs show up locally too); binary splice frames
8
+ * pass through by reference.
9
+ *
10
+ * Both ends expose the subset of the `ws` surface the plugin uses:
11
+ * send(obj) JSON text frame
12
+ * sendRaw(buffer) binary passthrough
13
+ * onMessage(cb) cb(text | Buffer)
14
+ * onClose(cb), close()
15
+ */
16
+ function makeEnd(getPeer) {
17
+ const end = {
18
+ open: true,
19
+ messageHandlers: new Set(),
20
+ closeHandlers: new Set(),
21
+ send(obj) {
22
+ if (!end.open) return
23
+ const text = JSON.stringify(obj)
24
+ const peer = getPeer()
25
+ // Deliver on a microtask so neither side re-enters synchronously.
26
+ queueMicrotask(() => {
27
+ if (!end.open || !peer || !peer.open) return
28
+ for (const handler of peer.messageHandlers) {
29
+ try { handler(text) } catch { /* contained */ }
30
+ }
31
+ })
32
+ },
33
+ sendRaw(buffer) {
34
+ if (!end.open) return
35
+ const peer = getPeer()
36
+ queueMicrotask(() => {
37
+ if (!end.open || !peer || !peer.open) return
38
+ for (const handler of peer.messageHandlers) {
39
+ try { handler(buffer) } catch { /* contained */ }
40
+ }
41
+ })
42
+ },
43
+ sendText(text) {
44
+ if (!end.open) return
45
+ const peer = getPeer()
46
+ queueMicrotask(() => {
47
+ if (!end.open || !peer || !peer.open) return
48
+ for (const handler of peer.messageHandlers) {
49
+ try { handler(text) } catch { /* contained */ }
50
+ }
51
+ })
52
+ },
53
+ onMessage(handler) {
54
+ end.messageHandlers.add(handler)
55
+ return () => end.messageHandlers.delete(handler)
56
+ },
57
+ onClose(handler) {
58
+ end.closeHandlers.add(handler)
59
+ return () => end.closeHandlers.delete(handler)
60
+ },
61
+ close() {
62
+ if (!end.open) return
63
+ end.open = false
64
+ const handlers = [...end.closeHandlers]
65
+ end.messageHandlers.clear()
66
+ end.closeHandlers.clear()
67
+ for (const handler of handlers) handler()
68
+ const peer = getPeer()
69
+ if (peer && peer.open) peer.close()
70
+ },
71
+ }
72
+ return end
73
+ }
74
+
75
+ export function createPipePair() {
76
+ let peerOfA
77
+ let peerOfB
78
+ const a = makeEnd(() => peerOfA)
79
+ const b = makeEnd(() => peerOfB)
80
+ peerOfA = b
81
+ peerOfB = a
82
+ return [a, b]
83
+ }