@cyrilmarin/dsh-lemonade 0.2.1 → 0.4.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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Depth-bounded JSON parser for proxied Lemonade request bodies.
3
+ *
4
+ * A hostile client can send a "JSON bomb": a tree whose width is small but
5
+ * whose depth is enormous. A naive `JSON.parse` walks such input with a stack
6
+ * proportional to the depth, which can blow the V8 stack. This parser walks it
7
+ * with an explicit recursion depth cap (`maxDepth`) and rejects deeper input
8
+ * with a {@link JsonParseError} carrying the byte offset of the offending
9
+ * token, so the caller can surface a precise message without the request ever
10
+ * reaching a downstream consumer.
11
+ *
12
+ * Only what the Lemonade proxy needs is supported: objects, arrays, strings
13
+ * (with escapes), numbers, and the `true`/`false`/`null` literals. Whitespace
14
+ * between tokens is skipped. Trailing characters after the value are rejected.
15
+ *
16
+ * @module dsh-lemonade-provider/json-parse
17
+ */
18
+ /** Error thrown by {@link parseJsonValue} on malformed input or depth overflow. */
19
+ export class JsonParseError extends Error {
20
+ /** Byte offset of the offending token (or where parsing ended). */
21
+ position;
22
+ /** True when the cap on nesting depth was exceeded rather than the text being malformed. */
23
+ isDepthOverflow;
24
+ constructor(message, position, isDepthOverflow = false) {
25
+ super(message);
26
+ this.name = 'JsonParseError';
27
+ this.position = position;
28
+ this.isDepthOverflow = isDepthOverflow;
29
+ }
30
+ }
31
+ /** Parse one UTF-8 JSON value from `text`.
32
+ * @param text - the raw request body.
33
+ * @param options - parser options; only `maxDepth` is honoured today.
34
+ * @returns the parsed value (never `undefined`; use `parseJsonValue` for that).
35
+ * @throws {JsonParseError} when the text is not a single valid JSON value or exceeds `maxDepth`.
36
+ */
37
+ export function parseJsonValue(text, options) {
38
+ const maxDepth = options?.maxDepth ?? 64;
39
+ const len = text.length;
40
+ let pos = 0;
41
+ const skipWhitespace = () => {
42
+ while (pos < len) {
43
+ const c = text[pos];
44
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r')
45
+ pos++;
46
+ else
47
+ break;
48
+ }
49
+ };
50
+ const fail = (message, isDepthOverflow = false) => {
51
+ throw new JsonParseError(message, pos, isDepthOverflow);
52
+ };
53
+ const parseValue = (depth) => {
54
+ skipWhitespace();
55
+ const c = text[pos] ?? '';
56
+ if (pos >= len)
57
+ fail('unexpected end of input');
58
+ if (c === '{')
59
+ return parseObject(depth + 1);
60
+ if (c === '[')
61
+ return parseArray(depth + 1);
62
+ if (c === '"')
63
+ return parseString();
64
+ if (c === 't' || c === 'f')
65
+ return parseBooleanLiteral();
66
+ if (c === 'n')
67
+ return parseNullLiteral();
68
+ if (c === '-' || (c >= '0' && c <= '9'))
69
+ return parseNumber();
70
+ fail('unexpected character "' + c + '"');
71
+ };
72
+ const parseObject = (depth) => {
73
+ if (depth > maxDepth)
74
+ fail('request body exceeds JSON depth ' + maxDepth, true);
75
+ const out = {};
76
+ pos++; // consume '{'
77
+ skipWhitespace();
78
+ if (text[pos] === '}') {
79
+ pos++;
80
+ return out;
81
+ }
82
+ for (;;) {
83
+ skipWhitespace();
84
+ if (text[pos] !== '"')
85
+ fail('expected object key string');
86
+ const key = parseString();
87
+ skipWhitespace();
88
+ if (text[pos] !== ':')
89
+ fail('expected ":" after key');
90
+ pos++;
91
+ out[key] = parseValue(depth);
92
+ skipWhitespace();
93
+ const close = text[pos];
94
+ if (close === ',') {
95
+ pos++;
96
+ continue;
97
+ }
98
+ if (close === '}') {
99
+ pos++;
100
+ return out;
101
+ }
102
+ fail('expected "," or "}" in object');
103
+ }
104
+ };
105
+ const parseArray = (depth) => {
106
+ if (depth > maxDepth)
107
+ fail('request body exceeds JSON depth ' + maxDepth, true);
108
+ const out = [];
109
+ pos++; // consume '['
110
+ skipWhitespace();
111
+ if (text[pos] === ']') {
112
+ pos++;
113
+ return out;
114
+ }
115
+ for (;;) {
116
+ out.push(parseValue(depth));
117
+ skipWhitespace();
118
+ const close = text[pos];
119
+ if (close === ',') {
120
+ pos++;
121
+ continue;
122
+ }
123
+ if (close === ']') {
124
+ pos++;
125
+ return out;
126
+ }
127
+ fail('expected "," or "]" in array');
128
+ }
129
+ };
130
+ const parseString = () => {
131
+ pos++; // consume opening '"'
132
+ let out = '';
133
+ for (;;) {
134
+ if (pos >= len)
135
+ fail('unterminated string');
136
+ const c = text[pos++];
137
+ if (c === '"')
138
+ return out;
139
+ if (c === '\\') {
140
+ if (pos >= len)
141
+ fail('unterminated escape');
142
+ const e = text[pos++];
143
+ switch (e) {
144
+ case '"':
145
+ out += '"';
146
+ break;
147
+ case '\\':
148
+ out += '\\';
149
+ break;
150
+ case '/':
151
+ out += '/';
152
+ break;
153
+ case 'b':
154
+ out += '\b';
155
+ break;
156
+ case 'f':
157
+ out += '\f';
158
+ break;
159
+ case 'n':
160
+ out += '\n';
161
+ break;
162
+ case 'r':
163
+ out += '\r';
164
+ break;
165
+ case 't':
166
+ out += '\t';
167
+ break;
168
+ case 'u': {
169
+ const hex = text.slice(pos, pos + 4);
170
+ if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex))
171
+ fail('invalid unicode escape');
172
+ out += String.fromCharCode(parseInt(hex, 16));
173
+ pos += 4;
174
+ break;
175
+ }
176
+ default: fail('invalid escape \\' + e);
177
+ }
178
+ }
179
+ else {
180
+ out += c;
181
+ }
182
+ }
183
+ };
184
+ const parseBooleanLiteral = () => {
185
+ if (text.startsWith('true', pos)) {
186
+ pos += 4;
187
+ return true;
188
+ }
189
+ if (text.startsWith('false', pos)) {
190
+ pos += 5;
191
+ return false;
192
+ }
193
+ throw fail('invalid literal');
194
+ };
195
+ const parseNullLiteral = () => {
196
+ if (text.startsWith('null', pos)) {
197
+ pos += 4;
198
+ return null;
199
+ }
200
+ throw fail('invalid literal');
201
+ };
202
+ const parseNumber = () => {
203
+ const start = pos;
204
+ if (text[pos] === undefined || text[pos] === '-') {
205
+ if (text[pos] === '-')
206
+ pos++;
207
+ }
208
+ while (pos < len) {
209
+ const c = text[pos];
210
+ if (c === undefined)
211
+ break;
212
+ if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-')
213
+ pos++;
214
+ else
215
+ break;
216
+ }
217
+ const numText = text.slice(start, pos);
218
+ const num = Number(numText);
219
+ if (!Number.isFinite(num))
220
+ fail('invalid number "' + numText + '"');
221
+ return num;
222
+ };
223
+ skipWhitespace();
224
+ const value = parseValue(0);
225
+ skipWhitespace();
226
+ if (pos !== len)
227
+ fail('trailing characters after JSON value');
228
+ return value;
229
+ }
230
+ //# sourceMappingURL=json-parse.js.map
package/lib/server-api.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { attributionHeaders } from '@deepseek-ai/dsh-llm';
2
2
  import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
