@cleocode/runtime 2026.6.13 → 2026.6.15
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/dist/{chunk-GOWVGQ5Q.js → chunk-3K3LFSDF.js} +24 -1
- package/dist/chunk-3K3LFSDF.js.map +1 -0
- package/dist/chunk-L5BLVQDA.js +91 -0
- package/dist/chunk-L5BLVQDA.js.map +1 -0
- package/dist/chunk-U2ESNIJ5.js +1107 -0
- package/dist/chunk-U2ESNIJ5.js.map +1 -0
- package/dist/{chunk-R4XBXUQN.js → chunk-XJOMQIXL.js} +5 -76
- package/dist/chunk-XJOMQIXL.js.map +1 -0
- package/dist/daemon/index.d.ts +83 -1
- package/dist/daemon/index.js +3 -1
- package/dist/gateway/http/index.d.ts +2 -1
- package/dist/gateway/http/index.js +44 -3
- package/dist/gateway/index.d.ts +3 -2
- package/dist/gateway/index.js +74 -16
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/mcp/index.d.ts +2 -1
- package/dist/gateway/mcp/index.js +2 -1
- package/dist/gateway/rpc/index.d.ts +694 -9
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/dist/chunk-GOWVGQ5Q.js.map +0 -1
- package/dist/chunk-R4XBXUQN.js.map +0 -1
- package/dist/chunk-YZBIPGMH.js +0 -305
- package/dist/chunk-YZBIPGMH.js.map +0 -1
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inferGateway,
|
|
3
|
+
resolveStreamRoute
|
|
4
|
+
} from "./chunk-L5BLVQDA.js";
|
|
5
|
+
|
|
6
|
+
// src/gateway/http/listen.ts
|
|
7
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
8
|
+
import { createServer } from "http";
|
|
9
|
+
import { getLogger as getLogger2 } from "@cleocode/core";
|
|
10
|
+
|
|
11
|
+
// src/gateway/http/server.ts
|
|
12
|
+
import { randomUUID } from "crypto";
|
|
13
|
+
function statusForResponse(response) {
|
|
14
|
+
if (response.success) return 200;
|
|
15
|
+
const code = response.error?.code ?? "";
|
|
16
|
+
if (code === "E_NOT_FOUND" || code.endsWith("_NOT_FOUND")) return 404;
|
|
17
|
+
if (code === "E_UNAUTHORIZED" || code === "E_AUTH_REQUIRED") return 401;
|
|
18
|
+
if (code === "E_FORBIDDEN") return 403;
|
|
19
|
+
if (code === "E_RATE_LIMITED" || code === "E_RATE_LIMIT_EXCEEDED") return 429;
|
|
20
|
+
if (code === "E_VALIDATION" || code.startsWith("E_VALIDATION") || code === "E_INVALID_INPUT" || code === "E_BAD_REQUEST") {
|
|
21
|
+
return 400;
|
|
22
|
+
}
|
|
23
|
+
if (code === "E_NOT_IMPLEMENTED" || code === "E_UNSUPPORTED") return 501;
|
|
24
|
+
return 500;
|
|
25
|
+
}
|
|
26
|
+
async function routeUnary(handler, req) {
|
|
27
|
+
const requestId = req.requestId ?? randomUUID();
|
|
28
|
+
const request = {
|
|
29
|
+
gateway: req.gateway,
|
|
30
|
+
domain: req.domain,
|
|
31
|
+
operation: req.operation,
|
|
32
|
+
params: req.params,
|
|
33
|
+
// Force the transport of origin — a client cannot impersonate cli/mcp/rpc.
|
|
34
|
+
source: "http",
|
|
35
|
+
requestId,
|
|
36
|
+
sessionId: req.sessionId
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const body = await handler.handle(request);
|
|
40
|
+
return { status: statusForResponse(body), body };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
const body = {
|
|
44
|
+
meta: {
|
|
45
|
+
gateway: req.gateway,
|
|
46
|
+
domain: req.domain,
|
|
47
|
+
operation: req.operation,
|
|
48
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
49
|
+
duration_ms: 0,
|
|
50
|
+
source: "http",
|
|
51
|
+
requestId
|
|
52
|
+
},
|
|
53
|
+
success: false,
|
|
54
|
+
error: { code: "E_HTTP_INTERNAL", message }
|
|
55
|
+
};
|
|
56
|
+
return { status: 500, body };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/gateway/http/sse.ts
|
|
61
|
+
var LF = "\n";
|
|
62
|
+
var RECORD_END = "\n\n";
|
|
63
|
+
function encodeSseFrame(frame) {
|
|
64
|
+
let out = "";
|
|
65
|
+
if (frame.event !== void 0) out += `event: ${frame.event}${LF}`;
|
|
66
|
+
if (frame.id !== void 0) out += `id: ${frame.id}${LF}`;
|
|
67
|
+
out += `data: ${JSON.stringify(frame.data)}${RECORD_END}`;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function encodeStreamEvent(frame) {
|
|
71
|
+
return encodeSseFrame({ id: String(frame.seq), data: frame });
|
|
72
|
+
}
|
|
73
|
+
var SSE_HEADERS = Object.freeze({
|
|
74
|
+
"Content-Type": "text/event-stream",
|
|
75
|
+
"Cache-Control": "no-cache",
|
|
76
|
+
Connection: "keep-alive",
|
|
77
|
+
"X-Accel-Buffering": "no"
|
|
78
|
+
});
|
|
79
|
+
function createSseStream(source, signal) {
|
|
80
|
+
const encoder = new TextEncoder();
|
|
81
|
+
return new ReadableStream({
|
|
82
|
+
start(controller) {
|
|
83
|
+
let closed = false;
|
|
84
|
+
let teardown;
|
|
85
|
+
let teardownDone = false;
|
|
86
|
+
let sourceRunning = false;
|
|
87
|
+
const onAbort = () => emitter.close();
|
|
88
|
+
const runTeardown = () => {
|
|
89
|
+
if (teardownDone) return;
|
|
90
|
+
teardownDone = true;
|
|
91
|
+
if (typeof teardown === "function") teardown();
|
|
92
|
+
};
|
|
93
|
+
const emitter = {
|
|
94
|
+
get closed() {
|
|
95
|
+
return closed;
|
|
96
|
+
},
|
|
97
|
+
send(frame) {
|
|
98
|
+
if (closed) return;
|
|
99
|
+
try {
|
|
100
|
+
controller.enqueue(encoder.encode(encodeSseFrame(frame)));
|
|
101
|
+
} catch {
|
|
102
|
+
emitter.close();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
sendStreamEvent(frame) {
|
|
106
|
+
if (closed) return;
|
|
107
|
+
try {
|
|
108
|
+
controller.enqueue(encoder.encode(encodeStreamEvent(frame)));
|
|
109
|
+
} catch {
|
|
110
|
+
emitter.close();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
sendRaw(record) {
|
|
114
|
+
if (closed) return;
|
|
115
|
+
try {
|
|
116
|
+
controller.enqueue(encoder.encode(record));
|
|
117
|
+
} catch {
|
|
118
|
+
emitter.close();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
close() {
|
|
122
|
+
if (closed) return;
|
|
123
|
+
closed = true;
|
|
124
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
125
|
+
if (!sourceRunning) runTeardown();
|
|
126
|
+
try {
|
|
127
|
+
controller.close();
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
if (signal) {
|
|
133
|
+
if (signal.aborted) {
|
|
134
|
+
closed = true;
|
|
135
|
+
teardownDone = true;
|
|
136
|
+
try {
|
|
137
|
+
controller.close();
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
signal.addEventListener("abort", onAbort);
|
|
143
|
+
}
|
|
144
|
+
sourceRunning = true;
|
|
145
|
+
const returned = source(emitter);
|
|
146
|
+
teardown = typeof returned === "function" ? returned : void 0;
|
|
147
|
+
sourceRunning = false;
|
|
148
|
+
if (closed) runTeardown();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/gateway/http/stream-sources.ts
|
|
154
|
+
var DEFAULT_TICK_INTERVAL_MS = 1e3;
|
|
155
|
+
var MAX_TICKS = 86400;
|
|
156
|
+
function resolveTickBudget(raw) {
|
|
157
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return MAX_TICKS;
|
|
158
|
+
if (raw <= 0) return 0;
|
|
159
|
+
return Math.min(Math.floor(raw), MAX_TICKS);
|
|
160
|
+
}
|
|
161
|
+
var tickStreamSource = (emitter, context) => {
|
|
162
|
+
const budget = resolveTickBudget(context.params.ticks);
|
|
163
|
+
let seq = 0;
|
|
164
|
+
const emitData = () => {
|
|
165
|
+
const frame = {
|
|
166
|
+
kind: "data",
|
|
167
|
+
seq,
|
|
168
|
+
data: { tick: seq, ts: (/* @__PURE__ */ new Date()).toISOString() },
|
|
169
|
+
requestId: context.requestId
|
|
170
|
+
};
|
|
171
|
+
emitter.sendStreamEvent(frame);
|
|
172
|
+
seq += 1;
|
|
173
|
+
};
|
|
174
|
+
const emitDone = () => {
|
|
175
|
+
const frame = {
|
|
176
|
+
kind: "done",
|
|
177
|
+
seq,
|
|
178
|
+
data: { ticks: seq, ts: (/* @__PURE__ */ new Date()).toISOString() },
|
|
179
|
+
requestId: context.requestId
|
|
180
|
+
};
|
|
181
|
+
emitter.sendStreamEvent(frame);
|
|
182
|
+
emitter.close();
|
|
183
|
+
};
|
|
184
|
+
if (budget <= 0) {
|
|
185
|
+
emitDone();
|
|
186
|
+
return void 0;
|
|
187
|
+
}
|
|
188
|
+
emitData();
|
|
189
|
+
const interval = setInterval(() => {
|
|
190
|
+
if (emitter.closed) {
|
|
191
|
+
clearInterval(interval);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (seq >= budget) {
|
|
195
|
+
clearInterval(interval);
|
|
196
|
+
emitDone();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
emitData();
|
|
200
|
+
}, DEFAULT_TICK_INTERVAL_MS);
|
|
201
|
+
return () => clearInterval(interval);
|
|
202
|
+
};
|
|
203
|
+
var taskBoardStreamSource = (emitter, context) => {
|
|
204
|
+
const budget = resolveTickBudget(context.params.ticks);
|
|
205
|
+
const root = typeof context.params.root === "string" ? context.params.root : null;
|
|
206
|
+
let seq = 0;
|
|
207
|
+
const emitData = () => {
|
|
208
|
+
const frame = {
|
|
209
|
+
kind: "data",
|
|
210
|
+
seq,
|
|
211
|
+
data: { scope: "tasks.board", root, event: "heartbeat", ts: (/* @__PURE__ */ new Date()).toISOString() },
|
|
212
|
+
requestId: context.requestId
|
|
213
|
+
};
|
|
214
|
+
emitter.sendStreamEvent(frame);
|
|
215
|
+
seq += 1;
|
|
216
|
+
};
|
|
217
|
+
const emitDone = () => {
|
|
218
|
+
const frame = {
|
|
219
|
+
kind: "done",
|
|
220
|
+
seq,
|
|
221
|
+
data: { scope: "tasks.board", root, frames: seq, ts: (/* @__PURE__ */ new Date()).toISOString() },
|
|
222
|
+
requestId: context.requestId
|
|
223
|
+
};
|
|
224
|
+
emitter.sendStreamEvent(frame);
|
|
225
|
+
emitter.close();
|
|
226
|
+
};
|
|
227
|
+
if (budget <= 0) {
|
|
228
|
+
emitDone();
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
emitData();
|
|
232
|
+
const interval = setInterval(() => {
|
|
233
|
+
if (emitter.closed) {
|
|
234
|
+
clearInterval(interval);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (seq >= budget) {
|
|
238
|
+
clearInterval(interval);
|
|
239
|
+
emitDone();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
emitData();
|
|
243
|
+
}, DEFAULT_TICK_INTERVAL_MS);
|
|
244
|
+
return () => clearInterval(interval);
|
|
245
|
+
};
|
|
246
|
+
var STREAM_SOURCES = /* @__PURE__ */ new Map([
|
|
247
|
+
["orchestrate.events", tickStreamSource],
|
|
248
|
+
// T11785 (epic T11556) — the FIRST streaming task op; default board heartbeat.
|
|
249
|
+
["tasks.subscribe", taskBoardStreamSource]
|
|
250
|
+
]);
|
|
251
|
+
function streamSourceKey(domain, operation) {
|
|
252
|
+
return `${domain}.${operation}`;
|
|
253
|
+
}
|
|
254
|
+
function registerStreamSource(domain, operation, source) {
|
|
255
|
+
STREAM_SOURCES.set(streamSourceKey(domain, operation), source);
|
|
256
|
+
}
|
|
257
|
+
function resolveStreamSource(domain, operation) {
|
|
258
|
+
return STREAM_SOURCES.get(streamSourceKey(domain, operation));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/gateway/http/studio-static.ts
|
|
262
|
+
import { createReadStream, existsSync } from "fs";
|
|
263
|
+
import { stat } from "fs/promises";
|
|
264
|
+
import { extname, join, resolve } from "path";
|
|
265
|
+
var log = {
|
|
266
|
+
info: (obj, msg) => {
|
|
267
|
+
if (process.env["CLEO_DEBUG_STUDIO"]) console.info("[studio-static]", msg, obj);
|
|
268
|
+
},
|
|
269
|
+
warn: (obj, msg) => {
|
|
270
|
+
if (process.env["NODE_ENV"] !== "test") {
|
|
271
|
+
console.warn("[studio-static]", msg, obj);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
debug: (_obj, _msg) => {
|
|
275
|
+
if (process.env["CLEO_DEBUG_STUDIO"]) console.debug("[studio-static]", _msg, _obj);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
var MIME_TYPES = /* @__PURE__ */ new Map([
|
|
279
|
+
[".html", "text/html; charset=utf-8"],
|
|
280
|
+
[".js", "application/javascript; charset=utf-8"],
|
|
281
|
+
[".mjs", "application/javascript; charset=utf-8"],
|
|
282
|
+
[".css", "text/css; charset=utf-8"],
|
|
283
|
+
[".json", "application/json; charset=utf-8"],
|
|
284
|
+
[".png", "image/png"],
|
|
285
|
+
[".jpg", "image/jpeg"],
|
|
286
|
+
[".jpeg", "image/jpeg"],
|
|
287
|
+
[".svg", "image/svg+xml"],
|
|
288
|
+
[".ico", "image/x-icon"],
|
|
289
|
+
[".woff", "font/woff"],
|
|
290
|
+
[".woff2", "font/woff2"],
|
|
291
|
+
[".ttf", "font/ttf"],
|
|
292
|
+
[".map", "application/json; charset=utf-8"],
|
|
293
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
294
|
+
[".webmanifest", "application/manifest+json; charset=utf-8"]
|
|
295
|
+
]);
|
|
296
|
+
var MIME_FALLBACK = "application/octet-stream";
|
|
297
|
+
var STUDIO_PREFIX = "/studio";
|
|
298
|
+
function isStudioPath(pathname) {
|
|
299
|
+
return pathname === STUDIO_PREFIX || pathname.startsWith(`${STUDIO_PREFIX}/`);
|
|
300
|
+
}
|
|
301
|
+
function resolveStudioAssetPath(staticDir, pathname) {
|
|
302
|
+
const base = resolve(staticDir);
|
|
303
|
+
const stripped = pathname.slice(STUDIO_PREFIX.length) || "/";
|
|
304
|
+
const relative = stripped.replace(/^[\\/]+/, "") || ".";
|
|
305
|
+
const candidate = resolve(base, relative);
|
|
306
|
+
const safeBase = base.endsWith("/") ? base : `${base}/`;
|
|
307
|
+
if (candidate !== base && !candidate.startsWith(safeBase)) {
|
|
308
|
+
return void 0;
|
|
309
|
+
}
|
|
310
|
+
return candidate;
|
|
311
|
+
}
|
|
312
|
+
function mimeType(filePath) {
|
|
313
|
+
return MIME_TYPES.get(extname(filePath).toLowerCase()) ?? MIME_FALLBACK;
|
|
314
|
+
}
|
|
315
|
+
function writeBundleAbsentError(res, hint) {
|
|
316
|
+
const body = JSON.stringify({
|
|
317
|
+
success: false,
|
|
318
|
+
error: {
|
|
319
|
+
code: "E_STUDIO_BUNDLE_ABSENT",
|
|
320
|
+
message: "CLEO Studio assets are not available. Run `pnpm --filter @cleocode/studio run build` to build them, or install from npm (npm i -g @cleocode/cleo) for a batteries-included release.",
|
|
321
|
+
hint: hint ?? null
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
res.writeHead(503, { "Content-Type": "application/json; charset=utf-8" });
|
|
325
|
+
res.end(body);
|
|
326
|
+
}
|
|
327
|
+
async function serveStudioStatic(req, res, opts) {
|
|
328
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
329
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
330
|
+
res.writeHead(405, {
|
|
331
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
332
|
+
Allow: "GET, HEAD"
|
|
333
|
+
});
|
|
334
|
+
res.end(
|
|
335
|
+
JSON.stringify({
|
|
336
|
+
success: false,
|
|
337
|
+
error: {
|
|
338
|
+
code: "E_METHOD_NOT_ALLOWED",
|
|
339
|
+
message: `Method ${req.method ?? "<none>"} not allowed for /studio routes`
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (pathname === STUDIO_PREFIX) {
|
|
346
|
+
res.writeHead(302, { Location: `${STUDIO_PREFIX}/` });
|
|
347
|
+
res.end();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const staticDir = opts.staticDir;
|
|
351
|
+
if (staticDir === void 0 || !existsSync(staticDir)) {
|
|
352
|
+
log.warn({ staticDir }, "studio static dir absent");
|
|
353
|
+
writeBundleAbsentError(res, staticDir);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const assetPath = resolveStudioAssetPath(staticDir, pathname);
|
|
357
|
+
if (assetPath === void 0) {
|
|
358
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
359
|
+
res.end(
|
|
360
|
+
JSON.stringify({
|
|
361
|
+
success: false,
|
|
362
|
+
error: { code: "E_BAD_REQUEST", message: "Invalid path" }
|
|
363
|
+
})
|
|
364
|
+
);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
let targetPath = assetPath;
|
|
368
|
+
let isSpaFallback = false;
|
|
369
|
+
try {
|
|
370
|
+
const st = await stat(targetPath);
|
|
371
|
+
if (st.isDirectory()) {
|
|
372
|
+
const dirIndex = join(targetPath, "index.html");
|
|
373
|
+
if (existsSync(dirIndex)) {
|
|
374
|
+
targetPath = dirIndex;
|
|
375
|
+
} else {
|
|
376
|
+
targetPath = join(staticDir, "index.html");
|
|
377
|
+
isSpaFallback = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
targetPath = join(staticDir, "index.html");
|
|
382
|
+
isSpaFallback = true;
|
|
383
|
+
}
|
|
384
|
+
if (!existsSync(targetPath)) {
|
|
385
|
+
res.writeHead(503, { "Content-Type": "application/json; charset=utf-8" });
|
|
386
|
+
res.end(
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
success: false,
|
|
389
|
+
error: {
|
|
390
|
+
code: "E_STUDIO_BUNDLE_CORRUPT",
|
|
391
|
+
message: "Studio index.html not found in the static bundle."
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const ct = mimeType(targetPath);
|
|
398
|
+
if (req.method === "HEAD") {
|
|
399
|
+
try {
|
|
400
|
+
const st = await stat(targetPath);
|
|
401
|
+
res.writeHead(200, {
|
|
402
|
+
"Content-Type": ct,
|
|
403
|
+
"Content-Length": String(st.size),
|
|
404
|
+
...isSpaFallback ? {} : { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
405
|
+
});
|
|
406
|
+
res.end();
|
|
407
|
+
} catch {
|
|
408
|
+
res.writeHead(404);
|
|
409
|
+
res.end();
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const st = await stat(targetPath);
|
|
415
|
+
res.writeHead(200, {
|
|
416
|
+
"Content-Type": ct,
|
|
417
|
+
"Content-Length": String(st.size),
|
|
418
|
+
// Immutable cache for fingerprinted assets; no cache for SPA fallback.
|
|
419
|
+
...isSpaFallback ? { "Cache-Control": "no-cache" } : {
|
|
420
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
const stream = createReadStream(targetPath);
|
|
424
|
+
stream.on("error", (err) => {
|
|
425
|
+
log.warn({ err, targetPath }, "studio static read error");
|
|
426
|
+
if (!res.writableEnded) res.end();
|
|
427
|
+
});
|
|
428
|
+
stream.pipe(res);
|
|
429
|
+
log.debug({ pathname, targetPath, isSpaFallback, ct }, "studio static served");
|
|
430
|
+
} catch (err) {
|
|
431
|
+
log.warn({ err, targetPath }, "studio static stat error");
|
|
432
|
+
if (!res.headersSent) {
|
|
433
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
434
|
+
res.end(
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
success: false,
|
|
437
|
+
error: { code: "E_INTERNAL", message: "Failed to read Studio asset" }
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
} else if (!res.writableEnded) {
|
|
441
|
+
res.end();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function resolveStudioStaticDir(cleoPackageDir) {
|
|
446
|
+
const candidate = join(cleoPackageDir, "studio-dist", "client");
|
|
447
|
+
return existsSync(candidate) ? candidate : void 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/gateway/http/ws-pty.ts
|
|
451
|
+
import { createHash } from "crypto";
|
|
452
|
+
import { delimiter } from "path";
|
|
453
|
+
import { getLogger } from "@cleocode/core";
|
|
454
|
+
var WS_PTY_PATH = "/v1/terminal/pty";
|
|
455
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
456
|
+
var DEFAULT_COLS = 80;
|
|
457
|
+
var DEFAULT_ROWS = 24;
|
|
458
|
+
var OPCODE_TEXT = 1;
|
|
459
|
+
var OPCODE_BINARY = 2;
|
|
460
|
+
var OPCODE_CLOSE = 8;
|
|
461
|
+
var OPCODE_PING = 9;
|
|
462
|
+
var OPCODE_PONG = 10;
|
|
463
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
464
|
+
var NODE_PTY_INSTALL_HINT = 'PTY backend unavailable: the optional "node-pty" package is not installed. Install it with `pnpm add node-pty` (a native build) to enable interactive terminal sessions.';
|
|
465
|
+
var NODE_PTY_SPECIFIER = "node-pty";
|
|
466
|
+
var realImporter = (specifier) => import(specifier);
|
|
467
|
+
var importer = realImporter;
|
|
468
|
+
function shapeCheckPty(candidate) {
|
|
469
|
+
if (candidate !== null && typeof candidate === "object" && typeof candidate.spawn === "function") {
|
|
470
|
+
return candidate;
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
async function loadNodePty() {
|
|
475
|
+
try {
|
|
476
|
+
const mod = await importer(NODE_PTY_SPECIFIER);
|
|
477
|
+
const candidate = mod.default ?? mod;
|
|
478
|
+
return shapeCheckPty(candidate);
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
var TRUSTED_PATH = [
|
|
484
|
+
"/usr/local/sbin",
|
|
485
|
+
"/usr/local/bin",
|
|
486
|
+
"/usr/sbin",
|
|
487
|
+
"/usr/bin",
|
|
488
|
+
"/sbin",
|
|
489
|
+
"/bin"
|
|
490
|
+
].join(delimiter);
|
|
491
|
+
var PASSTHROUGH_KEYS = [
|
|
492
|
+
"HOME",
|
|
493
|
+
"USER",
|
|
494
|
+
"LOGNAME",
|
|
495
|
+
"LANG",
|
|
496
|
+
"LC_ALL",
|
|
497
|
+
"LC_CTYPE",
|
|
498
|
+
"TZ",
|
|
499
|
+
"TMPDIR"
|
|
500
|
+
];
|
|
501
|
+
function scrubPtyEnv(parentEnv = process.env) {
|
|
502
|
+
const env = { PATH: TRUSTED_PATH, TERM: "xterm-256color" };
|
|
503
|
+
for (const key of PASSTHROUGH_KEYS) {
|
|
504
|
+
const value = parentEnv[key];
|
|
505
|
+
if (typeof value === "string" && value.length > 0) env[key] = value;
|
|
506
|
+
}
|
|
507
|
+
return env;
|
|
508
|
+
}
|
|
509
|
+
function computeAcceptKey(key) {
|
|
510
|
+
return createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
511
|
+
}
|
|
512
|
+
function encodeWsFrame(opcode, payload) {
|
|
513
|
+
const len = payload.length;
|
|
514
|
+
let header;
|
|
515
|
+
if (len < 126) {
|
|
516
|
+
header = Buffer.alloc(2);
|
|
517
|
+
header[1] = len;
|
|
518
|
+
} else if (len < 65536) {
|
|
519
|
+
header = Buffer.alloc(4);
|
|
520
|
+
header[1] = 126;
|
|
521
|
+
header.writeUInt16BE(len, 2);
|
|
522
|
+
} else {
|
|
523
|
+
header = Buffer.alloc(10);
|
|
524
|
+
header[1] = 127;
|
|
525
|
+
header.writeUInt32BE(Math.floor(len / 4294967296), 2);
|
|
526
|
+
header.writeUInt32BE(len >>> 0, 6);
|
|
527
|
+
}
|
|
528
|
+
header[0] = 128 | opcode & 15;
|
|
529
|
+
return Buffer.concat([header, payload]);
|
|
530
|
+
}
|
|
531
|
+
function encodeCloseFrame(code, reason) {
|
|
532
|
+
const reasonBytes = Buffer.from(reason, "utf8").subarray(0, 123);
|
|
533
|
+
const payload = Buffer.alloc(2 + reasonBytes.length);
|
|
534
|
+
payload.writeUInt16BE(code, 0);
|
|
535
|
+
reasonBytes.copy(payload, 2);
|
|
536
|
+
return encodeWsFrame(OPCODE_CLOSE, payload);
|
|
537
|
+
}
|
|
538
|
+
function decodeWsFrame(buf) {
|
|
539
|
+
if (buf.length < 2) return null;
|
|
540
|
+
const opcode = buf[0] & 15;
|
|
541
|
+
const masked = (buf[1] & 128) !== 0;
|
|
542
|
+
let len = buf[1] & 127;
|
|
543
|
+
let offset = 2;
|
|
544
|
+
if (len === 126) {
|
|
545
|
+
if (buf.length < offset + 2) return null;
|
|
546
|
+
len = buf.readUInt16BE(offset);
|
|
547
|
+
offset += 2;
|
|
548
|
+
} else if (len === 127) {
|
|
549
|
+
if (buf.length < offset + 8) return null;
|
|
550
|
+
len = buf.readUInt32BE(offset + 4);
|
|
551
|
+
offset += 8;
|
|
552
|
+
}
|
|
553
|
+
let mask;
|
|
554
|
+
if (masked) {
|
|
555
|
+
if (buf.length < offset + 4) return null;
|
|
556
|
+
mask = buf.subarray(offset, offset + 4);
|
|
557
|
+
offset += 4;
|
|
558
|
+
}
|
|
559
|
+
if (buf.length < offset + len) return null;
|
|
560
|
+
const raw = buf.subarray(offset, offset + len);
|
|
561
|
+
const payload = Buffer.allocUnsafe(len);
|
|
562
|
+
if (mask) {
|
|
563
|
+
for (let i = 0; i < len; i++) payload[i] = raw[i] ^ mask[i & 3];
|
|
564
|
+
} else {
|
|
565
|
+
raw.copy(payload);
|
|
566
|
+
}
|
|
567
|
+
return { opcode, payload, consumed: offset + len };
|
|
568
|
+
}
|
|
569
|
+
function authorizeUpgrade(req, remoteAddress, opts) {
|
|
570
|
+
if (remoteAddress === void 0 || !LOOPBACK_HOSTS.has(remoteAddress)) {
|
|
571
|
+
return { ok: false, status: 403, reason: "non-loopback connections are not permitted" };
|
|
572
|
+
}
|
|
573
|
+
if (opts.allowedOrigins !== void 0 && opts.allowedOrigins.length > 0) {
|
|
574
|
+
const origin = req.headers.origin;
|
|
575
|
+
if (typeof origin !== "string" || !opts.allowedOrigins.includes(origin)) {
|
|
576
|
+
return { ok: false, status: 403, reason: "origin not allowed" };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (opts.token !== void 0 && opts.token.length > 0) {
|
|
580
|
+
const presented = extractToken(req);
|
|
581
|
+
if (presented === void 0 || !timingSafeEqualStr(presented, opts.token)) {
|
|
582
|
+
return { ok: false, status: 401, reason: "missing or invalid token" };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { ok: true };
|
|
586
|
+
}
|
|
587
|
+
function extractToken(req) {
|
|
588
|
+
const auth = req.headers.authorization;
|
|
589
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
|
|
590
|
+
return auth.slice("Bearer ".length).trim();
|
|
591
|
+
}
|
|
592
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
593
|
+
const q = url.searchParams.get("token");
|
|
594
|
+
return q !== null && q.length > 0 ? q : void 0;
|
|
595
|
+
}
|
|
596
|
+
function timingSafeEqualStr(a, b) {
|
|
597
|
+
if (a.length !== b.length) return false;
|
|
598
|
+
let diff = 0;
|
|
599
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
600
|
+
return diff === 0;
|
|
601
|
+
}
|
|
602
|
+
function isWsPtyPath(url) {
|
|
603
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
604
|
+
return pathname === WS_PTY_PATH;
|
|
605
|
+
}
|
|
606
|
+
function rejectUpgrade(socket, status, reason) {
|
|
607
|
+
const statusText = status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request";
|
|
608
|
+
socket.write(
|
|
609
|
+
`HTTP/1.1 ${status} ${statusText}\r
|
|
610
|
+
Connection: close\r
|
|
611
|
+
Content-Type: text/plain\r
|
|
612
|
+
Content-Length: ${Buffer.byteLength(reason)}\r
|
|
613
|
+
\r
|
|
614
|
+
` + reason
|
|
615
|
+
);
|
|
616
|
+
socket.destroy();
|
|
617
|
+
}
|
|
618
|
+
function swallowLateSocketError() {
|
|
619
|
+
}
|
|
620
|
+
async function bridgePty(req, socket, opts, log2, onTeardown) {
|
|
621
|
+
const key = req.headers["sec-websocket-key"];
|
|
622
|
+
socket.write(
|
|
623
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
624
|
+
Upgrade: websocket\r
|
|
625
|
+
Connection: Upgrade\r
|
|
626
|
+
Sec-WebSocket-Accept: ${computeAcceptKey(typeof key === "string" ? key : "")}\r
|
|
627
|
+
\r
|
|
628
|
+
`
|
|
629
|
+
);
|
|
630
|
+
let tornDown = false;
|
|
631
|
+
let pty;
|
|
632
|
+
const disposables = [];
|
|
633
|
+
let inbound = Buffer.alloc(0);
|
|
634
|
+
const sendText = (data) => {
|
|
635
|
+
if (tornDown || socket.writableEnded) return;
|
|
636
|
+
const frame = encodeWsFrame(OPCODE_TEXT, Buffer.from(data, "utf8"));
|
|
637
|
+
if (!socket.write(frame)) {
|
|
638
|
+
socket.once("drain", () => void 0);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const teardown = (code = 1e3, reason = "") => {
|
|
642
|
+
if (tornDown) return;
|
|
643
|
+
tornDown = true;
|
|
644
|
+
for (const d of disposables.splice(0)) {
|
|
645
|
+
try {
|
|
646
|
+
d.dispose();
|
|
647
|
+
} catch {
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (pty !== void 0) {
|
|
651
|
+
try {
|
|
652
|
+
pty.kill();
|
|
653
|
+
} catch {
|
|
654
|
+
}
|
|
655
|
+
pty = void 0;
|
|
656
|
+
}
|
|
657
|
+
socket.removeListener("data", onData);
|
|
658
|
+
socket.removeListener("error", onError);
|
|
659
|
+
socket.removeListener("close", onSocketClose);
|
|
660
|
+
socket.on("error", swallowLateSocketError);
|
|
661
|
+
if (!socket.writableEnded) {
|
|
662
|
+
try {
|
|
663
|
+
socket.write(encodeCloseFrame(code, reason.slice(0, 120)));
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
socket.end();
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
onTeardown();
|
|
670
|
+
} catch {
|
|
671
|
+
}
|
|
672
|
+
log2.debug({ code, reason }, "ws-pty session torn down");
|
|
673
|
+
};
|
|
674
|
+
const onFrame = (frame) => {
|
|
675
|
+
switch (frame.opcode) {
|
|
676
|
+
case OPCODE_TEXT:
|
|
677
|
+
case OPCODE_BINARY: {
|
|
678
|
+
const text = frame.payload.toString("utf8");
|
|
679
|
+
const control = tryParseControl(text);
|
|
680
|
+
if (control !== void 0 && control.type === "resize") {
|
|
681
|
+
pty?.resize(control.cols, control.rows);
|
|
682
|
+
} else {
|
|
683
|
+
pty?.write(text);
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
case OPCODE_PING:
|
|
688
|
+
if (!tornDown && !socket.writableEnded)
|
|
689
|
+
socket.write(encodeWsFrame(OPCODE_PONG, frame.payload));
|
|
690
|
+
break;
|
|
691
|
+
case OPCODE_CLOSE:
|
|
692
|
+
teardown(1e3, "client closed");
|
|
693
|
+
break;
|
|
694
|
+
// PONG and continuation frames are ignored.
|
|
695
|
+
default:
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
const onData = (chunk) => {
|
|
700
|
+
inbound = inbound.length === 0 ? chunk : Buffer.concat([inbound, chunk]);
|
|
701
|
+
for (; ; ) {
|
|
702
|
+
const frame = decodeWsFrame(inbound);
|
|
703
|
+
if (frame === null) break;
|
|
704
|
+
inbound = inbound.subarray(frame.consumed);
|
|
705
|
+
onFrame(frame);
|
|
706
|
+
if (tornDown) break;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
const onError = () => teardown(1011, "socket error");
|
|
710
|
+
const onSocketClose = () => teardown();
|
|
711
|
+
socket.on("data", onData);
|
|
712
|
+
socket.on("error", onError);
|
|
713
|
+
socket.on("close", onSocketClose);
|
|
714
|
+
const ptyModule = await loadNodePty();
|
|
715
|
+
if (tornDown) return { teardown };
|
|
716
|
+
if (ptyModule === null) {
|
|
717
|
+
teardown(1011, NODE_PTY_INSTALL_HINT);
|
|
718
|
+
return { teardown };
|
|
719
|
+
}
|
|
720
|
+
const shell = opts.shell ?? process.env.SHELL ?? "/bin/sh";
|
|
721
|
+
try {
|
|
722
|
+
pty = ptyModule.spawn(shell, [], {
|
|
723
|
+
name: "xterm-256color",
|
|
724
|
+
cols: DEFAULT_COLS,
|
|
725
|
+
rows: DEFAULT_ROWS,
|
|
726
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
727
|
+
env: scrubPtyEnv()
|
|
728
|
+
});
|
|
729
|
+
} catch (err) {
|
|
730
|
+
log2.warn({ err }, "ws-pty spawn failed");
|
|
731
|
+
teardown(1011, "PTY spawn failed");
|
|
732
|
+
return { teardown };
|
|
733
|
+
}
|
|
734
|
+
const dataSub = pty.onData((d) => sendText(d));
|
|
735
|
+
if (dataSub && typeof dataSub.dispose === "function") disposables.push(dataSub);
|
|
736
|
+
const exitSub = pty.onExit(({ exitCode }) => teardown(1e3, `pty exited (${exitCode})`));
|
|
737
|
+
if (exitSub && typeof exitSub.dispose === "function") disposables.push(exitSub);
|
|
738
|
+
log2.debug({ shell }, "ws-pty session established");
|
|
739
|
+
return { teardown };
|
|
740
|
+
}
|
|
741
|
+
function tryParseControl(text) {
|
|
742
|
+
if (text.length === 0 || text[0] !== "{") return void 0;
|
|
743
|
+
try {
|
|
744
|
+
const parsed = JSON.parse(text);
|
|
745
|
+
if (parsed !== null && typeof parsed === "object" && parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
|
|
746
|
+
const p = parsed;
|
|
747
|
+
return { type: "resize", cols: p.cols, rows: p.rows };
|
|
748
|
+
}
|
|
749
|
+
} catch {
|
|
750
|
+
}
|
|
751
|
+
return void 0;
|
|
752
|
+
}
|
|
753
|
+
function attachWsPtyEndpoint(server, opts = {}) {
|
|
754
|
+
const log2 = getLogger("gateway-ws-pty");
|
|
755
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
756
|
+
const onUpgrade = (req, socket, _head) => {
|
|
757
|
+
if (!isWsPtyPath(req.url)) return;
|
|
758
|
+
const decision = authorizeUpgrade(req, req.socket.remoteAddress, opts);
|
|
759
|
+
if (!decision.ok) {
|
|
760
|
+
log2.debug({ status: decision.status, reason: decision.reason }, "ws-pty upgrade rejected");
|
|
761
|
+
rejectUpgrade(socket, decision.status, decision.reason);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
let session;
|
|
765
|
+
let deregistered = false;
|
|
766
|
+
const deregister = () => {
|
|
767
|
+
if (deregistered) return;
|
|
768
|
+
deregistered = true;
|
|
769
|
+
if (session !== void 0) sessions.delete(session);
|
|
770
|
+
};
|
|
771
|
+
bridgePty(req, socket, opts, log2, deregister).then((s) => {
|
|
772
|
+
session = s;
|
|
773
|
+
if (!deregistered) sessions.add(s);
|
|
774
|
+
}).catch((err) => {
|
|
775
|
+
log2.warn({ err }, "ws-pty bridge failed");
|
|
776
|
+
deregister();
|
|
777
|
+
if (!socket.destroyed) socket.destroy();
|
|
778
|
+
});
|
|
779
|
+
};
|
|
780
|
+
server.on("upgrade", onUpgrade);
|
|
781
|
+
let closed = false;
|
|
782
|
+
return {
|
|
783
|
+
get sessionCount() {
|
|
784
|
+
return sessions.size;
|
|
785
|
+
},
|
|
786
|
+
close() {
|
|
787
|
+
if (closed) return;
|
|
788
|
+
closed = true;
|
|
789
|
+
server.removeListener("upgrade", onUpgrade);
|
|
790
|
+
for (const session of [...sessions]) session.teardown(1001, "server closing");
|
|
791
|
+
sessions.clear();
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/gateway/http/listen.ts
|
|
797
|
+
var GATEWAYS = /* @__PURE__ */ new Set(["query", "mutate"]);
|
|
798
|
+
var MAX_BODY_BYTES = 1048576;
|
|
799
|
+
var V1_PREFIX = "v1";
|
|
800
|
+
function parseHttpRoute(method, url) {
|
|
801
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
802
|
+
const segments = pathname.split("/").filter((s) => s.length > 0);
|
|
803
|
+
if (method === "GET") {
|
|
804
|
+
if (segments.length === 2 && segments[0] === V1_PREFIX && segments[1] === "health") {
|
|
805
|
+
return { ok: true, kind: "health" };
|
|
806
|
+
}
|
|
807
|
+
if (segments.length === 3 && segments[0] === V1_PREFIX) {
|
|
808
|
+
const [, domain2, operation2] = segments;
|
|
809
|
+
const stream = resolveStreamRoute(domain2, operation2);
|
|
810
|
+
if (stream !== void 0) {
|
|
811
|
+
return { ok: true, kind: "stream", gateway: stream.gateway, domain: domain2, operation: operation2 };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
status: 405,
|
|
817
|
+
code: "E_HTTP_METHOD_NOT_ALLOWED",
|
|
818
|
+
message: `method GET not allowed for '${pathname}'; only GET /v1/health and registered streaming ops are served`
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (method !== "POST") {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
status: 405,
|
|
825
|
+
code: "E_HTTP_METHOD_NOT_ALLOWED",
|
|
826
|
+
message: `method ${method ?? "<none>"} not allowed; gateway accepts POST`
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (segments.length === 3 && segments[0] === V1_PREFIX) {
|
|
830
|
+
const [, domain2, operation2] = segments;
|
|
831
|
+
const gateway2 = inferGateway(domain2, operation2);
|
|
832
|
+
if (gateway2 === void 0) {
|
|
833
|
+
return {
|
|
834
|
+
ok: false,
|
|
835
|
+
status: 404,
|
|
836
|
+
code: "E_HTTP_NOT_FOUND",
|
|
837
|
+
message: `unknown operation '${domain2}/${operation2}'; no registered query/mutate matches`
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
return { ok: true, kind: "unary", gateway: gateway2, domain: domain2, operation: operation2 };
|
|
841
|
+
}
|
|
842
|
+
if (segments.length !== 3) {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
status: 404,
|
|
846
|
+
code: "E_HTTP_NOT_FOUND",
|
|
847
|
+
message: `expected POST /v1/<domain>/<operation> or /<gateway>/<domain>/<operation>, got '${pathname}'`
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const [gateway, domain, operation] = segments;
|
|
851
|
+
if (!GATEWAYS.has(gateway)) {
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
status: 404,
|
|
855
|
+
code: "E_HTTP_NOT_FOUND",
|
|
856
|
+
message: `unknown gateway '${gateway}'; expected one of query, mutate`
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return { ok: true, kind: "unary", gateway, domain, operation };
|
|
860
|
+
}
|
|
861
|
+
function readBody(req) {
|
|
862
|
+
return new Promise((resolve2, reject) => {
|
|
863
|
+
const chunks = [];
|
|
864
|
+
let size = 0;
|
|
865
|
+
req.on("data", (chunk) => {
|
|
866
|
+
size += chunk.length;
|
|
867
|
+
if (size > MAX_BODY_BYTES) {
|
|
868
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`));
|
|
869
|
+
req.destroy();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
chunks.push(chunk);
|
|
873
|
+
});
|
|
874
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
|
|
875
|
+
req.on("error", reject);
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function writeEdgeError(res, status, code, message) {
|
|
879
|
+
const body = {
|
|
880
|
+
meta: {
|
|
881
|
+
gateway: "query",
|
|
882
|
+
domain: "",
|
|
883
|
+
operation: "",
|
|
884
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
885
|
+
duration_ms: 0,
|
|
886
|
+
source: "http",
|
|
887
|
+
requestId: ""
|
|
888
|
+
},
|
|
889
|
+
success: false,
|
|
890
|
+
error: { code, message }
|
|
891
|
+
};
|
|
892
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
893
|
+
res.end(JSON.stringify(body));
|
|
894
|
+
}
|
|
895
|
+
function writeHealth(res) {
|
|
896
|
+
const body = {
|
|
897
|
+
meta: {
|
|
898
|
+
gateway: "query",
|
|
899
|
+
domain: "gateway",
|
|
900
|
+
operation: "health",
|
|
901
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
902
|
+
duration_ms: 0,
|
|
903
|
+
source: "http",
|
|
904
|
+
requestId: ""
|
|
905
|
+
},
|
|
906
|
+
success: true,
|
|
907
|
+
data: { status: "ok", version: V1_PREFIX }
|
|
908
|
+
};
|
|
909
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
910
|
+
res.end(JSON.stringify(body));
|
|
911
|
+
}
|
|
912
|
+
function parseStreamParams(url) {
|
|
913
|
+
const parsed = new URL(url ?? "/", "http://localhost");
|
|
914
|
+
const params = {};
|
|
915
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
916
|
+
const asNumber = Number(value);
|
|
917
|
+
params[key] = value !== "" && Number.isFinite(asNumber) ? asNumber : value;
|
|
918
|
+
}
|
|
919
|
+
return params;
|
|
920
|
+
}
|
|
921
|
+
function writeStreamError(res, domain, operation, requestId) {
|
|
922
|
+
const frame = {
|
|
923
|
+
kind: "error",
|
|
924
|
+
seq: 0,
|
|
925
|
+
error: {
|
|
926
|
+
code: "E_HTTP_NO_STREAM_SOURCE",
|
|
927
|
+
message: `no streaming source registered for '${domain}/${operation}'`
|
|
928
|
+
},
|
|
929
|
+
requestId
|
|
930
|
+
};
|
|
931
|
+
res.writeHead(200, { ...SSE_HEADERS });
|
|
932
|
+
res.end(encodeStreamEvent(frame));
|
|
933
|
+
}
|
|
934
|
+
async function handleStreamRequest(req, res, domain, operation, log2) {
|
|
935
|
+
const requestId = randomUUID2();
|
|
936
|
+
const source = resolveStreamSource(domain, operation);
|
|
937
|
+
if (source === void 0) {
|
|
938
|
+
writeStreamError(res, domain, operation, requestId);
|
|
939
|
+
log2.warn({ domain, operation }, "sse stream has no registered source");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const controller = new AbortController();
|
|
943
|
+
const onClose = () => controller.abort();
|
|
944
|
+
req.once("close", onClose);
|
|
945
|
+
const context = {
|
|
946
|
+
domain,
|
|
947
|
+
operation,
|
|
948
|
+
params: parseStreamParams(req.url),
|
|
949
|
+
requestId
|
|
950
|
+
};
|
|
951
|
+
res.writeHead(200, { ...SSE_HEADERS });
|
|
952
|
+
const webStream = createSseStream((emitter) => source(emitter, context), controller.signal);
|
|
953
|
+
const reader = webStream.getReader();
|
|
954
|
+
try {
|
|
955
|
+
for (; ; ) {
|
|
956
|
+
const { done, value } = await reader.read();
|
|
957
|
+
if (done) break;
|
|
958
|
+
if (res.writableEnded) break;
|
|
959
|
+
if (!res.write(value)) {
|
|
960
|
+
await new Promise((resolve2) => res.once("drain", resolve2));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} catch (err) {
|
|
964
|
+
log2.warn({ err, domain, operation }, "sse stream pipe failed");
|
|
965
|
+
} finally {
|
|
966
|
+
reader.releaseLock();
|
|
967
|
+
req.removeListener("close", onClose);
|
|
968
|
+
if (!controller.signal.aborted) controller.abort();
|
|
969
|
+
if (!res.writableEnded) res.end();
|
|
970
|
+
log2.debug({ domain, operation, requestId }, "sse stream closed");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
async function handleHttpRequest(handler, req, res, log2, studioOpts) {
|
|
974
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
975
|
+
if (studioOpts !== void 0 && isStudioPath(pathname)) {
|
|
976
|
+
await serveStudioStatic(req, res, studioOpts);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const route = parseHttpRoute(req.method, req.url);
|
|
980
|
+
if (!route.ok) {
|
|
981
|
+
writeEdgeError(res, route.status, route.code, route.message);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (route.kind === "health") {
|
|
985
|
+
writeHealth(res);
|
|
986
|
+
log2.debug({ route: "health" }, "http health probe served");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (route.kind === "stream") {
|
|
990
|
+
await handleStreamRequest(req, res, route.domain, route.operation, log2);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
let params;
|
|
994
|
+
try {
|
|
995
|
+
const raw = await readBody(req);
|
|
996
|
+
if (raw.trim().length > 0) {
|
|
997
|
+
const parsed = JSON.parse(raw);
|
|
998
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
999
|
+
writeEdgeError(res, 400, "E_HTTP_BAD_REQUEST", "request body must be a JSON object");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
params = parsed;
|
|
1003
|
+
}
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1006
|
+
writeEdgeError(res, 400, "E_HTTP_BAD_REQUEST", `invalid request body: ${message}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const unary = {
|
|
1010
|
+
gateway: route.gateway,
|
|
1011
|
+
domain: route.domain,
|
|
1012
|
+
operation: route.operation,
|
|
1013
|
+
params
|
|
1014
|
+
};
|
|
1015
|
+
const result = await routeUnary(handler, unary);
|
|
1016
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
1017
|
+
res.end(JSON.stringify(result.body));
|
|
1018
|
+
log2.debug(
|
|
1019
|
+
{
|
|
1020
|
+
gateway: route.gateway,
|
|
1021
|
+
domain: route.domain,
|
|
1022
|
+
operation: route.operation,
|
|
1023
|
+
status: result.status
|
|
1024
|
+
},
|
|
1025
|
+
"http request routed"
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
function startHttpServer(handler, opts) {
|
|
1029
|
+
const log2 = getLogger2("gateway-http");
|
|
1030
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1031
|
+
const studioOpts = opts.studioStaticDir !== void 0 ? { staticDir: opts.studioStaticDir } : void 0;
|
|
1032
|
+
if (studioOpts !== void 0) {
|
|
1033
|
+
log2.info({ studioStaticDir: opts.studioStaticDir }, "studio static serving enabled at /studio");
|
|
1034
|
+
}
|
|
1035
|
+
const server = createServer((req, res) => {
|
|
1036
|
+
handleHttpRequest(handler, req, res, log2, studioOpts).catch((err) => {
|
|
1037
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1038
|
+
log2.warn({ err }, "http request handling failed");
|
|
1039
|
+
if (!res.headersSent) {
|
|
1040
|
+
writeEdgeError(res, 500, "E_HTTP_INTERNAL", message);
|
|
1041
|
+
} else if (!res.writableEnded) {
|
|
1042
|
+
res.end();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
const wsPty = opts.wsPty !== void 0 ? attachWsPtyEndpoint(server, opts.wsPty) : void 0;
|
|
1047
|
+
return new Promise((resolve2, reject) => {
|
|
1048
|
+
server.once("error", reject);
|
|
1049
|
+
server.listen(opts.port, host, () => {
|
|
1050
|
+
server.removeListener("error", reject);
|
|
1051
|
+
const address = server.address();
|
|
1052
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : opts.port;
|
|
1053
|
+
log2.info(
|
|
1054
|
+
{ host, port: boundPort, wsPty: wsPty !== void 0 },
|
|
1055
|
+
"http gateway server listening"
|
|
1056
|
+
);
|
|
1057
|
+
let closed = false;
|
|
1058
|
+
const handle = {
|
|
1059
|
+
server,
|
|
1060
|
+
port: boundPort,
|
|
1061
|
+
host,
|
|
1062
|
+
close() {
|
|
1063
|
+
if (closed) return Promise.resolve();
|
|
1064
|
+
closed = true;
|
|
1065
|
+
wsPty?.close();
|
|
1066
|
+
return new Promise((res) => {
|
|
1067
|
+
server.close(() => res());
|
|
1068
|
+
server.closeIdleConnections();
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
resolve2(handle);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export {
|
|
1078
|
+
statusForResponse,
|
|
1079
|
+
routeUnary,
|
|
1080
|
+
encodeSseFrame,
|
|
1081
|
+
encodeStreamEvent,
|
|
1082
|
+
SSE_HEADERS,
|
|
1083
|
+
createSseStream,
|
|
1084
|
+
tickStreamSource,
|
|
1085
|
+
registerStreamSource,
|
|
1086
|
+
resolveStreamSource,
|
|
1087
|
+
STUDIO_PREFIX,
|
|
1088
|
+
isStudioPath,
|
|
1089
|
+
resolveStudioAssetPath,
|
|
1090
|
+
mimeType,
|
|
1091
|
+
writeBundleAbsentError,
|
|
1092
|
+
serveStudioStatic,
|
|
1093
|
+
resolveStudioStaticDir,
|
|
1094
|
+
WS_PTY_PATH,
|
|
1095
|
+
NODE_PTY_INSTALL_HINT,
|
|
1096
|
+
loadNodePty,
|
|
1097
|
+
scrubPtyEnv,
|
|
1098
|
+
computeAcceptKey,
|
|
1099
|
+
encodeWsFrame,
|
|
1100
|
+
decodeWsFrame,
|
|
1101
|
+
authorizeUpgrade,
|
|
1102
|
+
isWsPtyPath,
|
|
1103
|
+
attachWsPtyEndpoint,
|
|
1104
|
+
parseHttpRoute,
|
|
1105
|
+
startHttpServer
|
|
1106
|
+
};
|
|
1107
|
+
//# sourceMappingURL=chunk-U2ESNIJ5.js.map
|