@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/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
+ }