3
+ import { JsonParseError, parseJsonValue } from './json-parse.js';
3
4
  /** Route prefix registered on ctx.webServer. */
4
5
  export const API_ROUTE = '/dsh-lemonade/api';
5
6
  /** Maximum accepted request body and proxied response body in bytes. */
6
7
  export const MAX_BODY_BYTES = 1_000_000;
8
+ /** Maximum JSON nesting depth accepted in a proxied request body. */
9
+ export const MAX_JSON_DEPTH = 64;
10
+ /** Maximum path length after the route prefix (op + args). */
11
+ export const MAX_SEGMENTS = 5;
7
12
  /** Fetch timeout for proxied Lemonade calls. */
8
13
  export const API_TIMEOUT_MS = 10_000;
9
14
  const TIMEOUT_CODE = 'LEMONADE_API_TIMEOUT';
@@ -117,6 +122,12 @@ function resolveTarget(op, args, query, body) {
117
122
  throw new RequestError('model required', 'INVALID_REQUEST', 400);
118
123
  return { method: 'POST', url: '/v1/delete', body: { model_name: mn } };
119
124
  }
125
+ case 'modelInfo': {
126
+ const id = args[0];
127
+ if (id === undefined || id.length === 0)
128
+ throw new RequestError('model id required', 'INVALID_REQUEST', 400);
129
+ return { method: 'GET', url: '/v1/models/' + encodeURIComponent(id) + '/info' };
130
+ }
120
131
  case 'checkUpdates': return { method: 'POST', url: '/v1/models/check-updates' };
