dsh-zen-remote 2.3.5 → 2.3.6
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/interface.md +2 -2
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +3 -3
- package/docs/remote-access.md +3 -3
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +84 -19
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/locales.ts +4 -0
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/index.ts +13 -0
- package/src/intercept.ts +46 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
package/src/plugin-tunnel.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { connect as netConnect } from 'node:net'
|
|
27
27
|
import { connect as tlsConnect } from 'node:tls'
|
|
28
|
+
import { randomBytes } from 'node:crypto'
|
|
28
29
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
30
|
import type { Socket } from 'node:net'
|
|
30
31
|
import { pluginBridgeError, responseJson } from './http.js'
|
|
@@ -47,8 +48,11 @@ export type PluginCallValidation =
|
|
|
47
48
|
/** The json-body locator's body reader (PB-24): the caller owns the request
|
|
48
49
|
* stream — `v1/plugin` buffers it bounded by the route's `maxBody` (413 past
|
|
49
50
|
* the cap, mirroring the adapter half's discipline), the tunnel open omits
|
|
50
|
-
* the reader entirely (an open request is empty-bodied by contract
|
|
51
|
-
*
|
|
51
|
+
* the reader entirely (an open request is empty-bodied by contract). Since
|
|
52
|
+
* PB-26b that open-phase gap is fine for an `upgrade: true` route — its ids
|
|
53
|
+
* ride the LATER WebSocket text frames and the send gate share-checks each
|
|
54
|
+
* one — while a NON-upgrade json-body route still refuses at open: nothing
|
|
55
|
+
* would ever own its ids. */
|
|
52
56
|
export type PluginCallBodyReader = (maxBody: number) => Promise<
|
|
53
57
|
| { kind: 'body'; buffered: Buffer; parsed?: unknown }
|
|
54
58
|
| { kind: 'over' }
|
|
@@ -126,6 +130,15 @@ export async function validatePluginCall(
|
|
|
126
130
|
if (queryId === undefined || queryId === '') return { ok: false, status: 400, code: 'bad-query' }
|
|
127
131
|
if (!isAccessible(queryId)) return { ok: false, status: 403, code: 'not-shared' }
|
|
128
132
|
sessionId = queryId
|
|
133
|
+
} else if (locator.in === 'json-body' && readJsonBody === undefined && routeUpgradesOf(route)) {
|
|
134
|
+
// PB-26b: a json-body UPGRADE route's handshake is an empty-bodied GET —
|
|
135
|
+
// the ids ride the JSON text frames the browser sends AFTER the
|
|
136
|
+
// handshake, and the tunnel's send gate share-checks every one of those
|
|
137
|
+
// frames ({@link frameShareVerdict}) before a byte reaches the plugin.
|
|
138
|
+
// The open phase locates nothing and validates no body; the consent
|
|
139
|
+
// wall below still applies, and the tunnel carries the route so the
|
|
140
|
+
// gate can judge the frames.
|
|
141
|
+
sessionId = ''
|
|
129
142
|
} else {
|
|
130
143
|
let parsedBody: unknown
|
|
131
144
|
if (locator.in === 'json-body') {
|
|
@@ -169,6 +182,136 @@ export const TUNNEL_UP_CHUNK_BYTES = 64 * 1024
|
|
|
169
182
|
/** Concurrent tunnels per device. */
|
|
170
183
|
export const MAX_TUNNELS_PER_DEVICE = 8
|
|
171
184
|
|
|
185
|
+
// ---- PB-26b: the RFC 6455 frame gate for json-body upgrade routes -----------
|
|
186
|
+
//
|
|
187
|
+
// A `locator: { in: 'json-body' }` upgrade route carries its session ids in
|
|
188
|
+
// the JSON text frames the browser sends AFTER the handshake (the sidechat
|
|
189
|
+
// ws watch: `{"op":"watch","threadIds":[...]}`) — the handshake itself is an
|
|
190
|
+
// empty-bodied GET, so the open-phase share wall has nothing to judge. The
|
|
191
|
+
// gate lives on the SEND path instead: the sub-client's upstream chunks are
|
|
192
|
+
// split into frames, and every text frame is located + share-checked before
|
|
193
|
+
// a byte of it reaches the plugin. Query-locator upgrade routes never parse
|
|
194
|
+
// a frame — their bytes stay a 100% opaque stream (docs/adr/0002).
|
|
195
|
+
|
|
196
|
+
/** One RFC 6455 frame split off a byte stream. */
|
|
197
|
+
export interface Rfc6455Frame {
|
|
198
|
+
opcode: number
|
|
199
|
+
fin: boolean
|
|
200
|
+
/** The payload with the client mask removed (both directions parse; the
|
|
201
|
+
* mask belongs to client frames only and is undone here either way). */
|
|
202
|
+
payload: Buffer
|
|
203
|
+
/** The frame's complete wire bytes, mask included — the verbatim form a
|
|
204
|
+
* validated frame is forwarded in. */
|
|
205
|
+
raw: Buffer
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The frame-size cap the gate parses (per frame, declared length): a
|
|
209
|
+
* browser watch frame is a few KiB at most, and an unbounded declared
|
|
210
|
+
* length would let a broken sender grow the parse buffer forever. */
|
|
211
|
+
export const MAX_GATE_FRAME_BYTES = 1024 * 1024
|
|
212
|
+
|
|
213
|
+
/** Split the COMPLETE frames at the front of `buffer` — client frames
|
|
214
|
+
* (masked) and server frames (unmasked) alike. A partial frame at the tail
|
|
215
|
+
* stays unconsumed for the next chunk; `violated` reports a frame whose
|
|
216
|
+
* DECLARED length exceeds {@link MAX_GATE_FRAME_BYTES} (the caller refuses,
|
|
217
|
+
* never waits for bytes that must not arrive). */
|
|
218
|
+
export function parseRfc6455Frames(buffer: Buffer): { frames: Rfc6455Frame[]; consumed: number; violated: boolean } {
|
|
219
|
+
const frames: Rfc6455Frame[] = []
|
|
220
|
+
let offset = 0
|
|
221
|
+
while (true) {
|
|
222
|
+
const remain = buffer.length - offset
|
|
223
|
+
if (remain < 2) break
|
|
224
|
+
const fin = (buffer[offset] & 0x80) !== 0
|
|
225
|
+
const opcode = buffer[offset] & 0x0f
|
|
226
|
+
const masked = (buffer[offset + 1] & 0x80) !== 0
|
|
227
|
+
let length = buffer[offset + 1] & 0x7f
|
|
228
|
+
let headerLength = 2
|
|
229
|
+
if (length === 126) {
|
|
230
|
+
if (remain < 4) break
|
|
231
|
+
length = buffer.readUInt16BE(offset + 2)
|
|
232
|
+
headerLength = 4
|
|
233
|
+
} else if (length === 127) {
|
|
234
|
+
if (remain < 10) break
|
|
235
|
+
const declared = buffer.readBigUInt64BE(offset + 2)
|
|
236
|
+
if (declared > BigInt(MAX_GATE_FRAME_BYTES)) return { frames, consumed: offset, violated: true }
|
|
237
|
+
length = Number(declared)
|
|
238
|
+
headerLength = 10
|
|
239
|
+
}
|
|
240
|
+
if (length > MAX_GATE_FRAME_BYTES) return { frames, consumed: offset, violated: true }
|
|
241
|
+
const maskLength = masked ? 4 : 0
|
|
242
|
+
if (remain < headerLength + maskLength + length) break
|
|
243
|
+
const maskAt = offset + headerLength
|
|
244
|
+
const payloadAt = maskAt + maskLength
|
|
245
|
+
const end = payloadAt + length
|
|
246
|
+
const payload = Buffer.from(buffer.subarray(payloadAt, end))
|
|
247
|
+
if (masked) {
|
|
248
|
+
const mask = buffer.subarray(maskAt, payloadAt)
|
|
249
|
+
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4]
|
|
250
|
+
}
|
|
251
|
+
frames.push({ opcode, fin, payload, raw: Buffer.from(buffer.subarray(offset, end)) })
|
|
252
|
+
offset = end
|
|
253
|
+
}
|
|
254
|
+
return { frames, consumed: offset, violated: false }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Encode one RFC 6455 frame. `mask: true` produces the CLIENT side's
|
|
258
|
+
* masked form (4 fresh random bytes, payload XORed) — the bytes the sub-
|
|
259
|
+
* client forwards must look like the browser's own. `false` is the server
|
|
260
|
+
* side's plain form (frames toward the browser). `fin` (default true) is
|
|
261
|
+
* the frame's FIN bit — a fragmented frame keeps its fragmentation shape
|
|
262
|
+
* when the gate re-frames it. */
|
|
263
|
+
export function encodeRfc6455Frame(opcode: number, payload: Buffer, mask: boolean, fin = true): Buffer {
|
|
264
|
+
const firstByte = (fin ? 0x80 : 0x00) | (opcode & 0x0f)
|
|
265
|
+
const maskBit = mask ? 0x80 : 0
|
|
266
|
+
const length = payload.length
|
|
267
|
+
let head: Buffer
|
|
268
|
+
if (length < 126) {
|
|
269
|
+
head = Buffer.from([firstByte, maskBit | length])
|
|
270
|
+
} else if (length < 65536) {
|
|
271
|
+
head = Buffer.alloc(4)
|
|
272
|
+
head[0] = firstByte
|
|
273
|
+
head[1] = maskBit | 126
|
|
274
|
+
head.writeUInt16BE(length, 2)
|
|
275
|
+
} else {
|
|
276
|
+
head = Buffer.alloc(10)
|
|
277
|
+
head[0] = firstByte
|
|
278
|
+
head[1] = maskBit | 127
|
|
279
|
+
head.writeBigUInt64BE(BigInt(length), 2)
|
|
280
|
+
}
|
|
281
|
+
if (!mask) return Buffer.concat([head, payload])
|
|
282
|
+
const maskKey = randomBytes(4)
|
|
283
|
+
const masked = Buffer.from(payload)
|
|
284
|
+
for (let i = 0; i < masked.length; i += 1) masked[i] ^= maskKey[i % 4]
|
|
285
|
+
return Buffer.concat([head, maskKey, masked])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The share-wall verdict of ONE client text frame (PB-26b): the frame's
|
|
289
|
+
* JSON is located against the route's OWN locator and every hit is share-
|
|
290
|
+
* checked — the same adapter-core rule the open phase runs for query and
|
|
291
|
+
* body routes. Non-text frames (close/ping/pong/binary) carry no session
|
|
292
|
+
* ids and pass. A frame that locates NOTHING (an empty `threadIds` array —
|
|
293
|
+
* the unsubscribe-all — or a non-object JSON) is admitted: there is no id
|
|
294
|
+
* to check. */
|
|
295
|
+
function frameShareVerdict(route: NormalizedBridgeRoute, frame: Rfc6455Frame, isAccessible: (sessionId: string) => boolean): { ok: true } | { ok: false; status: number; code: string } {
|
|
296
|
+
if (frame.opcode !== 0x1) return { ok: true }
|
|
297
|
+
let parsed: unknown
|
|
298
|
+
try {
|
|
299
|
+
parsed = JSON.parse(frame.payload.toString('utf8'))
|
|
300
|
+
} catch {
|
|
301
|
+
return { ok: false, status: 400, code: 'bad-request' }
|
|
302
|
+
}
|
|
303
|
+
let hits
|
|
304
|
+
try {
|
|
305
|
+
hits = locateAdapterIds(asAdapterRoute(route), route.path, new URLSearchParams(), parsed)
|
|
306
|
+
} catch {
|
|
307
|
+
return { ok: false, status: 400, code: 'bad-query' }
|
|
308
|
+
}
|
|
309
|
+
for (const hit of hits) {
|
|
310
|
+
if (!isAccessible(hit.value)) return { ok: false, status: 403, code: 'not-shared' }
|
|
311
|
+
}
|
|
312
|
+
return { ok: true }
|
|
313
|
+
}
|
|
314
|
+
|
|
172
315
|
/** The four WebSocket headers the open body may carry, verbatim. */
|
|
173
316
|
const TUNNEL_CLIENT_HEADERS = ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions'] as const
|
|
174
317
|
|
|
@@ -192,6 +335,13 @@ interface Tunnel {
|
|
|
192
335
|
/** The next upstream sequence the client must send. */
|
|
193
336
|
expectedSeq: number
|
|
194
337
|
heartbeat: ReturnType<typeof setInterval>
|
|
338
|
+
/** PB-26b: the validated route — a json-body upgrade tunnel's send gate
|
|
339
|
+
* reads its locator to judge every upstream text frame. */
|
|
340
|
+
route: NormalizedBridgeRoute
|
|
341
|
+
/** PB-26b: the upstream frame-parse buffer. PRESENT ONLY on a json-body
|
|
342
|
+
* upgrade tunnel (the frame gate is enabled); a query-locator upgrade
|
|
343
|
+
* stays `undefined` — its bytes pass through 100% opaque, zero parsing. */
|
|
344
|
+
upFrameBuffer: Buffer | undefined
|
|
195
345
|
}
|
|
196
346
|
|
|
197
347
|
export interface CreatePluginTunnelOptions {
|
|
@@ -260,6 +410,27 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
|
|
|
260
410
|
return typeof value === 'string' ? value : undefined
|
|
261
411
|
}
|
|
262
412
|
|
|
413
|
+
/** PB-26b: run the frame gate over the tunnel's accumulated upstream
|
|
414
|
+
* buffer — split the complete frames, share-check every TEXT one against
|
|
415
|
+
* the route's locator and return the verified wire bytes for the plugin.
|
|
416
|
+
* A refusal returns the verdict and the caller owns the channel (an HTTP
|
|
417
|
+
* status on `send`; an end line on the early head bytes): the refused
|
|
418
|
+
* frame and everything after it NEVER reach upstream. Frames that locate
|
|
419
|
+
* no id (the empty-array unsubscribe) are admitted verbatim. */
|
|
420
|
+
const admitUpstreamFrames = (tunnel: Tunnel, isAccessible: (sessionId: string) => boolean): { ok: true; bytes: Buffer } | { ok: false; status: number; code: string } => {
|
|
421
|
+
const buffer = tunnel.upFrameBuffer!
|
|
422
|
+
const parsed = parseRfc6455Frames(buffer)
|
|
423
|
+
if (parsed.violated) return { ok: false, status: 400, code: 'bad-request' }
|
|
424
|
+
const verified: Buffer[] = []
|
|
425
|
+
for (const frame of parsed.frames) {
|
|
426
|
+
const verdict = frameShareVerdict(tunnel.route, frame, isAccessible)
|
|
427
|
+
if (!verdict.ok) return verdict
|
|
428
|
+
verified.push(frame.raw)
|
|
429
|
+
}
|
|
430
|
+
tunnel.upFrameBuffer = buffer.subarray(parsed.consumed)
|
|
431
|
+
return { ok: true, bytes: Buffer.concat(verified) }
|
|
432
|
+
}
|
|
433
|
+
|
|
263
434
|
return {
|
|
264
435
|
tunnelCount() {
|
|
265
436
|
return tunnels.size
|
|
@@ -336,9 +507,11 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
|
|
|
336
507
|
pluginBridgeError(res, 400, 'bad-request')
|
|
337
508
|
return
|
|
338
509
|
}
|
|
339
|
-
// No body reader here: an open request is empty-bodied by contract
|
|
340
|
-
//
|
|
341
|
-
//
|
|
510
|
+
// No body reader here: an open request is empty-bodied by contract. A
|
|
511
|
+
// NON-upgrade json-body route answers bad-request inside the shared
|
|
512
|
+
// chain (nothing would ever own its ids); an `upgrade: true` one is
|
|
513
|
+
// admitted with an empty session (PB-26b) — its ids arrive in the
|
|
514
|
+
// later WebSocket frames and the send gate owns them.
|
|
342
515
|
const validation = await validatePluginCall(bridge, coords, options.isAccessible)
|
|
343
516
|
if (!validation.ok) {
|
|
344
517
|
pluginBridgeError(res, validation.status, validation.code)
|
|
@@ -381,11 +554,14 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
|
|
|
381
554
|
return
|
|
382
555
|
}
|
|
383
556
|
const id = (await import('node:crypto')).randomBytes(16).toString('hex')
|
|
557
|
+
const gated = routeUpgradesOf(validation.route) && routeLocatorOf(validation.route).in === 'json-body'
|
|
384
558
|
const tunnel: Tunnel = {
|
|
385
559
|
device,
|
|
386
560
|
upstream,
|
|
387
561
|
res,
|
|
388
562
|
expectedSeq: 0,
|
|
563
|
+
route: validation.route,
|
|
564
|
+
upFrameBuffer: gated ? Buffer.alloc(0) : undefined,
|
|
389
565
|
heartbeat: setInterval(() => {
|
|
390
566
|
writeLine(res, { type: 'ping' })
|
|
391
567
|
}, options.heartbeatMs),
|
|
@@ -417,7 +593,22 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
|
|
|
417
593
|
'\r\n',
|
|
418
594
|
]
|
|
419
595
|
upstream.write(requestLines.join('\r\n'))
|
|
420
|
-
|
|
596
|
+
// PB-26b: the gated tunnel admits even its EARLY head bytes through
|
|
597
|
+
// the same per-frame wall — the client half never sends head on a
|
|
598
|
+
// json-body route, so bytes here are a bypass attempt and the tunnel
|
|
599
|
+
// dies with an end line (the river is already open — no HTTP status
|
|
600
|
+
// fits anymore) instead of slipping ids past the share check.
|
|
601
|
+
if (tunnel.upFrameBuffer !== undefined && head.length > 0) {
|
|
602
|
+
tunnel.upFrameBuffer = Buffer.concat([tunnel.upFrameBuffer, head])
|
|
603
|
+
const admitted = admitUpstreamFrames(tunnel, options.isAccessible)
|
|
604
|
+
if (!admitted.ok) {
|
|
605
|
+
teardown(tunnel, id, 'client-closed')
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
if (admitted.bytes.length > 0) upstream.write(admitted.bytes)
|
|
609
|
+
} else if (head.length > 0) {
|
|
610
|
+
upstream.write(head)
|
|
611
|
+
}
|
|
421
612
|
|
|
422
613
|
// Upstream bytes → downstream data lines, at most 48 KiB PER LINE, with
|
|
423
614
|
// the slow-reader discipline: a full socket pauses the UPSTREAM read, so
|
|
@@ -508,22 +699,41 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
|
|
|
508
699
|
}
|
|
509
700
|
const bytes = Buffer.concat(chunks)
|
|
510
701
|
tunnel.expectedSeq = seq + 1
|
|
702
|
+
// PB-26b: a json-body upgrade tunnel parses its upstream bytes into
|
|
703
|
+
// RFC 6455 frames FIRST — every text frame is located and share-
|
|
704
|
+
// checked before a byte of it reaches the plugin; a refused frame
|
|
705
|
+
// answers the send's own HTTP status and tears the tunnel down. An
|
|
706
|
+
// opaque (query-locator) tunnel skips the gate entirely.
|
|
707
|
+
let outbound: Buffer = bytes
|
|
708
|
+
if (tunnel.upFrameBuffer !== undefined) {
|
|
709
|
+
tunnel.upFrameBuffer = Buffer.concat([tunnel.upFrameBuffer, bytes])
|
|
710
|
+
const admitted = admitUpstreamFrames(tunnel, options.isAccessible)
|
|
711
|
+
if (!admitted.ok) {
|
|
712
|
+
pluginBridgeError(res, admitted.status, admitted.code)
|
|
713
|
+
teardown(tunnel, tunnelId, 'client-closed')
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
outbound = admitted.bytes
|
|
717
|
+
}
|
|
511
718
|
// Backpressure: the answer waits for the upstream socket to take the
|
|
512
719
|
// bytes, so a stalled plugin makes THIS send slow instead of growing
|
|
513
|
-
// buffers here.
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
720
|
+
// buffers here. A gated chunk may hold no complete frame yet — nothing
|
|
721
|
+
// to write, the bytes stay in the parse buffer, the send answers ok.
|
|
722
|
+
if (outbound.length > 0) {
|
|
723
|
+
const flushed = tunnel.upstream.write(outbound)
|
|
724
|
+
if (!flushed) {
|
|
725
|
+
await new Promise<void>((resolve) => {
|
|
726
|
+
const settled = (): void => {
|
|
727
|
+
tunnel.upstream.off('drain', settled)
|
|
728
|
+
tunnel.upstream.off('close', settled)
|
|
729
|
+
tunnel.upstream.off('error', settled)
|
|
730
|
+
resolve()
|
|
731
|
+
}
|
|
732
|
+
tunnel.upstream.once('drain', settled)
|
|
733
|
+
tunnel.upstream.once('close', settled)
|
|
734
|
+
tunnel.upstream.once('error', settled)
|
|
735
|
+
})
|
|
736
|
+
}
|
|
527
737
|
}
|
|
528
738
|
if (tunnels.get(tunnelId) !== tunnel) {
|
|
529
739
|
// The tunnel died while the bytes were draining.
|