@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.
- package/README.fr.md +6 -0
- package/README.md +21 -1
- package/lib/adapter.js +5 -4
- package/lib/client.js +200 -8
- package/lib/index.js +33 -21
- package/lib/json-parse.js +230 -0
- package/lib/server-api.js +320 -30
- package/lib/translate.js +67 -7
- package/lib/types/adapter.d.ts +7 -2
- package/lib/types/index.d.ts +1 -0
- package/lib/types/json-parse.d.ts +34 -0
- package/lib/types/server-api.d.ts +4 -10
- package/lib/types/translate.d.ts +19 -4
- package/package.json +6 -2
- package/src/adapter.ts +11 -4
- package/src/client/index.js +200 -8
- package/src/index.ts +32 -15
- package/src/json-parse.ts +175 -0
- package/src/server-api.ts +329 -30
- package/src/translate.ts +71 -5
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 =
|
|
328
|
+
const isRootPath = url.startsWith('/internal/') || url === '/live' || url === '/metrics';
|
|
220
329
|
const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
|
|
221
|
-
const
|
|
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 (
|
|
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(
|
|
231
|
-
method
|
|
339
|
+
response = await fetch(fullUrl, {
|
|
340
|
+
method,
|
|
232
341
|
headers,
|
|
233
|
-
...(
|
|
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 ' +
|
|
352
|
+
return errResult('could not reach ' + fullUrl, 'TRANSPORT');
|
|
244
353
|
}
|
|
245
354
|
finally {
|
|
246
|
-
timer
|
|
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 (
|
|
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 ' +
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|