@zhanguym/chatroom 1.1.0 → 1.2.0-beta.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/dist/core/runtime/child-environment.js +3 -2
- package/dist/infrastructure/http/http-server.js +7 -1
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
- package/dist/native/windows/chatroom-computer-helper.exe +0 -0
- package/dist/plugins/cloud/tunnel-client.d.ts +2 -84
- package/dist/plugins/cloud/tunnel-client.js +2 -426
- package/dist/plugins/cloud/v2/stream.d.ts +48 -0
- package/dist/plugins/cloud/v2/stream.js +156 -0
- package/dist/plugins/cloud/v2/tunnel-client.d.ts +45 -0
- package/dist/plugins/cloud/v2/tunnel-client.js +426 -0
- package/dist/plugins/process/backend.d.ts +3 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +2 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.js +5 -2
- package/dist/plugins/process/infrastructure/terminal-shell.d.ts +4 -0
- package/dist/plugins/process/infrastructure/terminal-shell.js +10 -0
- package/dist/plugins/process/process-supervisor.d.ts +6 -0
- package/dist/plugins/process/process-supervisor.js +26 -0
- package/dist/plugins/process/types.d.ts +2 -0
- package/dist/plugins/web/http/api-router.d.ts +2 -1
- package/dist/plugins/web/http/api-router.js +5 -1
- package/dist/plugins/web/http/logs-api-router.js +2 -2
- package/dist/plugins/web/http/process-api-router.js +48 -0
- package/dist/plugins/web/http/rtc-api-router.d.ts +4 -0
- package/dist/plugins/web/http/rtc-api-router.js +316 -0
- package/dist/plugins/web/http/runtime-api-router.js +2 -2
- package/dist/plugins/web/http/terminal-api-router.d.ts +4 -0
- package/dist/plugins/web/http/terminal-api-router.js +132 -0
- package/dist/web/assets/{AgentView-BGLIBoxF.css → AgentView-CMxIn3KB.css} +1 -1
- package/dist/web/assets/AgentView-DQr-5aLK.js +4 -0
- package/dist/web/assets/CloudView-B2VBut3O.js +1 -0
- package/dist/web/assets/CloudView-Bgp5G965.css +1 -0
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-DkN8x_6L.js +3 -0
- package/dist/web/assets/ComputerView-CIFF1zUb.js +1 -0
- package/dist/web/assets/{MarkdownContent-CkjR5fs7.js → MarkdownContent-DRZp88_G.js} +2 -2
- package/dist/web/assets/McpToolsView-CSEru3ck.js +1 -0
- package/dist/web/assets/OperationsView-B_Nv1Mgi.js +4 -0
- package/dist/web/assets/ProcessesView-5K8wNiPC.js +5 -0
- package/dist/web/assets/ProcessesView-BmNdR3C0.css +1 -0
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-CQEj-YRM.js +1 -0
- package/dist/web/assets/SystemLogsView-CBINILPo.js +1 -0
- package/dist/web/assets/SystemLogsView-COvwamo5.css +1 -0
- package/dist/web/assets/TerminalView-DNaKG3II.css +1 -0
- package/dist/web/assets/TerminalView-Dvyy2GKF.js +16 -0
- package/dist/web/assets/VSelect-CBX17QQj.js +1 -0
- package/dist/web/assets/VSpacer-9N_Ffoce.js +1 -0
- package/dist/web/assets/VSwitch-CvQYU48e.js +1 -0
- package/dist/web/assets/VTabs-CftrBFlF.js +1 -0
- package/dist/web/assets/VTextarea-Dcui00tn.js +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-DJKSNaZ_.css +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-DzM2p_xl.js +1 -0
- package/dist/web/assets/WorkspaceMediaPreview-ctIyG3Ru.js +4 -0
- package/dist/web/assets/{WorkspacePdfPreview-CO-ZtDHG.js → WorkspacePdfPreview-CeizxEAH.js} +2 -2
- package/dist/web/assets/WorkspacesView-DcmqHGvr.js +2 -0
- package/dist/web/assets/index-BRI_re-n.css +1 -0
- package/dist/web/assets/index-DTyxBHlJ.js +19 -0
- package/dist/web/assets/{useMasterDetailFocus-D1mQ8Qjk.js → useMasterDetailFocus-DR5N95_A.js} +1 -1
- package/dist/web/assets/useOperationLabels-Dou4m0ac.js +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/transport-sw.js +411 -0
- package/package.json +9 -4
- package/scripts/ensure-pty-helper.mjs +28 -0
- package/dist/web/assets/AgentView-Dd6zCbr-.js +0 -4
- package/dist/web/assets/CloudView-DVVvwuWW.css +0 -1
- package/dist/web/assets/CloudView-DquY1HDZ.js +0 -1
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-ekOImmBP.js +0 -3
- package/dist/web/assets/ComputerView-RD2gMxJZ.js +0 -1
- package/dist/web/assets/McpToolsView-Dg2-iaDP.js +0 -1
- package/dist/web/assets/OperationsView-C8yCX7WZ.js +0 -4
- package/dist/web/assets/ProcessesView-C834LC-f.js +0 -4
- package/dist/web/assets/ProcessesView-Ce6iVwjA.css +0 -1
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-By2gilBw.js +0 -1
- package/dist/web/assets/SystemLogsView-B6KcZv1J.js +0 -1
- package/dist/web/assets/SystemLogsView-CofP4Jvi.css +0 -1
- package/dist/web/assets/VSelect-BtAJX-XJ.js +0 -1
- package/dist/web/assets/VSpacer-M8bpkhTH.js +0 -1
- package/dist/web/assets/VSwitch-C0VTnwLJ.js +0 -1
- package/dist/web/assets/VTabs-g61mykvY.js +0 -1
- package/dist/web/assets/VTextarea-CRYQYRqg.js +0 -1
- package/dist/web/assets/WorkspaceDownloadPreview-BrESF-82.js +0 -1
- package/dist/web/assets/WorkspaceDownloadPreview-DnxhH5Q7.css +0 -1
- package/dist/web/assets/WorkspaceMediaPreview-HYajqgZ3.js +0 -4
- package/dist/web/assets/WorkspacesView-BJCnzBLj.js +0 -2
- package/dist/web/assets/_plugin-vue_export-helper-DlAUqK2U.js +0 -1
- package/dist/web/assets/index-CZ-4tJlF.css +0 -1
- package/dist/web/assets/index-u-aADQP-.js +0 -19
- package/dist/web/assets/useOperationLabels-BjPIVnJW.js +0 -1
- package/dist/web/assets/utils-rtPwWNel.js +0 -1
|
@@ -27,6 +27,12 @@ export declare class ProcessSupervisor {
|
|
|
27
27
|
list(): ProcessSnapshot[];
|
|
28
28
|
summaries(): ProcessSummary[];
|
|
29
29
|
write(processId: ProcessId, data: string): ProcessSnapshot;
|
|
30
|
+
/** Subscribe to raw PTY output; terminal reconnects start from the snapshot. */
|
|
31
|
+
subscribeOutput(processId: ProcessId, listener: (chunk: Buffer | null, stream?: "stdout" | "stderr") => void): {
|
|
32
|
+
snapshot: ProcessSnapshot;
|
|
33
|
+
unsubscribe: () => void;
|
|
34
|
+
};
|
|
35
|
+
resize(processId: ProcessId, cols: number, rows: number): void;
|
|
30
36
|
kill(processId: ProcessId, force?: boolean): ProcessSnapshot;
|
|
31
37
|
shutdown(): Promise<void>;
|
|
32
38
|
private onOutput;
|
|
@@ -36,6 +36,7 @@ export class ProcessSupervisor {
|
|
|
36
36
|
args,
|
|
37
37
|
cwd: request.cwd,
|
|
38
38
|
pty: request.pty ?? false,
|
|
39
|
+
...(request.pty ? { cols: request.cols, rows: request.rows } : {}),
|
|
39
40
|
timeoutMs: request.timeoutMs ?? this.defaultTimeoutMs,
|
|
40
41
|
env: request.env ?? {},
|
|
41
42
|
},
|
|
@@ -47,6 +48,8 @@ export class ProcessSupervisor {
|
|
|
47
48
|
backend = await (request.pty ? this.backends.pty : this.backends.pipe).start(request.command, args, {
|
|
48
49
|
cwd: request.cwd,
|
|
49
50
|
env: childEnvironment(request.env),
|
|
51
|
+
...(request.cols !== undefined ? { cols: request.cols } : {}),
|
|
52
|
+
...(request.rows !== undefined ? { rows: request.rows } : {}),
|
|
50
53
|
});
|
|
51
54
|
}
|
|
52
55
|
catch (error) {
|
|
@@ -81,6 +84,7 @@ export class ProcessSupervisor {
|
|
|
81
84
|
timeout: null,
|
|
82
85
|
forceTimeout: null,
|
|
83
86
|
outputEventTimer: null,
|
|
87
|
+
outputListeners: new Set(),
|
|
84
88
|
timedOut: false,
|
|
85
89
|
operationId,
|
|
86
90
|
settled,
|
|
@@ -129,6 +133,23 @@ export class ProcessSupervisor {
|
|
|
129
133
|
item.backend.write(data);
|
|
130
134
|
return this.snapshotOf(processId, item);
|
|
131
135
|
}
|
|
136
|
+
/** Subscribe to raw PTY output; terminal reconnects start from the snapshot. */
|
|
137
|
+
subscribeOutput(processId, listener) {
|
|
138
|
+
const item = this.require(processId);
|
|
139
|
+
item.outputListeners.add(listener);
|
|
140
|
+
return {
|
|
141
|
+
snapshot: this.snapshotOf(processId, item),
|
|
142
|
+
unsubscribe: () => item.outputListeners.delete(listener),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
resize(processId, cols, rows) {
|
|
146
|
+
const item = this.requireRunning(processId);
|
|
147
|
+
if (!item.request.pty || !item.backend.resize)
|
|
148
|
+
throw new ChatRoomError("UNSUPPORTED", "Not a resizable PTY");
|
|
149
|
+
if (![cols, rows].every((value) => Number.isInteger(value) && value >= 2 && value <= 500))
|
|
150
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid terminal dimensions");
|
|
151
|
+
item.backend.resize(cols, rows);
|
|
152
|
+
}
|
|
132
153
|
kill(processId, force = false) {
|
|
133
154
|
const item = this.requireRunning(processId);
|
|
134
155
|
item.backend.kill(force ? "SIGKILL" : "SIGTERM");
|
|
@@ -161,6 +182,8 @@ export class ProcessSupervisor {
|
|
|
161
182
|
if (!item || item.state !== "running")
|
|
162
183
|
return;
|
|
163
184
|
(stream === "stdout" ? item.stdout : item.stderr).append(chunk);
|
|
185
|
+
for (const listener of item.outputListeners)
|
|
186
|
+
listener(chunk, stream);
|
|
164
187
|
if (!item.outputEventTimer) {
|
|
165
188
|
item.outputEventTimer = setTimeout(() => {
|
|
166
189
|
item.outputEventTimer = null;
|
|
@@ -183,6 +206,9 @@ export class ProcessSupervisor {
|
|
|
183
206
|
item.exitCode = exitCode;
|
|
184
207
|
item.signal = signal;
|
|
185
208
|
item.state = signal ? "killed" : exitCode === 0 ? "exited" : "failed";
|
|
209
|
+
for (const listener of item.outputListeners)
|
|
210
|
+
listener(null);
|
|
211
|
+
item.outputListeners.clear();
|
|
186
212
|
const snapshot = this.snapshotOf(processId, item);
|
|
187
213
|
this.operations.finish(item.operationId, exitCode === 0 && !signal ? "success" : signal ? "cancelled" : "error", {
|
|
188
214
|
stdout: snapshot.stdout,
|
|
@@ -6,7 +6,8 @@ import type { PasskeyService } from "#auth/passkey-service";
|
|
|
6
6
|
import type { IngressPolicy } from "#auth/ingress-policy";
|
|
7
7
|
import type { CloudController } from "#plugins/cloud/controller";
|
|
8
8
|
import type { LogService } from "#core/logging/types";
|
|
9
|
-
|
|
9
|
+
import type { ChatRoomConfig } from "#config/types";
|
|
10
|
+
export declare function createApiRouter(application: WebRuntime, eventBus: RuntimeEventBus, auth: AuthService, passkeys: PasskeyService, ingress: IngressPolicy, cloud: CloudController, logs: LogService, config: ChatRoomConfig, runtimeStatus: () => {
|
|
10
11
|
version: string;
|
|
11
12
|
mcpRequests: number;
|
|
12
13
|
uptimeMinutes: number;
|
|
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|
|
2
2
|
import { ChatRoomError } from "#core/errors/chatroom-error";
|
|
3
3
|
import { boundedIntegerQuery, requireString, } from "#presentation/http/http-utils";
|
|
4
4
|
import { createProcessApiRouter } from "./process-api-router.js";
|
|
5
|
+
import { createTerminalApiRouter } from "./terminal-api-router.js";
|
|
5
6
|
import { createWorkspaceApiRouter } from "./workspace-api-router.js";
|
|
6
7
|
import { createGitApiRouter } from "./git-api-router.js";
|
|
7
8
|
import { createCloudApiRouter } from "./cloud-api-router.js";
|
|
@@ -10,11 +11,13 @@ import { apiAuthentication, createPrivateAuthApiRouter, createPublicAuthApiRoute
|
|
|
10
11
|
import { createRuntimeApiRouter } from "./runtime-api-router.js";
|
|
11
12
|
import { createLogsApiRouter } from "./logs-api-router.js";
|
|
12
13
|
import { createAgentApiRouter } from "./agent-api-router.js";
|
|
13
|
-
|
|
14
|
+
import { createRtcApiRouter } from "./rtc-api-router.js";
|
|
15
|
+
export function createApiRouter(application, eventBus, auth, passkeys, ingress, cloud, logs, config, runtimeStatus) {
|
|
14
16
|
const router = Router();
|
|
15
17
|
router.use(createPublicAuthApiRouter(auth, passkeys, ingress, logs));
|
|
16
18
|
router.use(apiAuthentication(auth, ingress));
|
|
17
19
|
router.use(createPrivateAuthApiRouter(passkeys, ingress, logs));
|
|
20
|
+
router.use(createRtcApiRouter(config));
|
|
18
21
|
router.use(createRuntimeApiRouter(application, eventBus, runtimeStatus));
|
|
19
22
|
router.use(createLogsApiRouter(logs));
|
|
20
23
|
router.get("/operations", (req, res) => {
|
|
@@ -49,6 +52,7 @@ export function createApiRouter(application, eventBus, auth, passkeys, ingress,
|
|
|
49
52
|
router.use(createWorkspaceApiRouter(application));
|
|
50
53
|
router.use(createGitApiRouter(application));
|
|
51
54
|
router.use(createProcessApiRouter(application));
|
|
55
|
+
router.use(createTerminalApiRouter(application));
|
|
52
56
|
router.use(createAgentApiRouter(application.agents));
|
|
53
57
|
router.use(createComputerApiRouter(application.computer, application.operations, ingress));
|
|
54
58
|
router.use(createCloudApiRouter(cloud, application.operations));
|
|
@@ -16,7 +16,7 @@ export function createLogsApiRouter(logs) {
|
|
|
16
16
|
...(modules?.length ? { modules: new Set(modules) } : {}),
|
|
17
17
|
}));
|
|
18
18
|
}));
|
|
19
|
-
router.get("/logs/stream", (
|
|
19
|
+
router.get("/logs/stream", (_req, res) => {
|
|
20
20
|
res.status(200);
|
|
21
21
|
res.setHeader("Content-Type", "text/event-stream");
|
|
22
22
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
@@ -28,7 +28,7 @@ export function createLogsApiRouter(logs) {
|
|
|
28
28
|
});
|
|
29
29
|
const keepalive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
30
30
|
keepalive.unref();
|
|
31
|
-
|
|
31
|
+
res.on("close", () => {
|
|
32
32
|
clearInterval(keepalive);
|
|
33
33
|
unsubscribe();
|
|
34
34
|
});
|
|
@@ -6,6 +6,54 @@ export function createProcessApiRouter(application) {
|
|
|
6
6
|
router.get("/processes/:processId/summary", (req, res) => {
|
|
7
7
|
res.json(application.getProcessSummary(requireString(req.params.processId, "processId")));
|
|
8
8
|
});
|
|
9
|
+
router.get("/processes/:processId/stream", (req, res) => {
|
|
10
|
+
const processId = requireString(req.params.processId, "processId");
|
|
11
|
+
// Validate and attach before flushing headers: invalid IDs return JSON 404.
|
|
12
|
+
let closed = false;
|
|
13
|
+
const write = (type, data) => {
|
|
14
|
+
if (closed || res.destroyed)
|
|
15
|
+
return;
|
|
16
|
+
if (res.writableLength > 512 * 1024) {
|
|
17
|
+
res.destroy(new Error("Process output stream is too slow"));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
21
|
+
};
|
|
22
|
+
const { snapshot, unsubscribe } = application.processes.subscribeOutput(processId, (chunk, stream) => {
|
|
23
|
+
if (!chunk) {
|
|
24
|
+
const { stdout: _stdout, stderr: _stderr, ...status } = application.getProcess(processId);
|
|
25
|
+
write("exit", status);
|
|
26
|
+
res.end();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
for (let offset = 0; offset < chunk.length; offset += 16 * 1024)
|
|
30
|
+
write("output", {
|
|
31
|
+
stream: stream ?? "stdout",
|
|
32
|
+
bytes: chunk
|
|
33
|
+
.subarray(offset, offset + 16 * 1024)
|
|
34
|
+
.toString("base64"),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
res.status(200);
|
|
39
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
40
|
+
res.setHeader("Cache-Control", "private, no-store, no-transform");
|
|
41
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
42
|
+
res.flushHeaders();
|
|
43
|
+
write("snapshot", snapshot);
|
|
44
|
+
if (snapshot.state !== "running") {
|
|
45
|
+
res.end();
|
|
46
|
+
unsubscribe();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const heartbeat = setInterval(() => write("ping", null), 15_000);
|
|
50
|
+
heartbeat.unref();
|
|
51
|
+
res.on("close", () => {
|
|
52
|
+
closed = true;
|
|
53
|
+
clearInterval(heartbeat);
|
|
54
|
+
unsubscribe();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
9
57
|
router.get("/processes/:processId", (req, res) => {
|
|
10
58
|
res.json(application.getProcess(requireString(req.params.processId, "processId")));
|
|
11
59
|
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { Router } from "express";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { CreditSender, INITIAL_CREDIT, creditFrame, decodeFrame, metaFrame, parseMeta, readCredit, } from "#plugins/cloud/v2/stream";
|
|
6
|
+
const offerSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
type: z.literal("offer"),
|
|
9
|
+
sdp: z.string().min(1).max(64_000),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const openSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
15
|
+
path: z.string().max(8192),
|
|
16
|
+
headers: z.record(z.string(), z.string()).default({}),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
const STUN = ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"];
|
|
20
|
+
const MAX_PEERS = 8;
|
|
21
|
+
const MAX_STREAMS = 32;
|
|
22
|
+
const MAX_UPLOAD = 8 * 1024 * 1024;
|
|
23
|
+
const MAX_IDLE_MS = 30 * 60 * 1000;
|
|
24
|
+
const HEADER_NAMES = new Set([
|
|
25
|
+
"accept",
|
|
26
|
+
"content-type",
|
|
27
|
+
"range",
|
|
28
|
+
"if-none-match",
|
|
29
|
+
"if-modified-since",
|
|
30
|
+
"x-chatroom-mime-type",
|
|
31
|
+
]);
|
|
32
|
+
const RESPONSE_HEADERS = new Set([
|
|
33
|
+
"content-type",
|
|
34
|
+
"content-disposition",
|
|
35
|
+
"content-length",
|
|
36
|
+
"content-range",
|
|
37
|
+
"accept-ranges",
|
|
38
|
+
"cache-control",
|
|
39
|
+
"etag",
|
|
40
|
+
"last-modified",
|
|
41
|
+
]);
|
|
42
|
+
/** V2 DataChannel HTTP transport. Cloud and self-hosting use the same HTTPS signaling API. */
|
|
43
|
+
export function createRtcApiRouter(config) {
|
|
44
|
+
const router = Router();
|
|
45
|
+
const peers = new Map();
|
|
46
|
+
router.post("/rtc/offer", async (req, res) => {
|
|
47
|
+
const parsed = offerSchema.safeParse(req.body);
|
|
48
|
+
if (!parsed.success ||
|
|
49
|
+
!parsed.data.sdp.includes("m=application ") ||
|
|
50
|
+
!parsed.data.sdp.includes("a=fingerprint:") ||
|
|
51
|
+
!parsed.data.sdp.includes("a=ice-ufrag:")) {
|
|
52
|
+
res.status(400).json({ error: "invalid_offer" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (peers.size >= MAX_PEERS) {
|
|
56
|
+
res.status(429).json({ error: "rtc_capacity" });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let library;
|
|
60
|
+
try {
|
|
61
|
+
library = await import("node-datachannel");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
res.status(503).json({ error: "rtc_unavailable" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const id = randomUUID();
|
|
68
|
+
const pc = new library.PeerConnection(id, { iceServers: STUN });
|
|
69
|
+
peers.set(id, pc);
|
|
70
|
+
const streams = new Map();
|
|
71
|
+
let dc = null;
|
|
72
|
+
let closed = false;
|
|
73
|
+
const cookie = req.headers.cookie ?? "";
|
|
74
|
+
const host = req.headers.host ?? "localhost";
|
|
75
|
+
const origin = req.headers.origin ?? "";
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
if (closed)
|
|
78
|
+
return;
|
|
79
|
+
closed = true;
|
|
80
|
+
clearTimeout(expiry);
|
|
81
|
+
for (const stream of streams.values()) {
|
|
82
|
+
stream.sender?.cancel();
|
|
83
|
+
stream.request.destroy();
|
|
84
|
+
}
|
|
85
|
+
streams.clear();
|
|
86
|
+
peers.delete(id);
|
|
87
|
+
try {
|
|
88
|
+
dc?.close();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* connection already closed */
|
|
92
|
+
}
|
|
93
|
+
pc.close();
|
|
94
|
+
};
|
|
95
|
+
let expiry;
|
|
96
|
+
const activity = () => {
|
|
97
|
+
clearTimeout(expiry);
|
|
98
|
+
expiry = setTimeout(cleanup, MAX_IDLE_MS);
|
|
99
|
+
expiry.unref();
|
|
100
|
+
};
|
|
101
|
+
activity();
|
|
102
|
+
pc.onStateChange((state) => {
|
|
103
|
+
if (state === "closed" || state === "failed")
|
|
104
|
+
cleanup();
|
|
105
|
+
});
|
|
106
|
+
pc.onDataChannel((channel) => {
|
|
107
|
+
if (closed || dc || channel.getLabel() !== "chatroom-v2") {
|
|
108
|
+
channel.close();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
dc = channel;
|
|
112
|
+
channel.setBufferedAmountLowThreshold(512 * 1024);
|
|
113
|
+
channel.onBufferedAmountLow(() => {
|
|
114
|
+
for (const stream of streams.values())
|
|
115
|
+
stream.sender?.writable();
|
|
116
|
+
});
|
|
117
|
+
const send = (frame) => {
|
|
118
|
+
try {
|
|
119
|
+
if (!channel.isOpen() ||
|
|
120
|
+
channel.bufferedAmount() > 4 * 1024 * 1024 ||
|
|
121
|
+
!channel.sendMessageBinary(Buffer.from(frame))) {
|
|
122
|
+
cleanup();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
activity();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
cleanup();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const abort = (streamId) => {
|
|
132
|
+
const stream = streams.get(streamId);
|
|
133
|
+
if (!stream)
|
|
134
|
+
return;
|
|
135
|
+
streams.delete(streamId);
|
|
136
|
+
stream.sender?.cancel();
|
|
137
|
+
stream.request.destroy();
|
|
138
|
+
};
|
|
139
|
+
const reset = (streamId, reason) => {
|
|
140
|
+
if (!streams.has(streamId))
|
|
141
|
+
return;
|
|
142
|
+
abort(streamId);
|
|
143
|
+
send(metaFrame(5 /* Op.RESET */, streamId, { reason }));
|
|
144
|
+
};
|
|
145
|
+
channel.onMessage((raw) => {
|
|
146
|
+
if (closed || typeof raw === "string") {
|
|
147
|
+
cleanup();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
activity();
|
|
152
|
+
const frame = decodeFrame(new Uint8Array(raw));
|
|
153
|
+
if (!frame.id)
|
|
154
|
+
throw Error("Reserved RTC control stream");
|
|
155
|
+
if (frame.op === 1 /* Op.OPEN */) {
|
|
156
|
+
if (streams.has(frame.id) || streams.size >= MAX_STREAMS) {
|
|
157
|
+
reset(frame.id, "capacity");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const open = openSchema.parse(parseMeta(frame.payload));
|
|
161
|
+
if (!(open.path.startsWith("/api/") ||
|
|
162
|
+
open.path.startsWith("/assets/")) ||
|
|
163
|
+
open.path.startsWith("/api/rtc/") ||
|
|
164
|
+
(open.path.startsWith("/assets/") && open.method !== "GET") ||
|
|
165
|
+
open.path.includes("#")) {
|
|
166
|
+
reset(frame.id, "invalid_path");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const headers = { host, cookie };
|
|
170
|
+
if (origin)
|
|
171
|
+
headers.origin = origin;
|
|
172
|
+
for (const [name, value] of Object.entries(open.headers))
|
|
173
|
+
if (HEADER_NAMES.has(name.toLowerCase()))
|
|
174
|
+
headers[name.toLowerCase()] = value;
|
|
175
|
+
const request = http.request({
|
|
176
|
+
host: ["0.0.0.0", "::"].includes(config.server.host)
|
|
177
|
+
? "127.0.0.1"
|
|
178
|
+
: config.server.host,
|
|
179
|
+
port: config.server.port,
|
|
180
|
+
method: open.method,
|
|
181
|
+
path: open.path,
|
|
182
|
+
headers,
|
|
183
|
+
}, (response) => {
|
|
184
|
+
const stream = streams.get(frame.id);
|
|
185
|
+
if (!stream) {
|
|
186
|
+
response.destroy();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const responseHeaders = Object.fromEntries(Object.entries(response.headers).filter(([name, value]) => RESPONSE_HEADERS.has(name) && typeof value === "string"));
|
|
190
|
+
send(metaFrame(2 /* Op.META */, frame.id, {
|
|
191
|
+
status: response.statusCode ?? 502,
|
|
192
|
+
headers: responseHeaders,
|
|
193
|
+
}));
|
|
194
|
+
const writer = new CreditSender(frame.id, send, () => response.pause(), () => response.resume(), () => {
|
|
195
|
+
streams.delete(frame.id);
|
|
196
|
+
}, () => channel.bufferedAmount() < 1024 * 1024);
|
|
197
|
+
stream.sender = writer;
|
|
198
|
+
response.on("data", (chunk) => {
|
|
199
|
+
if (!streams.has(frame.id))
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
writer.enqueue(chunk);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
reset(frame.id, "send_overflow");
|
|
206
|
+
response.destroy();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
response.on("end", () => writer.end());
|
|
210
|
+
response.on("error", () => reset(frame.id, "response_error"));
|
|
211
|
+
response.on("close", () => {
|
|
212
|
+
if (!response.complete && streams.has(frame.id))
|
|
213
|
+
reset(frame.id, "response_disconnected");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
const stream = {
|
|
217
|
+
request,
|
|
218
|
+
sender: null,
|
|
219
|
+
requestBytes: 0,
|
|
220
|
+
receiveCredit: INITIAL_CREDIT,
|
|
221
|
+
consumed: 0,
|
|
222
|
+
drainArmed: false,
|
|
223
|
+
ended: false,
|
|
224
|
+
};
|
|
225
|
+
streams.set(frame.id, stream);
|
|
226
|
+
request.on("error", () => {
|
|
227
|
+
if (streams.has(frame.id))
|
|
228
|
+
reset(frame.id, "request_failed");
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else if (frame.op === 3 /* Op.DATA */) {
|
|
232
|
+
const stream = streams.get(frame.id);
|
|
233
|
+
if (!stream)
|
|
234
|
+
return;
|
|
235
|
+
stream.requestBytes += frame.payload.byteLength;
|
|
236
|
+
stream.receiveCredit -= frame.payload.byteLength;
|
|
237
|
+
if (stream.ended ||
|
|
238
|
+
stream.requestBytes > MAX_UPLOAD ||
|
|
239
|
+
stream.receiveCredit < 0) {
|
|
240
|
+
reset(frame.id, "request_limit");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (stream.request.write(frame.payload)) {
|
|
244
|
+
stream.receiveCredit += frame.payload.byteLength;
|
|
245
|
+
send(creditFrame(frame.id, frame.payload.byteLength));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
stream.consumed += frame.payload.byteLength;
|
|
249
|
+
if (!stream.drainArmed) {
|
|
250
|
+
stream.drainArmed = true;
|
|
251
|
+
stream.request.once("drain", () => {
|
|
252
|
+
stream.drainArmed = false;
|
|
253
|
+
if (!streams.has(frame.id))
|
|
254
|
+
return;
|
|
255
|
+
stream.receiveCredit += stream.consumed;
|
|
256
|
+
send(creditFrame(frame.id, stream.consumed));
|
|
257
|
+
stream.consumed = 0;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else if (frame.op === 4 /* Op.FIN */) {
|
|
263
|
+
const stream = streams.get(frame.id);
|
|
264
|
+
if (!stream || stream.ended)
|
|
265
|
+
return;
|
|
266
|
+
stream.ended = true;
|
|
267
|
+
stream.request.end();
|
|
268
|
+
}
|
|
269
|
+
else if (frame.op === 6 /* Op.CREDIT */) {
|
|
270
|
+
streams.get(frame.id)?.sender?.addCredit(readCredit(frame.payload));
|
|
271
|
+
}
|
|
272
|
+
else if (frame.op === 5 /* Op.RESET */) {
|
|
273
|
+
abort(frame.id);
|
|
274
|
+
}
|
|
275
|
+
else
|
|
276
|
+
throw Error("Unexpected RTC frame");
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
cleanup();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
channel.onClosed(cleanup);
|
|
283
|
+
channel.onError(cleanup);
|
|
284
|
+
});
|
|
285
|
+
try {
|
|
286
|
+
const answer = await new Promise((resolve, reject) => {
|
|
287
|
+
const timeout = setTimeout(() => reject(Error("RTC answer timeout")), 12_000);
|
|
288
|
+
const finish = () => {
|
|
289
|
+
const description = pc.localDescription();
|
|
290
|
+
if (!description || description.type !== "answer")
|
|
291
|
+
return;
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
resolve({ type: "answer", sdp: description.sdp });
|
|
294
|
+
};
|
|
295
|
+
pc.onGatheringStateChange((state) => {
|
|
296
|
+
if (state === "complete")
|
|
297
|
+
finish();
|
|
298
|
+
});
|
|
299
|
+
pc.onLocalDescription(() => {
|
|
300
|
+
if (pc.gatheringState() === "complete")
|
|
301
|
+
finish();
|
|
302
|
+
});
|
|
303
|
+
pc.setRemoteDescription(parsed.data.sdp, "offer");
|
|
304
|
+
});
|
|
305
|
+
if (closed)
|
|
306
|
+
throw Error("RTC negotiation ended");
|
|
307
|
+
res.json(answer);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
cleanup();
|
|
311
|
+
if (!res.headersSent)
|
|
312
|
+
res.status(503).json({ error: "rtc_negotiation_failed" });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
return router;
|
|
316
|
+
}
|
|
@@ -13,7 +13,7 @@ export function createRuntimeApiRouter(application, eventBus, runtimeStatus) {
|
|
|
13
13
|
throw new ChatRoomError("INVALID_INPUT", "enabled must be a boolean");
|
|
14
14
|
res.json(application.mcpTools.setEnabled(requireString(req.params.toolName, "toolName"), body.enabled));
|
|
15
15
|
});
|
|
16
|
-
router.get("/events", (
|
|
16
|
+
router.get("/events", (_req, res) => {
|
|
17
17
|
res.status(200);
|
|
18
18
|
res.setHeader("Content-Type", "text/event-stream");
|
|
19
19
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
@@ -25,7 +25,7 @@ export function createRuntimeApiRouter(application, eventBus, runtimeStatus) {
|
|
|
25
25
|
});
|
|
26
26
|
const keepalive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
27
27
|
keepalive.unref();
|
|
28
|
-
|
|
28
|
+
res.on("close", () => {
|
|
29
29
|
clearInterval(keepalive);
|
|
30
30
|
unsubscribe();
|
|
31
31
|
});
|