dsh-tap 0.20.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/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- package/scripts/verify-trae-provider.mjs +1451 -0
package/core/bridge.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/bridge.js — provider-agnostic stream bridge: a smart loopback proxy
|
|
3
|
+
* and the single credential owner.
|
|
4
|
+
*
|
|
5
|
+
* The host's LLM route points at this bridge, so the main chat path crosses
|
|
6
|
+
* it too. It passes any request path through to the upstream, and on POST
|
|
7
|
+
* /chat/completions it can (a) inject session-attribution headers from the
|
|
8
|
+
* incoming session id, (b) cap concurrent in-flight requests per session id
|
|
9
|
+
* (excess queue, FIFO), and (c) aggregate the upstream SSE stream into
|
|
10
|
+
* classic non-streaming OpenAI JSON for callers that need it.
|
|
11
|
+
*
|
|
12
|
+
* Generic transport guardrails (no provider specifics involved):
|
|
13
|
+
* - client hangup (res 'close' before completion) aborts the queued wait,
|
|
14
|
+
* the upstream fetch and the SSE relay, and frees the limiter slot;
|
|
15
|
+
* - every upstream attempt carries a first-byte timer
|
|
16
|
+
* (`settings.upstreamFirstByteTimeoutMs`, default 45s) that fails fast
|
|
17
|
+
* when the peer connects but never sends response headers — the timer
|
|
18
|
+
* clears on headers, so long SSE streams are unaffected;
|
|
19
|
+
* - an inbound body past 32MB is answered 413, not silently destroyed;
|
|
20
|
+
* - aggregated chat.completion JSON carries the captured usage object.
|
|
21
|
+
*
|
|
22
|
+
* Everything upstream-specific is injected through the `provider` adapter:
|
|
23
|
+
* bridgeHeaders() static outbound headers for proxied calls
|
|
24
|
+
* transformChatPayload(p) in-place rewrite of a parsed chat payload
|
|
25
|
+
* extractUsage(chunk) usage object from a parsed SSE chunk, or null
|
|
26
|
+
* extractStreamError(chunk) truthy when a parsed SSE chunk is an error
|
|
27
|
+
* bridgeResponseId `id` for aggregated chat.completion JSON
|
|
28
|
+
* logHeaderNames header names the forensic log may record
|
|
29
|
+
* sentinelAuth the static sentinel Authorization value callers
|
|
30
|
+
* use (classified in logs, never logged verbatim)
|
|
31
|
+
* texts.credentialUnavailable error text for the no-credential response
|
|
32
|
+
* logPrefix stderr prefix for bridge lifecycle warnings
|
|
33
|
+
*
|
|
34
|
+
* Credentials are always resolved host-side through the injected
|
|
35
|
+
* `withCredentials(attempt)` runner (the composition root's rotation);
|
|
36
|
+
* the caller's Authorization is never forwarded.
|
|
37
|
+
*
|
|
38
|
+
* Extracted from index.js during the core/providers split. This file must
|
|
39
|
+
* not mention any concrete upstream by name — that is the falsifiable
|
|
40
|
+
* generality contract (scripts/verify-core-generic.mjs enforces it).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createServer } from 'node:http'
|
|
44
|
+
import { createHash } from 'node:crypto'
|
|
45
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
46
|
+
import { join } from 'node:path'
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Loopback Host gate ([8]+[26] hardening): the bridge carries the user's live
|
|
50
|
+
// credential, so it must only answer callers that reached it the intended way
|
|
51
|
+
// — a same-machine client addressing the loopback bind directly. A non-loopback
|
|
52
|
+
// Host header (DNS-rebinding name, LAN-spoofed Host) is refused before any
|
|
53
|
+
// body byte is read or proxied.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Hostnames a same-machine caller may present in Host (::1 bare for raw
|
|
57
|
+
* Host values; URL-parsed IPv6 keeps its brackets). */
|
|
58
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
|
|
59
|
+
|
|
60
|
+
/** Inbound request-body ceiling; past it the caller gets a real 413. */
|
|
61
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
62
|
+
|
|
63
|
+
/** True when the Host header (may carry a port) names the loopback. */
|
|
64
|
+
export function hostIsLoopback(host) {
|
|
65
|
+
if (typeof host !== 'string' || !host) return false
|
|
66
|
+
try {
|
|
67
|
+
return LOOPBACK_HOSTNAMES.has(new URL(`http://${host}`).hostname)
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Origin gate: a browser cross-site request (including a no-preflight
|
|
75
|
+
* `navigator.sendBeacon` with a text/plain body) always carries Origin — its
|
|
76
|
+
* host:port must equal the Host header or the request is a drive-by CSRF that
|
|
77
|
+
* would burn the user's upstream quota. A missing Origin passes: same-machine
|
|
78
|
+
* fetch/curl and shell-forwarded in-app requests carry no Origin; the Host
|
|
79
|
+
* gate above still confines those to loopback. Non-browser clients can forge
|
|
80
|
+
* any Origin anyway, so this gate adds no constraint for them.
|
|
81
|
+
*/
|
|
82
|
+
export function originMatchesHost(req) {
|
|
83
|
+
const origin = req.headers.origin
|
|
84
|
+
if (origin === undefined) return true
|
|
85
|
+
try {
|
|
86
|
+
return new URL(origin).host === req.headers.host
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Session attribution
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const SESSION_HEADER_SETS = {
|
|
97
|
+
openai: ['session_id', 'x-client-request-id', 'x-session-affinity'],
|
|
98
|
+
openrouter: ['x-session-id'],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Extract the session id from headers or a body hint, in precedence order. */
|
|
102
|
+
export function extractSessionId(headers, payload) {
|
|
103
|
+
const candidates = [
|
|
104
|
+
headers['x-conversation-id'],
|
|
105
|
+
headers['x-session-id'],
|
|
106
|
+
headers['session_id'],
|
|
107
|
+
headers['x-client-request-id'],
|
|
108
|
+
headers['x-session-affinity'],
|
|
109
|
+
payload?.conversation_id,
|
|
110
|
+
payload?.session_id,
|
|
111
|
+
]
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
if (typeof c === 'string' && c.trim().length > 0) return c.trim()
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-session concurrency governor: per-id in-flight counters with FIFO
|
|
120
|
+
* waiting queues. acquire() resolves once this call may proceed.
|
|
121
|
+
*
|
|
122
|
+
* An optional AbortSignal covers the queued wait: when it fires while the
|
|
123
|
+
* call is still waiting, the waiter is removed from the queue and the
|
|
124
|
+
* promise rejects with signal.reason — a caller that went away must never
|
|
125
|
+
* be woken to fire upstream. A call that already holds its slot is
|
|
126
|
+
* unaffected (its release still comes from the caller's finally).
|
|
127
|
+
*/
|
|
128
|
+
export class SessionLimiter {
|
|
129
|
+
constructor() {
|
|
130
|
+
this.inflight = new Map()
|
|
131
|
+
this.queues = new Map()
|
|
132
|
+
}
|
|
133
|
+
acquire(id, limit, signal) {
|
|
134
|
+
if (id === null) return Promise.resolve(() => {})
|
|
135
|
+
if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'))
|
|
136
|
+
const running = this.inflight.get(id) ?? 0
|
|
137
|
+
if (running < limit) {
|
|
138
|
+
this.inflight.set(id, running + 1)
|
|
139
|
+
return Promise.resolve(() => this.release(id))
|
|
140
|
+
}
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const q = this.queues.get(id) ?? []
|
|
143
|
+
const waiter = () => {
|
|
144
|
+
signal?.removeEventListener('abort', onAbort)
|
|
145
|
+
resolve(() => this.release(id))
|
|
146
|
+
}
|
|
147
|
+
const onAbort = () => {
|
|
148
|
+
const idx = q.indexOf(waiter)
|
|
149
|
+
if (idx >= 0) q.splice(idx, 1)
|
|
150
|
+
if (q.length === 0) this.queues.delete(id)
|
|
151
|
+
reject(signal.reason ?? new Error('aborted'))
|
|
152
|
+
}
|
|
153
|
+
q.push(waiter)
|
|
154
|
+
this.queues.set(id, q)
|
|
155
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
release(id) {
|
|
159
|
+
const running = (this.inflight.get(id) ?? 0) - 1
|
|
160
|
+
const q = this.queues.get(id) ?? []
|
|
161
|
+
// One release frees exactly one slot; hand it to the oldest waiter.
|
|
162
|
+
// The woken call inherits the slot, so the in-flight count is restored
|
|
163
|
+
// BEFORE its callback runs (it will release again when done).
|
|
164
|
+
const next = q.shift()
|
|
165
|
+
if (next !== undefined) {
|
|
166
|
+
this.inflight.set(id, running + 1)
|
|
167
|
+
next()
|
|
168
|
+
} else if (running <= 0) {
|
|
169
|
+
this.inflight.delete(id)
|
|
170
|
+
} else {
|
|
171
|
+
this.inflight.set(id, running)
|
|
172
|
+
}
|
|
173
|
+
if (q.length === 0) this.queues.delete(id)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Request forensics (opt-in) + payload classification helpers
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
const sha16 = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16)
|
|
182
|
+
|
|
183
|
+
/** Pick the whitelisted headers worth recording; authorization is classified. */
|
|
184
|
+
function pickLogHeaders(headers, logHeaderNames, sentinelAuth) {
|
|
185
|
+
const out = {}
|
|
186
|
+
for (const name of logHeaderNames) {
|
|
187
|
+
const value = headers[name]
|
|
188
|
+
if (typeof value === 'string' && value.length > 0) out[name] = value
|
|
189
|
+
}
|
|
190
|
+
const auth = headers.authorization
|
|
191
|
+
if (typeof auth === 'string' && auth.length > 0) {
|
|
192
|
+
out.authorization = auth === sentinelAuth ? 'sentinel' : 'caller-set'
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Text of one chat message, whether content is a string or typed parts. */
|
|
198
|
+
function messageText(message) {
|
|
199
|
+
if (!message || typeof message !== 'object') return ''
|
|
200
|
+
const content = message.content
|
|
201
|
+
if (typeof content === 'string') return content
|
|
202
|
+
if (!Array.isArray(content)) return ''
|
|
203
|
+
return content
|
|
204
|
+
.filter((p) => p?.type === 'text' && typeof p.text === 'string')
|
|
205
|
+
.map((p) => p.text)
|
|
206
|
+
.join('\n')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Classify a chat payload by its prompt shape — the host's auxiliary LLM
|
|
211
|
+
* calls (session title, compaction) reuse the main route and are
|
|
212
|
+
* recognizable only by their prompt text (verified against the host's
|
|
213
|
+
* session-title / compaction sources). Host-specific, upstream-agnostic.
|
|
214
|
+
*/
|
|
215
|
+
function detectPayloadMarker(messages) {
|
|
216
|
+
const first = messages[0]
|
|
217
|
+
if (first?.role === 'system'
|
|
218
|
+
&& messageText(first).startsWith('Create a concise title for an AI coding-assistant session')) {
|
|
219
|
+
return 'session-title'
|
|
220
|
+
}
|
|
221
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
222
|
+
if (messages[i]?.role !== 'user') continue
|
|
223
|
+
if (messageText(messages[i]).startsWith('You are now acting as a compaction engine')) {
|
|
224
|
+
return 'compaction'
|
|
225
|
+
}
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Metering kind for a parsed chat payload: the host's auxiliary calls
|
|
232
|
+
* (title, compaction) get their own kinds so the usage view can tell them
|
|
233
|
+
* apart. */
|
|
234
|
+
function chatUsageKind(payload) {
|
|
235
|
+
const marker = detectPayloadMarker(Array.isArray(payload?.messages) ? payload.messages : [])
|
|
236
|
+
return marker === 'session-title' ? 'title' : marker === 'compaction' ? 'compaction' : 'chat'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Hash-and-shape summary of a parsed chat payload (no message text logged). */
|
|
240
|
+
function summarizeChatPayload(rawBody, payload) {
|
|
241
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : []
|
|
242
|
+
const sysText = messages[0]?.role === 'system' ? messageText(messages[0]) : ''
|
|
243
|
+
let lastUserText = ''
|
|
244
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
245
|
+
if (messages[i]?.role === 'user') {
|
|
246
|
+
lastUserText = messageText(messages[i])
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
model: typeof payload.model === 'string' ? payload.model : null,
|
|
252
|
+
stream: payload.stream === true,
|
|
253
|
+
msgs: messages.length,
|
|
254
|
+
bytes: rawBody.length,
|
|
255
|
+
bodySha: sha16(rawBody),
|
|
256
|
+
msgsSha: sha16(JSON.stringify(payload.messages ?? null)),
|
|
257
|
+
sysSha: sysText.length > 0 ? sha16(sysText) : null,
|
|
258
|
+
sysBytes: sysText.length,
|
|
259
|
+
lastUserSha: lastUserText.length > 0 ? sha16(lastUserText) : null,
|
|
260
|
+
lastUserPreview: lastUserText.replace(/\s+/g, ' ').slice(0, 60),
|
|
261
|
+
marker: detectPayloadMarker(messages),
|
|
262
|
+
maxTokens: payload.max_tokens ?? payload.max_completion_tokens ?? null,
|
|
263
|
+
reasoningEffort: payload.reasoning_effort ?? null,
|
|
264
|
+
tools: Array.isArray(payload.tools) ? payload.tools.length : 0,
|
|
265
|
+
promptCacheKey: typeof payload.prompt_cache_key === 'string' ? payload.prompt_cache_key : null,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// The bridge factory
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {() => object} settings live-resolved settings (needs baseURL,
|
|
275
|
+
* sessionHeadersEnabled, sessionHeaderFormat, maxConcurrentPerSession;
|
|
276
|
+
* upstreamFirstByteTimeoutMs optional, default 45000)
|
|
277
|
+
* @param {object} provider upstream adapter (hooks listed at the top)
|
|
278
|
+
* @param {(attempt: (cred: object) => Promise<Response>) => Promise<{cred: object|null, res: Response|null, err: Error|null}>} withCredentials
|
|
279
|
+
* credential-owning runner from the composition root
|
|
280
|
+
* @param {{ record: Function }} meter usage meter (core/usage-meter)
|
|
281
|
+
* @param {{ logPath: () => string|undefined, dumpDir: () => string|undefined }} forensics
|
|
282
|
+
* opt-in forensic sinks; payload text is never logged — only hashes and a
|
|
283
|
+
* short preview. The dump sink writes inbound chat bodies verbatim
|
|
284
|
+
* (local-only, opt-in). Diagnostics must never break the bridge.
|
|
285
|
+
* @param {{ running: boolean, port: number|null, lastError: string|null }} runtime
|
|
286
|
+
* shared runtime-state object mutated by listen() (owned by the
|
|
287
|
+
* composition root so several apply() generations report one reality).
|
|
288
|
+
*/
|
|
289
|
+
export function createBridge({ settings, provider, withCredentials, meter, forensics, runtime }) {
|
|
290
|
+
const limiter = new SessionLimiter()
|
|
291
|
+
let logSeq = 0
|
|
292
|
+
let dumpSeq = 0
|
|
293
|
+
|
|
294
|
+
function bridgeLog(record) {
|
|
295
|
+
const path = forensics.logPath()
|
|
296
|
+
if (!path) return
|
|
297
|
+
try {
|
|
298
|
+
appendFileSync(path, JSON.stringify(record) + '\n')
|
|
299
|
+
} catch {
|
|
300
|
+
// logging is best-effort
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Full-body dump for cache/prompt forensics: plaintext by design —
|
|
305
|
+
* local-only, opt-in. */
|
|
306
|
+
function bridgeDump(rawBody) {
|
|
307
|
+
const dir = forensics.dumpDir()
|
|
308
|
+
if (!dir) return
|
|
309
|
+
try {
|
|
310
|
+
mkdirSync(dir, { recursive: true })
|
|
311
|
+
writeFileSync(join(dir, `req-${String(++dumpSeq).padStart(4, '0')}-${Date.now()}.json`), rawBody)
|
|
312
|
+
} catch {
|
|
313
|
+
// dump is best-effort
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Aggregate one streamed upstream chat completion into a classic
|
|
319
|
+
* chat.completion JSON for non-streaming callers. The bridge always
|
|
320
|
+
* speaks SSE upstream and translates back here.
|
|
321
|
+
*/
|
|
322
|
+
async function aggregateChatCompletion(upstream, res, model, tap = null) {
|
|
323
|
+
if (!upstream.ok) {
|
|
324
|
+
const errBody = await upstream.text().catch(() => '')
|
|
325
|
+
res.writeHead(upstream.status, { 'Content-Type': 'application/json' })
|
|
326
|
+
res.end(errBody)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
let content = ''
|
|
330
|
+
let finishReason = null
|
|
331
|
+
let buf = ''
|
|
332
|
+
const reader = upstream.body.getReader()
|
|
333
|
+
const decoder = new TextDecoder()
|
|
334
|
+
for (;;) {
|
|
335
|
+
const { done, value } = await reader.read()
|
|
336
|
+
if (done) break
|
|
337
|
+
buf += decoder.decode(value, { stream: true })
|
|
338
|
+
let nl
|
|
339
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
340
|
+
const line = buf.slice(0, nl).trim()
|
|
341
|
+
buf = buf.slice(nl + 1)
|
|
342
|
+
if (!line.startsWith('data:')) continue
|
|
343
|
+
const data = line.slice(5).trim()
|
|
344
|
+
if (data === '[DONE]') continue
|
|
345
|
+
try {
|
|
346
|
+
const chunk = JSON.parse(data)
|
|
347
|
+
const usage = provider.extractUsage(chunk)
|
|
348
|
+
if (tap && usage) tap.usage = usage
|
|
349
|
+
if (provider.extractStreamError(chunk)) {
|
|
350
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
351
|
+
res.end(data)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const choice = chunk.choices?.[0]
|
|
355
|
+
if (choice?.delta?.content) content += choice.delta.content
|
|
356
|
+
if (choice?.finish_reason) finishReason = choice.finish_reason
|
|
357
|
+
} catch {
|
|
358
|
+
// incomplete JSON inside a complete SSE line — skip
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
363
|
+
res.end(
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
id: provider.bridgeResponseId,
|
|
366
|
+
object: 'chat.completion',
|
|
367
|
+
created: Math.floor(Date.now() / 1000),
|
|
368
|
+
model,
|
|
369
|
+
choices: [
|
|
370
|
+
{
|
|
371
|
+
index: 0,
|
|
372
|
+
message: { role: 'assistant', content },
|
|
373
|
+
finish_reason: finishReason ?? 'stop',
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
// The caller asked for classic JSON — give it the usage the stream
|
|
377
|
+
// carried ({} only when the upstream truly sent none).
|
|
378
|
+
usage: tap?.usage ?? {},
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Proxy one request upstream. Passes through non-chat paths verbatim; on
|
|
385
|
+
* POST /chat/completions injects session headers (from the incoming
|
|
386
|
+
* session id, unless the caller already set one) and gates per-session
|
|
387
|
+
* concurrency. Inbound stream:true gets the SSE passed through; anything
|
|
388
|
+
* else (classic non-streaming callers) gets the stream aggregated into a
|
|
389
|
+
* chat.completion JSON.
|
|
390
|
+
*/
|
|
391
|
+
async function proxyUpstream(req, res, rawBody) {
|
|
392
|
+
const s = settings()
|
|
393
|
+
const isChat = req.url?.endsWith('/chat/completions') === true
|
|
394
|
+
const logId = forensics.logPath() ? ++logSeq : 0
|
|
395
|
+
const t0 = Date.now()
|
|
396
|
+
|
|
397
|
+
// Client-disconnect propagation: when the caller goes away before the
|
|
398
|
+
// response completed, abort everything downstream — the queued wait, the
|
|
399
|
+
// upstream fetch, and the SSE relay — and free the limiter slot (finally
|
|
400
|
+
// below). The signal is res 'close' with writableEnded === false: since
|
|
401
|
+
// Node 16, req 'close' fires on EVERY fully-received request body, so it
|
|
402
|
+
// cannot tell a hangup from a normal request; res 'close' can.
|
|
403
|
+
// Abort reasons carry name 'AbortError' so the rotation engine treats
|
|
404
|
+
// them as caller-side (no cooldown, no failover to the next key).
|
|
405
|
+
const clientAbort = new AbortController()
|
|
406
|
+
const onClientClose = () => {
|
|
407
|
+
if (res.writableEnded) return
|
|
408
|
+
const err = new Error('client disconnected')
|
|
409
|
+
err.name = 'AbortError'
|
|
410
|
+
err.clientDisconnected = true
|
|
411
|
+
clientAbort.abort(err)
|
|
412
|
+
}
|
|
413
|
+
res.on('close', onClientClose)
|
|
414
|
+
|
|
415
|
+
// Parse the body only for chat (the session hint may live there); other
|
|
416
|
+
// paths pass the bytes through untouched.
|
|
417
|
+
let payload = null
|
|
418
|
+
if (isChat && rawBody.length > 0) {
|
|
419
|
+
try { payload = JSON.parse(rawBody) } catch { payload = null }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const sessionId = isChat ? extractSessionId(req.headers, payload) : null
|
|
423
|
+
if (logId) {
|
|
424
|
+
bridgeLog({
|
|
425
|
+
seq: logId,
|
|
426
|
+
dir: 'in',
|
|
427
|
+
ts: t0,
|
|
428
|
+
method: req.method,
|
|
429
|
+
path: req.url,
|
|
430
|
+
hdr: pickLogHeaders(req.headers, provider.logHeaderNames, provider.sentinelAuth),
|
|
431
|
+
chat: payload ? summarizeChatPayload(rawBody, payload) : null,
|
|
432
|
+
bytes: payload ? undefined : rawBody.length,
|
|
433
|
+
bodySha: payload ? undefined : sha16(rawBody),
|
|
434
|
+
sessionIn: sessionId,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
if (isChat && payload !== null) bridgeDump(rawBody)
|
|
438
|
+
const outHeaders = {
|
|
439
|
+
'Content-Type': 'application/json',
|
|
440
|
+
...provider.bridgeHeaders(),
|
|
441
|
+
}
|
|
442
|
+
// Session attribution: per header, a value the caller already set wins;
|
|
443
|
+
// missing headers are filled with the extracted session id. (The bridge
|
|
444
|
+
// rebuilds the header set from scratch — an all-or-nothing "preserve"
|
|
445
|
+
// would silently DROP the caller's headers instead of forwarding them.)
|
|
446
|
+
const sessionOut = {}
|
|
447
|
+
if (isChat && s.sessionHeadersEnabled === true && sessionId !== null) {
|
|
448
|
+
const names = SESSION_HEADER_SETS[s.sessionHeaderFormat] ?? SESSION_HEADER_SETS.openai
|
|
449
|
+
for (const name of names) {
|
|
450
|
+
const existing = req.headers[name]
|
|
451
|
+
outHeaders[name] = typeof existing === 'string' && existing.length > 0 ? existing : sessionId
|
|
452
|
+
sessionOut[name] = outHeaders[name]
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let body = rawBody
|
|
457
|
+
// Only an explicit stream:true passes SSE through; classic non-streaming
|
|
458
|
+
// callers (stream:false or absent — the OpenAI default) get aggregation,
|
|
459
|
+
// which requires speaking SSE upstream regardless of the inbound flag.
|
|
460
|
+
const aggregate = isChat && payload !== null && payload.stream !== true
|
|
461
|
+
if (isChat && payload !== null) {
|
|
462
|
+
payload.stream = true
|
|
463
|
+
// Adapter hook: upstream-specific payload normalization (e.g. role
|
|
464
|
+
// rewrites the upstream's entry layer demands). Runs on the way out,
|
|
465
|
+
// after the stream flag is forced, before re-serialization.
|
|
466
|
+
provider.transformChatPayload(payload)
|
|
467
|
+
body = JSON.stringify(payload)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Outcome record shared by every exit path below. */
|
|
471
|
+
const logOut = (extra) => {
|
|
472
|
+
if (!logId) return
|
|
473
|
+
bridgeLog({
|
|
474
|
+
seq: logId,
|
|
475
|
+
dir: 'out',
|
|
476
|
+
ts: Date.now(),
|
|
477
|
+
ms: Date.now() - t0,
|
|
478
|
+
...(Object.keys(sessionOut).length > 0 ? { sessionOut } : {}),
|
|
479
|
+
...extra,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Concurrency gate only applies to chat (LLM calls). A caller that hangs
|
|
484
|
+
// up while queued rejects out of the wait and never reaches the upstream.
|
|
485
|
+
let release
|
|
486
|
+
try {
|
|
487
|
+
release = isChat ? await limiter.acquire(sessionId, s.maxConcurrentPerSession, clientAbort.signal) : () => {}
|
|
488
|
+
} catch {
|
|
489
|
+
logOut({ waitMs: Date.now() - t0, clientGone: true })
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (clientAbort.signal.aborted) {
|
|
493
|
+
// Woken in the same tick the caller disconnected: hand the slot back.
|
|
494
|
+
release()
|
|
495
|
+
logOut({ waitMs: Date.now() - t0, clientGone: true })
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
const waitMs = Date.now() - t0
|
|
499
|
+
// Header names the current credential candidate contributed; removed
|
|
500
|
+
// before the next attempt so a stale identity header from a prior
|
|
501
|
+
// candidate never leaks across failovers.
|
|
502
|
+
let credHeaderNames = []
|
|
503
|
+
try {
|
|
504
|
+
// First-byte guardrail: if the upstream connects but never sends
|
|
505
|
+
// response headers, fail fast instead of hanging the caller forever.
|
|
506
|
+
// The timer clears the moment headers arrive (fetch resolves), so a
|
|
507
|
+
// long SSE stream is unaffected. Per attempt, so a failover retry gets
|
|
508
|
+
// its own window.
|
|
509
|
+
const firstByteMs = Number(s.upstreamFirstByteTimeoutMs) > 0 ? Number(s.upstreamFirstByteTimeoutMs) : 45_000
|
|
510
|
+
const { cred, res: upstream0, err } = await withCredentials((c) => {
|
|
511
|
+
outHeaders.Authorization = c.authorization
|
|
512
|
+
for (const name of credHeaderNames) delete outHeaders[name]
|
|
513
|
+
credHeaderNames = Object.keys(c.headers ?? {})
|
|
514
|
+
Object.assign(outHeaders, c.headers)
|
|
515
|
+
const attemptAbort = new AbortController()
|
|
516
|
+
const firstByteTimer = setTimeout(() => {
|
|
517
|
+
const err = new Error(`upstream sent no response headers within ${firstByteMs}ms (first-byte timeout)`)
|
|
518
|
+
err.name = 'AbortError'
|
|
519
|
+
err.firstByteTimeout = true
|
|
520
|
+
attemptAbort.abort(err)
|
|
521
|
+
}, firstByteMs)
|
|
522
|
+
return fetch(`${s.baseURL}${req.url}`, {
|
|
523
|
+
method: req.method,
|
|
524
|
+
headers: outHeaders,
|
|
525
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
|
|
526
|
+
signal: AbortSignal.any([clientAbort.signal, attemptAbort.signal]),
|
|
527
|
+
}).finally(() => clearTimeout(firstByteTimer))
|
|
528
|
+
})
|
|
529
|
+
if (!cred || err) {
|
|
530
|
+
if (err?.clientDisconnected === true) {
|
|
531
|
+
// The caller hung up mid-handshake — nobody left to answer.
|
|
532
|
+
logOut({ waitMs, clientGone: true })
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
// No credential at all (503) or every candidate failed at the network
|
|
536
|
+
// layer (502) — the caller gets a classic JSON error either way. The
|
|
537
|
+
// composition root flags its empty-candidate error with
|
|
538
|
+
// `credentialUnavailable` so the bridge needs no provider strings.
|
|
539
|
+
const isCred = err?.credentialUnavailable === true
|
|
540
|
+
const status = isCred ? 503 : 502
|
|
541
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
542
|
+
res.end(JSON.stringify({ error: { message: isCred ? provider.texts.credentialUnavailable : `upstream unreachable: ${err?.message ?? 'unknown'}` } }))
|
|
543
|
+
logOut({ status, waitMs, err: err?.message ?? 'credential unavailable' })
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
const upstream = upstream0
|
|
547
|
+
const ttfbMs = Date.now() - t0
|
|
548
|
+
if (aggregate) {
|
|
549
|
+
const tap = { usage: null }
|
|
550
|
+
await aggregateChatCompletion(upstream, res, payload.model, tap)
|
|
551
|
+
if (tap.usage) meter.record({ ts: t0, kind: chatUsageKind(payload), model: payload.model ?? null, usage: tap.usage })
|
|
552
|
+
logOut({ status: upstream.status, waitMs, ttfbMs, aggregated: true, usage: tap?.usage ?? null, keyName: cred.keyName ?? undefined })
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
res.writeHead(upstream.status, { 'Content-Type': upstream.headers.get('content-type') ?? 'application/json' })
|
|
556
|
+
if (upstream.body === null) {
|
|
557
|
+
res.end()
|
|
558
|
+
logOut({ status: upstream.status, waitMs, ttfbMs })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
// Tee chat SSE bytes through a line scanner that keeps the last usage
|
|
562
|
+
// object (the upstream may repeat usage on every chunk). Always on for
|
|
563
|
+
// chat: the usage meter feeds the settings card; the forensic log
|
|
564
|
+
// reuses the same scan when enabled.
|
|
565
|
+
const reader = upstream.body.getReader()
|
|
566
|
+
const decoder = isChat ? new TextDecoder() : null
|
|
567
|
+
let scanBuf = ''
|
|
568
|
+
let usage = null
|
|
569
|
+
for (;;) {
|
|
570
|
+
const { done, value } = await reader.read()
|
|
571
|
+
if (done) break
|
|
572
|
+
if (decoder) {
|
|
573
|
+
scanBuf += decoder.decode(value, { stream: true })
|
|
574
|
+
let nl
|
|
575
|
+
while ((nl = scanBuf.indexOf('\n')) >= 0) {
|
|
576
|
+
const line = scanBuf.slice(0, nl).trim()
|
|
577
|
+
scanBuf = scanBuf.slice(nl + 1)
|
|
578
|
+
if (!line.startsWith('data:') || !line.includes('"usage"')) continue
|
|
579
|
+
try {
|
|
580
|
+
const chunk = JSON.parse(line.slice(5).trim())
|
|
581
|
+
const u = provider.extractUsage(chunk)
|
|
582
|
+
if (u) usage = u
|
|
583
|
+
} catch {
|
|
584
|
+
// incomplete JSON inside a complete SSE line — skip
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!res.write(value)) {
|
|
589
|
+
// Backpressure wait — but a hangup never drains; close wins the
|
|
590
|
+
// race and the aborted check below unwinds the loop instead of
|
|
591
|
+
// hanging on a dead socket (and writing to it).
|
|
592
|
+
await new Promise((resolve) => {
|
|
593
|
+
res.once('drain', resolve)
|
|
594
|
+
res.once('close', resolve)
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
if (clientAbort.signal.aborted) throw clientAbort.signal.reason
|
|
598
|
+
}
|
|
599
|
+
res.end()
|
|
600
|
+
if (usage && isChat && payload) {
|
|
601
|
+
meter.record({ ts: t0, kind: chatUsageKind(payload), model: payload.model ?? null, usage })
|
|
602
|
+
}
|
|
603
|
+
logOut({ status: upstream.status, waitMs, ttfbMs, usage })
|
|
604
|
+
} catch (err) {
|
|
605
|
+
if (err?.clientDisconnected === true) {
|
|
606
|
+
// Caller hung up mid-stream: upstream is aborted, the slot frees in
|
|
607
|
+
// the finally, and res is already gone — nothing left to write.
|
|
608
|
+
logOut({ waitMs, clientGone: true })
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
logOut({ err: String(err?.message ?? err) })
|
|
612
|
+
throw err
|
|
613
|
+
} finally {
|
|
614
|
+
release()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Listen on 127.0.0.1 only. A listen failure must NEVER crash the host:
|
|
620
|
+
* an unhandled 'error' event on the server used to take the whole process
|
|
621
|
+
* down — failures land in runtime.lastError instead (EADDRINUSE typically
|
|
622
|
+
* means another instance already holds the port and still serves traffic,
|
|
623
|
+
* since routes point at the port, not the process).
|
|
624
|
+
*
|
|
625
|
+
* @param {number} port
|
|
626
|
+
* @returns {() => Promise<void>} stop function
|
|
627
|
+
*/
|
|
628
|
+
function listen(port) {
|
|
629
|
+
const server = createServer((req, res) => {
|
|
630
|
+
// Host gate ([8]+[26]): the bind is loopback-only, but the Host header
|
|
631
|
+
// is still caller-controlled — a rebinding DNS name or a spoofed Host
|
|
632
|
+
// must not reach the credential-bearing proxy. Refuse before reading
|
|
633
|
+
// the body or dispatching anywhere. The Origin gate complements it: a
|
|
634
|
+
// browser cross-site drive-by (sendBeacon/text-plain, no preflight)
|
|
635
|
+
// reaches the loopback bind with a browser-correct Host, and would
|
|
636
|
+
// silently burn upstream quota unless its Origin is rejected here.
|
|
637
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
638
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
639
|
+
res.end('bridge: loopback-only (Host must be 127.0.0.1/localhost/::1)')
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
if (!originMatchesHost(req)) {
|
|
643
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
644
|
+
res.end('bridge: cross-origin refused (Origin must match Host)')
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
// Bytes must be collected and decoded ONCE (踩坑 #28): implicit
|
|
648
|
+
// per-chunk utf8 decoding (`rawBody += c`) corrupts any multibyte
|
|
649
|
+
// character straddling a TCP chunk boundary into 3×U+FFFD at a
|
|
650
|
+
// per-request random position — the outbound prefix drifts and the
|
|
651
|
+
// gateway's content-addressed prompt cache can only hit up to the
|
|
652
|
+
// corruption point.
|
|
653
|
+
const chunks = []
|
|
654
|
+
let received = 0
|
|
655
|
+
let oversize = false
|
|
656
|
+
req.on('data', (c) => {
|
|
657
|
+
if (oversize) return // already answered 413; drain and drop the rest
|
|
658
|
+
chunks.push(c)
|
|
659
|
+
received += c.length
|
|
660
|
+
if (received > MAX_BODY_BYTES) {
|
|
661
|
+
oversize = true
|
|
662
|
+
chunks.length = 0
|
|
663
|
+
// Answer, don't destroy: a bare req.destroy() left the caller with
|
|
664
|
+
// a reset connection and no status. Connection: close because the
|
|
665
|
+
// unframed remainder of the upload must not poison keep-alive.
|
|
666
|
+
res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' })
|
|
667
|
+
res.end(JSON.stringify({ error: { message: `request body too large (limit ${MAX_BODY_BYTES} bytes)` } }))
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
req.on('end', () => {
|
|
671
|
+
if (oversize) return
|
|
672
|
+
const rawBody = Buffer.concat(chunks).toString('utf8')
|
|
673
|
+
proxyUpstream(req, res, rawBody).catch((err) => {
|
|
674
|
+
if (res.destroyed || res.writableEnded) return // caller already gone
|
|
675
|
+
if (!res.headersSent) res.writeHead(500)
|
|
676
|
+
res.end(`stream bridge error: ${err.message}`)
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
server.on('error', (err) => {
|
|
681
|
+
runtime.running = false
|
|
682
|
+
runtime.lastError = err?.code ?? String(err?.message ?? err)
|
|
683
|
+
process.stderr.write(`${provider.logPrefix} bridge :${port} unavailable: ${runtime.lastError}(插件其余功能不受影响;若占用者是另一个 dsh 实例,其桥仍会代管本实例流量)\n`)
|
|
684
|
+
})
|
|
685
|
+
server.on('listening', () => {
|
|
686
|
+
runtime.running = true
|
|
687
|
+
runtime.lastError = null
|
|
688
|
+
})
|
|
689
|
+
server.listen(port, '127.0.0.1')
|
|
690
|
+
return () =>
|
|
691
|
+
new Promise((resolve) => {
|
|
692
|
+
server.close(() => resolve())
|
|
693
|
+
server.closeAllConnections?.()
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return { listen }
|
|
698
|
+
}
|