@moxxy/plugin-channel-http 0.26.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/LICENSE +21 -0
- package/dist/channel.d.ts +50 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +164 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +95 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +354 -0
- package/dist/router.js.map +1 -0
- package/package.json +67 -0
- package/src/attach.test.ts +85 -0
- package/src/channel.test.ts +165 -0
- package/src/channel.ts +211 -0
- package/src/index.ts +58 -0
- package/src/integration.test.ts +254 -0
- package/src/router.test.ts +665 -0
- package/src/router.ts +424 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { readRequestBody, bearerTokenMatches } from '@moxxy/sdk/server';
|
|
4
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
5
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
6
|
+
|
|
7
|
+
export const turnRequestSchema = z.object({
|
|
8
|
+
prompt: z.string().min(1),
|
|
9
|
+
model: z.string().optional(),
|
|
10
|
+
systemPrompt: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type TurnRequest = z.infer<typeof turnRequestSchema>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Counting semaphore gating how many turns run concurrently on the single
|
|
17
|
+
* shared session. The HTTP server otherwise accepts unlimited parallel
|
|
18
|
+
* requests, each calling `session.runTurn` on the SAME session — N concurrent
|
|
19
|
+
* clients would spawn N provider streams (cost/memory/connection blowup) and
|
|
20
|
+
* interleave one shared conversation history. `tryAcquire` is non-blocking so
|
|
21
|
+
* excess requests get a fast 429 instead of queueing unbounded.
|
|
22
|
+
*/
|
|
23
|
+
export class TurnLimiter {
|
|
24
|
+
private inUse = 0;
|
|
25
|
+
constructor(private readonly max: number) {}
|
|
26
|
+
tryAcquire(): boolean {
|
|
27
|
+
if (this.inUse >= this.max) return false;
|
|
28
|
+
this.inUse += 1;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
release(): void {
|
|
32
|
+
if (this.inUse > 0) this.inUse -= 1;
|
|
33
|
+
}
|
|
34
|
+
get active(): number {
|
|
35
|
+
return this.inUse;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RouterContext {
|
|
40
|
+
readonly session: Session;
|
|
41
|
+
readonly authToken: string | null;
|
|
42
|
+
readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
|
|
43
|
+
/** Bounds concurrent in-flight turns on the shared session. Optional so
|
|
44
|
+
* existing embedders/tests keep working unbounded; the channel always
|
|
45
|
+
* supplies one. */
|
|
46
|
+
readonly turnLimiter?: TurnLimiter;
|
|
47
|
+
/** Abort an SSE turn whose consumer has stalled (socket open but not reading)
|
|
48
|
+
* for this many ms. Bounds the backpressure park in {@link handleTurnStream}
|
|
49
|
+
* so a half-open client can't keep a provider stream (and billing) alive
|
|
50
|
+
* forever. Optional; defaults to {@link DEFAULT_STREAM_STALL_MS}. */
|
|
51
|
+
readonly streamStallMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Default write-side idle timeout for an SSE turn. A live-but-slow consumer
|
|
55
|
+
* resets this on every flushed write; only a truly stalled (open-but-silent)
|
|
56
|
+
* reader trips it, at which point we abort the turn — the same outcome as a
|
|
57
|
+
* clean disconnect, just without waiting for a TCP close that may never come.
|
|
58
|
+
* Generous so normal inter-event gaps (model thinking, tool calls) don't trip
|
|
59
|
+
* it. */
|
|
60
|
+
export const DEFAULT_STREAM_STALL_MS = 120_000;
|
|
61
|
+
|
|
62
|
+
export type RouteHandler = (req: IncomingMessage, res: ServerResponse, ctx: RouterContext) => Promise<void>;
|
|
63
|
+
|
|
64
|
+
/** Shape an internal/server-side error for the wire: log the full detail
|
|
65
|
+
* server-side and return a stable, generic message so filesystem paths,
|
|
66
|
+
* provider internals, or system-prompt fragments embedded in `err` never leak
|
|
67
|
+
* to a (possibly remote) caller. Client-input validation errors (400) are NOT
|
|
68
|
+
* routed through this — echoing the caller's own malformed input is safe. */
|
|
69
|
+
function publicError(
|
|
70
|
+
ctx: RouterContext,
|
|
71
|
+
logMsg: string,
|
|
72
|
+
err: unknown,
|
|
73
|
+
): string {
|
|
74
|
+
ctx.logger?.warn(logMsg, { err: err instanceof Error ? err.message : String(err) });
|
|
75
|
+
return 'internal error';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Match HTTP request to a handler. Returns null if no route matches. */
|
|
79
|
+
export function routeRequest(req: IncomingMessage): RouteHandler | null {
|
|
80
|
+
const rawUrl = req.url ?? '/';
|
|
81
|
+
// Strip the query string before matching — `/v1/turn/audio?model=...`
|
|
82
|
+
// is the same route as `/v1/turn/audio`. The handler reads query
|
|
83
|
+
// params off req.url itself.
|
|
84
|
+
const pathname = rawUrl.split('?')[0] ?? rawUrl;
|
|
85
|
+
if (req.method === 'GET' && pathname === '/v1/health') return handleHealth;
|
|
86
|
+
if (req.method === 'POST' && pathname === '/v1/turn') return handleTurn;
|
|
87
|
+
if (req.method === 'POST' && pathname === '/v1/turn/stream') return handleTurnStream;
|
|
88
|
+
if (req.method === 'POST' && pathname === '/v1/turn/audio') return handleTurnAudio;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function handleHealth(
|
|
93
|
+
_req: IncomingMessage,
|
|
94
|
+
res: ServerResponse,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkAuth(req: IncomingMessage, expected: string | null): boolean {
|
|
101
|
+
if (!expected) return true;
|
|
102
|
+
// Constant-time compare of the full `Bearer <token>` header so the token
|
|
103
|
+
// isn't recoverable byte-by-byte via response timing.
|
|
104
|
+
return bearerTokenMatches(req.headers.authorization, `Bearer ${expected}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readBody(req: IncomingMessage, max = 64 * 1024): Promise<string> {
|
|
108
|
+
return (await readRequestBody(req, max)).toString('utf8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Audio uploads need a much larger cap than JSON; 10 MB covers a few
|
|
112
|
+
* minutes of Opus voice (Telegram caps voice notes at 50 MB, but
|
|
113
|
+
* realistic notes are well under that). */
|
|
114
|
+
const DEFAULT_AUDIO_MAX = 10 * 1024 * 1024;
|
|
115
|
+
|
|
116
|
+
function reply(res: ServerResponse, status: number, body: unknown): void {
|
|
117
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
118
|
+
res.end(JSON.stringify(body));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Run options forwarded to `session.runTurn`, minus the abort `signal`
|
|
122
|
+
* (which `driveTurn` owns). */
|
|
123
|
+
export type TurnRunOptions = Omit<Parameters<Session['runTurn']>[1] & object, 'signal'>;
|
|
124
|
+
|
|
125
|
+
/** Hard cap on events buffered for a single non-streaming turn. The buffered
|
|
126
|
+
* path holds every event in memory and returns them in one JSON blob; a
|
|
127
|
+
* runaway/tool-looping turn could otherwise grow this without bound. Once
|
|
128
|
+
* exceeded we abort the turn rather than keep allocating. Generous enough that
|
|
129
|
+
* no normal turn hits it. */
|
|
130
|
+
export const MAX_BUFFERED_EVENTS = 50_000;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drive a single buffered (non-streaming) turn to completion: drain every
|
|
134
|
+
* event, abort the turn if the client hangs up (so the model stops billing
|
|
135
|
+
* with nobody listening), and pull out the final assistant message. Shared by
|
|
136
|
+
* `handleTurn` and `handleTurnAudio` so the abort wiring and event/assistant
|
|
137
|
+
* extraction live in exactly one place. Re-throws on turn failure so each
|
|
138
|
+
* caller can shape its own error reply.
|
|
139
|
+
*/
|
|
140
|
+
export async function driveTurn(
|
|
141
|
+
session: Session,
|
|
142
|
+
prompt: string,
|
|
143
|
+
runOptions: TurnRunOptions,
|
|
144
|
+
res: ServerResponse,
|
|
145
|
+
): Promise<{ events: MoxxyEvent[]; assistant: string }> {
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const onClose = (): void => controller.abort();
|
|
148
|
+
res.on('close', onClose);
|
|
149
|
+
|
|
150
|
+
const events: MoxxyEvent[] = [];
|
|
151
|
+
try {
|
|
152
|
+
for await (const event of session.runTurn(prompt, { ...runOptions, signal: controller.signal })) {
|
|
153
|
+
events.push(event);
|
|
154
|
+
if (events.length >= MAX_BUFFERED_EVENTS) {
|
|
155
|
+
controller.abort();
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
res.off('close', onClose);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const finalAssistant = events.findLast((e) => e.type === 'assistant_message');
|
|
164
|
+
const assistant =
|
|
165
|
+
finalAssistant && finalAssistant.type === 'assistant_message' ? finalAssistant.content : '';
|
|
166
|
+
return { events, assistant };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function handleTurn(
|
|
170
|
+
req: IncomingMessage,
|
|
171
|
+
res: ServerResponse,
|
|
172
|
+
ctx: RouterContext,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
if (!checkAuth(req, ctx.authToken)) {
|
|
175
|
+
reply(res, 401, { error: 'unauthorized' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let body: TurnRequest;
|
|
180
|
+
try {
|
|
181
|
+
const raw = await readBody(req);
|
|
182
|
+
body = turnRequestSchema.parse(JSON.parse(raw));
|
|
183
|
+
} catch (err) {
|
|
184
|
+
reply(res, 400, { error: 'bad_request', message: err instanceof Error ? err.message : String(err) });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
|
|
189
|
+
reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let result: { events: MoxxyEvent[]; assistant: string };
|
|
194
|
+
try {
|
|
195
|
+
result = await driveTurn(
|
|
196
|
+
ctx.session,
|
|
197
|
+
body.prompt,
|
|
198
|
+
{
|
|
199
|
+
...(body.model ? { model: body.model } : {}),
|
|
200
|
+
...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}),
|
|
201
|
+
},
|
|
202
|
+
res,
|
|
203
|
+
);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
reply(res, 500, { error: 'turn_failed', message: publicError(ctx, 'http turn failed', err) });
|
|
206
|
+
return;
|
|
207
|
+
} finally {
|
|
208
|
+
ctx.turnLimiter?.release();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
reply(res, 200, { events: result.events, assistant: result.assistant });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Audio-in turn. Designed for iOS Shortcuts and curl: the client POSTs
|
|
216
|
+
* raw audio bytes with `Content-Type: audio/<format>`. Optional query
|
|
217
|
+
* params (`model`, `language`, `systemPrompt`) tune the run.
|
|
218
|
+
*
|
|
219
|
+
* The session must have an active Transcriber registered (e.g. via
|
|
220
|
+
* `@moxxy/plugin-stt-whisper`); without one the endpoint returns 503
|
|
221
|
+
* rather than transparently dropping the audio.
|
|
222
|
+
*/
|
|
223
|
+
export async function handleTurnAudio(
|
|
224
|
+
req: IncomingMessage,
|
|
225
|
+
res: ServerResponse,
|
|
226
|
+
ctx: RouterContext,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
if (!checkAuth(req, ctx.authToken)) {
|
|
229
|
+
reply(res, 401, { error: 'unauthorized' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const transcriber = ctx.session.transcribers.tryGetActive();
|
|
234
|
+
if (!transcriber) {
|
|
235
|
+
reply(res, 503, {
|
|
236
|
+
error: 'no_transcriber',
|
|
237
|
+
message:
|
|
238
|
+
'No active Transcriber on this session. Install @moxxy/plugin-stt-whisper (or another transcriber plugin) and activate it before POSTing audio.',
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const contentType = (req.headers['content-type'] ?? '').toLowerCase();
|
|
244
|
+
if (!contentType.startsWith('audio/')) {
|
|
245
|
+
reply(res, 415, {
|
|
246
|
+
error: 'unsupported_media_type',
|
|
247
|
+
message: "Expected Content-Type: audio/* (e.g. audio/ogg, audio/m4a, audio/mpeg).",
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let bytes: Buffer;
|
|
253
|
+
try {
|
|
254
|
+
bytes = await readRequestBody(req, DEFAULT_AUDIO_MAX);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
reply(res, 413, { error: 'payload_too_large', message: err instanceof Error ? err.message : String(err) });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (bytes.length === 0) {
|
|
260
|
+
reply(res, 400, { error: 'empty_body', message: 'audio body is empty' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Pull tuning params off the query string — keeping them out of the
|
|
265
|
+
// body lets the payload remain raw audio (cleanest curl / Shortcut flow).
|
|
266
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
267
|
+
const model = url.searchParams.get('model') ?? undefined;
|
|
268
|
+
const language = url.searchParams.get('language') ?? undefined;
|
|
269
|
+
const promptHint = url.searchParams.get('prompt') ?? undefined;
|
|
270
|
+
const systemPrompt = url.searchParams.get('systemPrompt') ?? undefined;
|
|
271
|
+
|
|
272
|
+
let transcript: string;
|
|
273
|
+
try {
|
|
274
|
+
const result = await transcriber.transcribe(new Uint8Array(bytes), {
|
|
275
|
+
mimeType: contentType,
|
|
276
|
+
...(language ? { language } : {}),
|
|
277
|
+
...(promptHint ? { prompt: promptHint } : {}),
|
|
278
|
+
});
|
|
279
|
+
transcript = result.text.trim();
|
|
280
|
+
} catch (err) {
|
|
281
|
+
reply(res, 502, { error: 'transcription_failed', message: publicError(ctx, 'http audio transcription failed', err) });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!transcript) {
|
|
285
|
+
reply(res, 422, { error: 'empty_transcript', message: 'transcriber returned empty text' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
|
|
290
|
+
reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let result: { events: MoxxyEvent[]; assistant: string };
|
|
295
|
+
try {
|
|
296
|
+
result = await driveTurn(
|
|
297
|
+
ctx.session,
|
|
298
|
+
transcript,
|
|
299
|
+
{
|
|
300
|
+
...(model ? { model } : {}),
|
|
301
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
302
|
+
},
|
|
303
|
+
res,
|
|
304
|
+
);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
reply(res, 500, { error: 'turn_failed', message: publicError(ctx, 'http audio turn failed', err) });
|
|
307
|
+
return;
|
|
308
|
+
} finally {
|
|
309
|
+
ctx.turnLimiter?.release();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
reply(res, 200, { transcript, events: result.events, assistant: result.assistant });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function handleTurnStream(
|
|
316
|
+
req: IncomingMessage,
|
|
317
|
+
res: ServerResponse,
|
|
318
|
+
ctx: RouterContext,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
if (!checkAuth(req, ctx.authToken)) {
|
|
321
|
+
reply(res, 401, { error: 'unauthorized' });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let body: TurnRequest;
|
|
326
|
+
try {
|
|
327
|
+
const raw = await readBody(req);
|
|
328
|
+
body = turnRequestSchema.parse(JSON.parse(raw));
|
|
329
|
+
} catch (err) {
|
|
330
|
+
reply(res, 400, { error: 'bad_request', message: err instanceof Error ? err.message : String(err) });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
|
|
335
|
+
reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
res.writeHead(200, {
|
|
340
|
+
'content-type': 'text/event-stream',
|
|
341
|
+
'cache-control': 'no-cache',
|
|
342
|
+
connection: 'keep-alive',
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Abort the turn when the client hangs up — without this the model keeps
|
|
346
|
+
// generating (and billing) with nothing consuming the SSE stream.
|
|
347
|
+
const controller = new AbortController();
|
|
348
|
+
const onClose = (): void => controller.abort();
|
|
349
|
+
res.on('close', onClose);
|
|
350
|
+
|
|
351
|
+
// Degrade, never crash: an unhandled 'error' on a ServerResponse is fatal
|
|
352
|
+
// (uncaughtException). Treat any response-socket error like a disconnect —
|
|
353
|
+
// abort the turn and let the finally clean up — instead of letting it
|
|
354
|
+
// propagate and take down the whole runner process.
|
|
355
|
+
const onError = (err: unknown): void => {
|
|
356
|
+
ctx.logger?.warn('http stream response error', {
|
|
357
|
+
err: err instanceof Error ? err.message : String(err),
|
|
358
|
+
});
|
|
359
|
+
controller.abort();
|
|
360
|
+
};
|
|
361
|
+
res.on('error', onError);
|
|
362
|
+
|
|
363
|
+
// A stalled-but-open consumer (reads nothing, never closes the socket) would
|
|
364
|
+
// otherwise park `safeWrite` on 'drain' indefinitely, keeping the provider
|
|
365
|
+
// stream — and billing — alive forever. The request/keep-alive timeouts cover
|
|
366
|
+
// header/body reads, NOT an in-progress response. `res.setTimeout` fires on
|
|
367
|
+
// write-side socket inactivity; a live-but-slow consumer resets it on every
|
|
368
|
+
// flushed write, so only a true stall trips it. We abort (same as a close).
|
|
369
|
+
const stallMs = ctx.streamStallMs ?? DEFAULT_STREAM_STALL_MS;
|
|
370
|
+
if (stallMs > 0) {
|
|
371
|
+
res.setTimeout(stallMs, () => {
|
|
372
|
+
ctx.logger?.warn('http stream consumer stalled; aborting turn', { stallMs });
|
|
373
|
+
controller.abort();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Respect TCP backpressure: a slow/stalled-but-open consumer would otherwise
|
|
378
|
+
// let Node's internal write queue grow unbounded (a tool-heavy turn emits
|
|
379
|
+
// thousands of large events) until OOM. When `write` returns false we pause
|
|
380
|
+
// until 'drain', escaping early if the turn is aborted (client gone/stalled).
|
|
381
|
+
const safeWrite = async (chunk: string): Promise<void> => {
|
|
382
|
+
if (res.writableEnded || res.destroyed) return;
|
|
383
|
+
if (res.write(chunk)) return;
|
|
384
|
+
if (controller.signal.aborted) return;
|
|
385
|
+
await new Promise<void>((resolve) => {
|
|
386
|
+
const done = (): void => {
|
|
387
|
+
res.off('drain', done);
|
|
388
|
+
controller.signal.removeEventListener('abort', done);
|
|
389
|
+
resolve();
|
|
390
|
+
};
|
|
391
|
+
res.once('drain', done);
|
|
392
|
+
controller.signal.addEventListener('abort', done, { once: true });
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
for await (const event of ctx.session.runTurn(body.prompt, {
|
|
398
|
+
...(body.model ? { model: body.model } : {}),
|
|
399
|
+
...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}),
|
|
400
|
+
signal: controller.signal,
|
|
401
|
+
})) {
|
|
402
|
+
await safeWrite(`data: ${JSON.stringify(event)}\n\n`);
|
|
403
|
+
}
|
|
404
|
+
await safeWrite('data: [DONE]\n\n');
|
|
405
|
+
} catch (err) {
|
|
406
|
+
await safeWrite(
|
|
407
|
+
`event: error\ndata: ${JSON.stringify({ message: publicError(ctx, 'http stream turn failed', err) })}\n\n`,
|
|
408
|
+
);
|
|
409
|
+
} finally {
|
|
410
|
+
res.off('close', onClose);
|
|
411
|
+
res.off('error', onError);
|
|
412
|
+
// Disarm the stall timer so it can't fire (and abort) after the stream has
|
|
413
|
+
// already settled, and so the timer doesn't keep a handle alive.
|
|
414
|
+
if (stallMs > 0) {
|
|
415
|
+
try { res.setTimeout(0); } catch { /* response already torn down */ }
|
|
416
|
+
}
|
|
417
|
+
ctx.turnLimiter?.release();
|
|
418
|
+
// The client may have hung up mid-stream; end() on an already-ended/
|
|
419
|
+
// destroyed response throws ERR_STREAM_WRITE_AFTER_END.
|
|
420
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
421
|
+
try { res.end(); } catch { /* socket already gone */ }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|