121
132
  case 'registrySearch':
122
133
  return {
@@ -143,6 +154,19 @@ function resolveTarget(op, args, query, body) {
143
154
  url: '/v1/pull',
144
155
  body: pick(['model_name', 'recipe', 'checkpoint', 'checkpoints', 'reasoning', 'vision', 'embedding', 'reranking', 'mmproj', 'stream', 'subscribe']),
145
156
  };
157
+ case 'loraList': return { method: 'GET', url: '/v1/extensions/lora/list' };
158
+ case 'loraLoad': {
159
+ const adapter = args[0];
160
+ if (adapter === undefined || adapter.length === 0)
161
+ throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
162
+ return { method: 'POST', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
163
+ }
164
+ case 'loraUnload': {
165
+ const adapter = args[0];
166
+ if (adapter === undefined || adapter.length === 0)
167
+ throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
168
+ return { method: 'DELETE', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
169
+ }
146
170
  case 'downloads': return { method: 'GET', url: '/v1/downloads' };
147
171
  case 'downloadsControl': return { method: 'POST', url: '/v1/downloads/control', body: pick(['id', 'action']) };
148
172
  case 'stats': return { method: 'GET', url: '/v1/stats' };
@@ -190,6 +214,23 @@ async function readResponseText(response) {
190
214
  * @param signal - optional caller cancellation.
191
215
  */
192
216
  export async function serveLemonadeApi(cfg, method, op, args, query, body, signal) {
217
+ // Batch operations are intercepted before resolveTarget: they are not a
218
+ // single Lemonade endpoint but a sequence of one dispatched through postLemonade.
219
+ if (op === 'batchLoad' || op === 'batchUnload' || op === 'batchDelete') {
220
+ const ids = collectBatchIds(body);
221
+ if (ids.length === 0) {
222
+ return errResult('batch ' + op + ' requires a non-empty "models" list', 'INVALID_REQUEST', 400);
223
+ }
224
+ const build = (id) => {
225
+ switch (op) {
226
+ case 'batchLoad': return { method: 'POST', url: '/v1/load', body: { model_name: id } };
227
+ case 'batchUnload': return { method: 'POST', url: '/v1/unload', body: { model_name: id } };
228
+ default: return { method: 'POST', url: '/v1/delete', body: { model_name: id } };
229
+ }
230
+ };
231
+ const kind = op === 'batchLoad' ? 'load' : op === 'batchUnload' ? 'unload' : 'delete';
232
+ return batchRun(cfg, kind, ids, build);
233
+ }
193
234
  let target;
194
235
  try {
195
236
  target = resolveTarget(op, args, query, body);
@@ -202,11 +243,79 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
202
243
  if (method !== target.method) {
203
244
  return errResult('method ' + method + ' not allowed for ' + op + ' (expected ' + target.method + ')', 'METHOD_NOT_ALLOWED', 405);
204
245
  }
205
- // Per-endpoint key selection: internal/control endpoints (/internal/*, /metrics)
206
- // authenticate with the admin key (falling back to the regular key, which
207
- // lemonade accepts for /metrics); regular endpoints use the regular key (the
208
- // admin key is a superior credential and also works when it is the only one set).
209
- const admin = isAdminOp(op);
246
+ return postLemonade(cfg, target.url, target.method, target.body, isAdminOp(op), signal);
247
+ }
248
+ /**
249
+ * Extract the target model ids from a batch-operation body. Lemonade uses the
250
+ * `model_name` field per spec, so accept both `models` and `model_name` (single
251
+ * or list) and ignore any other keys.
252
+ */
253
+ function collectBatchIds(body) {
254
+ const record = asRecord(body);
255
+ const collect = (key) => {
256
+ const raw = record[key];
257
+ if (Array.isArray(raw)) {
258
+ return raw.filter((v) => typeof v === 'string' && v.length > 0);
259
+ }
260
+ return typeof raw === 'string' && raw.length > 0 ? [raw] : [];
261
+ };
262
+ const ids = collect('models').concat(collect('model_name'));
263
+ // De-duplicate while preserving order.
264
+ const seen = new Set();
265
+ return ids.filter((id) => {
266
+ if (seen.has(id))
267
+ return false;
268
+ seen.add(id);
269
+ return true;
270
+ });
271
+ }
272
+ /**
273
+ * Run a batch of sub-calls against Lemonade, dispatching each sequentially
274
+ * through {@link postLemonade} (reusing per-endpoint key selection, the
275
+ * /api-vs-root rule, the shared timer, and status/body normalization). Each
276
+ * sub-call error is recorded alongside the successful ones rather than
277
+ * aborting the whole batch; the wire result carries `ok: true` with a
278
+ * structured payload listing every item and its status.
279
+ *
280
+ * @param cfg - connection facts (thunks resolved per call).
281
+ * @param kind - the operation class ("load", "unload", "delete").
282
+ * @param ids - the identifiers to apply the operation to.
283
+ * @param build - build one sub-call target for an id (path/body), or throw.
284
+ */
285
+ async function batchRun(cfg, kind, ids, build) {
286
+ const calls = [];
287
+ for (const id of ids) {
288
+ try {
289
+ calls.push({ id, target: build(id) });
290
+ }
291
+ catch (error) {
292
+ if (error instanceof RequestError)
293
+ return errResult(error.message, error.code, error.status);
294
+ throw error;
295
+ }
296
+ }
297
+ const results = [];
298
+ for (const call of calls) {
299
+ const wire = await postLemonade(cfg, call.target.url, call.target.method, call.target.body, false, undefined);
300
+ results.push({
301
+ id: call.id,
302
+ ok: wire.ok,
303
+ message: wire.ok ? undefined : (wire.error && (wire.error.message || wire.error.code)),
304
+ status: wire.ok ? undefined : wire.error.status,
305
+ });
306
+ }
307
+ const failed = results.filter((r) => !r.ok).length;
308
+ return okResult({ kind, total: results.length, failed, results });
309
+ }
310
+ /**
311
+ * Perform the HTTP round-trip to Lemonade and normalize the response into a
312
+ * wire result. Shared by {@link serveLemonadeApi} (single op) and the batch
313
+ * ops: it resolves the per-endpoint API key (from the target url), builds the
314
+ * fully-qualified URL (root vs /api prefix), runs the fetch under the shared
315
+ * timer, and applies the status/body normalization. `admin` selects the admin
316
+ * vs regular credential and drives the root-path rule.
317
+ */
318
+ async function postLemonade(cfg, url, method, body, admin, signal) {
210
319
  const regularKey = await cfg.resolveKey(cfg.apiKeyRef());
211
320
  const adminKey = await cfg.resolveKey(cfg.adminApiKeyRef());
212
321
  const apiKey = admin ? (adminKey ?? regularKey) : (regularKey ?? adminKey);
@@ -216,21 +325,21 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
216
325
  const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
217
326
  // /internal/*, /live and /metrics are ROOT-level (no /api, no /v1) per spec;
218
327
  // everything else is served under the /api prefix.
219
- const isRootPath = target.url.startsWith('/internal/') || target.url === '/live' || target.url === '/metrics';
328
+ const isRootPath = url.startsWith('/internal/') || url === '/live' || url === '/metrics';
220
329
  const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
221
- const url = base + target.url;
330
+ const fullUrl = base + url;
222
331
  const headers = { accept: 'application/json', ...attributionHeaders() };
223
332
  if (apiKey !== undefined)
224
333
  headers.authorization = 'Bearer ' + apiKey;
225
- if (target.body !== undefined)
334
+ if (body !== undefined)
226
335
  headers['content-type'] = 'application/json';
227
336
  const timer = deadline(signal, API_TIMEOUT_MS, TIMEOUT_CODE);
228
337
  let response;
229
338
  try {
230
- response = await fetch(url, {
231
- method: target.method,
339
+ response = await fetch(fullUrl, {
340
+ method,
232
341
  headers,
233
- ...(target.body === undefined ? {} : { body: JSON.stringify(target.body) }),
342
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
234
343
  signal: timer.signal,
235
344
  });
236
345
  }
@@ -240,10 +349,17 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
240
349
  }
241
350
  if (signal !== undefined && signal.aborted)
242
351
  return errResult('Lemonade request aborted by caller', 'ABORTED');
243
- return errResult('could not reach ' + url, 'TRANSPORT');
352
+ return errResult('could not reach ' + fullUrl, 'TRANSPORT');
244
353
  }
245
354
  finally {
246
- timer[Symbol.dispose]();
355
+ // Wrap cleanup in try/catch so a timer teardown error (it should never
356
+ // throw) can't mask the original throw/return from the block above.
357
+ try {
358
+ timer[Symbol.dispose]();
359
+ }
360
+ catch {
361
+ // ignore — the timer is disposable and teardown errors are non-fatal
362
+ }
247
363
  }
248
364
  const decoded = await readResponseText(response);
249
365
  if (decoded.tooLarge) {
@@ -251,7 +367,7 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
251
367
  }
252
368
  let value = null;
253
369
  if (decoded.text.length > 0) {
254
- if (op === 'metrics') {
370
+ if (url === '/metrics') {
255
371
  // Prometheus text exposition format, not JSON.
256
372
  value = decoded.text;
257
373
  }
@@ -260,7 +376,7 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
260
376
  value = JSON.parse(decoded.text);
261
377
  }
262
378
  catch {
263
- return errResult('Lemonade answered with non-JSON at ' + url, 'BAD_RESPONSE', 502);
379
+ return errResult('Lemonade answered with non-JSON at ' + fullUrl, 'BAD_RESPONSE', 502);
264
380
  }
265
381
  }
266
382
  }
@@ -286,6 +402,36 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
286
402
  }
287
403
  return okResult(value);
288
404
  }
405
+ /**
406
+ * Parse one request body as JSON with a hard nesting-depth cap, so a hostile
407
+ * client cannot send a "JSON bomb" (a tree whose width is small but whose
408
+ * depth is enormous, whose naive JSON.parse stack would blow). Throws a
409
+ * RequestError (INVALID_REQUEST 400) on malformed input or on exceeding the
410
+ * depth cap, carrying the byte offset of the offending token so the client can
411
+ * point at the exact character.
412
+ */
413
+ /**
414
+ * Parse one request body as JSON with a hard nesting-depth cap, so a hostile
415
+ * client cannot send a "JSON bomb" (a tree whose width is small but whose
416
+ * depth is enormous, whose naive JSON.parse stack would blow). A malformed or
417
+ * too-deep body becomes a {@link RequestError} (INVALID_REQUEST 400) carrying
418
+ * the byte offset of the offending token, so the client can point at the exact
419
+ * character; a body over {@link MAX_BODY_BYTES} stays a 413.
420
+ */
421
+ function parseJsonRequest(text) {
422
+ try {
423
+ return parseJsonValue(text, { maxDepth: MAX_JSON_DEPTH });
424
+ }
425
+ catch (error) {
426
+ if (error instanceof JsonParseError) {
427
+ const detail = error.isDepthOverflow
428
+ ? 'request body exceeds JSON depth ' + MAX_JSON_DEPTH
429
+ : 'request body is not valid JSON near offset ' + error.position;
430
+ throw new RequestError(detail, 'INVALID_REQUEST', 400);
431
+ }
432
+ throw error;
433
+ }
434
+ }
289
435
  async function readRequestBody(req) {
290
436
  const chunks = [];
291
437
  let size = 0;
@@ -300,12 +446,7 @@ async function readRequestBody(req) {
300
446
  const text = Buffer.concat(chunks).toString('utf8');
301
447
  if (text.trim().length === 0)
302
448
  return undefined;
303
- try {
304
- return JSON.parse(text);
305
- }
306
- catch {
307
- throw new RequestError('request body is not valid JSON', 'INVALID_REQUEST', 400);
308
- }
449
+ return parseJsonRequest(text);
309
450
  }
310
451
  function writeJson(res, status, value) {
311
452
  res.writeHead(status, {
@@ -324,6 +465,141 @@ function writeJson(res, status, value) {
324
465
  * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
325
466
  * held open and closed when the browser disconnects.
326
467
  */
468
+ /** Write one Server-Sent-Event line (`event:` + `data:`), splitting multi-line payloads. */
469
+ function writeSse(res, event, data) {
470
+ res.write('event: ' + event + '\n');
471
+ const parts = data.replace(/\r\n/g, '\n').split('\n');
472
+ for (const line of parts) {
473
+ res.write('data: ' + line + '\n');
474
+ }
475
+ res.write('\n');
476
+ }
477
+ /**
478
+ * Serve the Lemonade server log stream to the browser as an SSE feed.
479
+ *
480
+ * The browser holds a plain HTTP/SSE connection to the host proxy (which owns
481
+ * the credentials); the proxy, in turn, opens a WebSocket *client* to the
482
+ * Lemonade log endpoint and re-emits every upstream message as an SSE event. A
483
+ * client→Lemonade relay is mandatory here: Node's runtime exposes no
484
+ * server-side WebSocket API and the `ws` package is not installable, so the
485
+ * relay can never accept an upgrade itself. The log port is discovered from
486
+ * /v1/health (`websocket_port`, which shares the Realtime Audio port and
487
+ * therefore differs from the main API port), the log message is authenticated
488
+ * through the regular API key, and the response is held open and closed when
489
+ * the browser disconnects.
490
+ */
491
+ async function serveLogsStream(cfg, res, signal) {
492
+ const key = await cfg.resolveKey(cfg.apiKeyRef());
493
+ const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
494
+ const healthUrl = configured + '/v1/health';
495
+ const openHeaders = {
496
+ 'content-type': 'text/event-stream; charset=utf-8',
497
+ 'cache-control': 'no-store',
498
+ connection: 'close',
499
+ // Disable proxy buffering that would defeat the streaming contract.
500
+ 'x-accel-buffering': 'no',
501
+ };
502
+ res.writeHead(200, openHeaders);
503
+ writeSse(res, 'comment', 'streaming logs');
504
+ // Discover the WebSocket port from health before touching the log endpoint.
505
+ let health;
506
+ try {
507
+ const resp = await fetch(healthUrl, { headers: { accept: 'application/json' } });
508
+ const text = await resp.text().catch(() => '');
509
+ if (text.trim().length > 0) {
510
+ const parsed = JSON.parse(text);
511
+ health = parsed;
512
+ }
513
+ }
514
+ catch {
515
+ writeSse(res, 'error', 'could not reach ' + healthUrl);
516
+ res.end();
517
+ return;
518
+ }
519
+ const port = health && typeof health.websocket_port === 'number' ? health.websocket_port : undefined;
520
+ if (!port || port <= 0) {
521
+ writeSse(res, 'error', 'Lemonade server did not advertise a websocket_port');
522
+ res.end();
523
+ return;
524
+ }
525
+ // Reuse the host of the configured base URL, but speak ws over the log port.
526
+ const base = new URL(configured);
527
+ const wsUrl = 'ws://' + base.hostname + ':' + port + '/logs/stream';
528
+ let clientClosed = false;
529
+ const upstreamAbort = () => {
530
+ if (clientClosed)
531
+ return;
532
+ try {
533
+ ws.close(1001);
534
+ }
535
+ catch { /* noop */ }
536
+ };
537
+ if (signal) {
538
+ if (signal.aborted)
539
+ upstreamAbort();
540
+ else
541
+ signal.addEventListener?.('abort', upstreamAbort);
542
+ }
543
+ // When the browser drops the SSE, close the upstream WebSocket cleanly.
544
+ res.on('close', () => {
545
+ clientClosed = true;
546
+ if (signal)
547
+ signal.removeEventListener?.('abort', upstreamAbort);
548
+ try {
549
+ ws.close(1001);
550
+ }
551
+ catch { /* noop */ }
552
+ });
553
+ const ws = new WebSocket(wsUrl);
554
+ const decode = (data) => {
555
+ if (typeof data === 'string')
556
+ return data;
557
+ if (data instanceof ArrayBuffer)
558
+ return new TextDecoder().decode(data);
559
+ return data.toString('utf8');
560
+ };
561
+ ws.addEventListener('open', () => {
562
+ if (clientClosed)
563
+ return;
564
+ try {
565
+ ws.send(JSON.stringify({ type: 'logs.subscribe', after_seq: null, ...(key !== undefined ? { key } : {}) }));
566
+ }
567
+ catch {
568
+ writeSse(res, 'error', 'failed to subscribe to the log stream');
569
+ res.end();
570
+ return;
571
+ }
572
+ writeSse(res, 'comment', 'connected');
573
+ });
574
+ ws.addEventListener('message', (event) => {
575
+ if (clientClosed)
576
+ return;
577
+ const raw = decode(event.data);
578
+ try {
579
+ JSON.parse(raw);
580
+ writeSse(res, 'data', raw);
581
+ }
582
+ catch {
583
+ writeSse(res, 'data', raw);
584
+ }
585
+ });
586
+ ws.addEventListener('close', (event) => {
587
+ if (clientClosed)
588
+ return;
589
+ writeSse(res, 'error', 'log stream closed by Lemonade (code ' + event.code + (event.reason ? ' ' + event.reason : '') + ')');
590
+ res.end();
591
+ });
592
+ ws.addEventListener('error', () => {
593
+ if (clientClosed)
594
+ return;
595
+ writeSse(res, 'error', 'log stream error');
596
+ try {
597
+ ws.close(1001);
598
+ }
599
+ catch { /* noop */ }
600
+ res.end();
601
+ });
602
+ }
327
603
  /**
328
604
  * Build the node:http handler mounting the Lemonade-specific API proxy at the
329
605
  * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
@@ -331,17 +607,31 @@ function writeJson(res, status, value) {
331
607
  */
332
608
  export function createLemonadeApiHandler(cfg) {
333
609
  return async (req, res) => {
610
+ const url = new URL(req.url ?? '/', 'http://localhost');
611
+ const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
612
+ const segments = rest.split('/').filter((part) => part.length > 0);
613
+ if (segments.length > MAX_SEGMENTS) {
614
+ throw new RequestError('request path is too deep (' + segments.length + '/' + MAX_SEGMENTS + ')', 'INVALID_REQUEST', 400);
615
+ }
616
+ const op = segments[0] ?? '';
617
+ const args = segments.slice(1);
618
+ const method = req.method ?? 'GET';
619
+ const body = method === 'POST' || method === 'PUT' || method === 'PATCH'
620
+ ? await readRequestBody(req)
621
+ : undefined;
622
+ // The log stream is a long-lived SSE relay, not a short wire result: the
623
+ // proxy owns the WebSocket to Lemonade and re-emits it as SSE to the browser.
624
+ if (op === 'logsStream') {
625
+ // IncomingMessage's type carries no request signal; derive one and fire
626
+ // it when the browser drops the underlying socket.
627
+ const controller = new AbortController();
628
+ req.socket?.on?.('close', () => controller.abort());
629
+ await serveLogsStream(cfg, res, controller.signal);
630
+ return;
631
+ }
334
632
  let result;
335
633
  try {
336
- const url = new URL(req.url ?? '/', 'http://localhost');
337
- const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
338
- const segments = rest.split('/').filter((part) => part.length > 0);
339
- const op = segments[0] ?? '';
340
- const args = segments.slice(1);
341
- const body = req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'
342
- ? await readRequestBody(req)
343
- : undefined;
344
- result = await serveLemonadeApi(cfg, req.method ?? 'GET', op, args, url.searchParams, body);
634
+ result = await serveLemonadeApi(cfg, method, op, args, url.searchParams, body);
345
635
  }
346
636
  catch (error) {
347
637
  result =