@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.
Files changed (87) hide show
  1. package/dist/core/runtime/child-environment.js +3 -2
  2. package/dist/infrastructure/http/http-server.js +7 -1
  3. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
  4. package/dist/native/windows/chatroom-computer-helper.exe +0 -0
  5. package/dist/plugins/cloud/tunnel-client.d.ts +2 -84
  6. package/dist/plugins/cloud/tunnel-client.js +2 -426
  7. package/dist/plugins/cloud/v2/stream.d.ts +48 -0
  8. package/dist/plugins/cloud/v2/stream.js +156 -0
  9. package/dist/plugins/cloud/v2/tunnel-client.d.ts +45 -0
  10. package/dist/plugins/cloud/v2/tunnel-client.js +426 -0
  11. package/dist/plugins/process/backend.d.ts +3 -0
  12. package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +2 -0
  13. package/dist/plugins/process/infrastructure/pty-process-backend.js +5 -2
  14. package/dist/plugins/process/infrastructure/terminal-shell.d.ts +4 -0
  15. package/dist/plugins/process/infrastructure/terminal-shell.js +10 -0
  16. package/dist/plugins/process/process-supervisor.d.ts +6 -0
  17. package/dist/plugins/process/process-supervisor.js +26 -0
  18. package/dist/plugins/process/types.d.ts +2 -0
  19. package/dist/plugins/web/http/api-router.d.ts +2 -1
  20. package/dist/plugins/web/http/api-router.js +5 -1
  21. package/dist/plugins/web/http/logs-api-router.js +2 -2
  22. package/dist/plugins/web/http/process-api-router.js +48 -0
  23. package/dist/plugins/web/http/rtc-api-router.d.ts +4 -0
  24. package/dist/plugins/web/http/rtc-api-router.js +316 -0
  25. package/dist/plugins/web/http/runtime-api-router.js +2 -2
  26. package/dist/plugins/web/http/terminal-api-router.d.ts +4 -0
  27. package/dist/plugins/web/http/terminal-api-router.js +132 -0
  28. package/dist/web/assets/{AgentView-BGLIBoxF.css → AgentView-CMxIn3KB.css} +1 -1
  29. package/dist/web/assets/AgentView-DQr-5aLK.js +4 -0
  30. package/dist/web/assets/CloudView-B2VBut3O.js +1 -0
  31. package/dist/web/assets/CloudView-Bgp5G965.css +1 -0
  32. package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-DkN8x_6L.js +3 -0
  33. package/dist/web/assets/ComputerView-CIFF1zUb.js +1 -0
  34. package/dist/web/assets/{MarkdownContent-CkjR5fs7.js → MarkdownContent-DRZp88_G.js} +2 -2
  35. package/dist/web/assets/McpToolsView-CSEru3ck.js +1 -0
  36. package/dist/web/assets/OperationsView-B_Nv1Mgi.js +4 -0
  37. package/dist/web/assets/ProcessesView-5K8wNiPC.js +5 -0
  38. package/dist/web/assets/ProcessesView-BmNdR3C0.css +1 -0
  39. package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-CQEj-YRM.js +1 -0
  40. package/dist/web/assets/SystemLogsView-CBINILPo.js +1 -0
  41. package/dist/web/assets/SystemLogsView-COvwamo5.css +1 -0
  42. package/dist/web/assets/TerminalView-DNaKG3II.css +1 -0
  43. package/dist/web/assets/TerminalView-Dvyy2GKF.js +16 -0
  44. package/dist/web/assets/VSelect-CBX17QQj.js +1 -0
  45. package/dist/web/assets/VSpacer-9N_Ffoce.js +1 -0
  46. package/dist/web/assets/VSwitch-CvQYU48e.js +1 -0
  47. package/dist/web/assets/VTabs-CftrBFlF.js +1 -0
  48. package/dist/web/assets/VTextarea-Dcui00tn.js +1 -0
  49. package/dist/web/assets/WorkspaceDownloadPreview-DJKSNaZ_.css +1 -0
  50. package/dist/web/assets/WorkspaceDownloadPreview-DzM2p_xl.js +1 -0
  51. package/dist/web/assets/WorkspaceMediaPreview-ctIyG3Ru.js +4 -0
  52. package/dist/web/assets/{WorkspacePdfPreview-CO-ZtDHG.js → WorkspacePdfPreview-CeizxEAH.js} +2 -2
  53. package/dist/web/assets/WorkspacesView-DcmqHGvr.js +2 -0
  54. package/dist/web/assets/index-BRI_re-n.css +1 -0
  55. package/dist/web/assets/index-DTyxBHlJ.js +19 -0
  56. package/dist/web/assets/{useMasterDetailFocus-D1mQ8Qjk.js → useMasterDetailFocus-DR5N95_A.js} +1 -1
  57. package/dist/web/assets/useOperationLabels-Dou4m0ac.js +1 -0
  58. package/dist/web/index.html +2 -2
  59. package/dist/web/transport-sw.js +411 -0
  60. package/package.json +9 -4
  61. package/scripts/ensure-pty-helper.mjs +28 -0
  62. package/dist/web/assets/AgentView-Dd6zCbr-.js +0 -4
  63. package/dist/web/assets/CloudView-DVVvwuWW.css +0 -1
  64. package/dist/web/assets/CloudView-DquY1HDZ.js +0 -1
  65. package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-ekOImmBP.js +0 -3
  66. package/dist/web/assets/ComputerView-RD2gMxJZ.js +0 -1
  67. package/dist/web/assets/McpToolsView-Dg2-iaDP.js +0 -1
  68. package/dist/web/assets/OperationsView-C8yCX7WZ.js +0 -4
  69. package/dist/web/assets/ProcessesView-C834LC-f.js +0 -4
  70. package/dist/web/assets/ProcessesView-Ce6iVwjA.css +0 -1
  71. package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-By2gilBw.js +0 -1
  72. package/dist/web/assets/SystemLogsView-B6KcZv1J.js +0 -1
  73. package/dist/web/assets/SystemLogsView-CofP4Jvi.css +0 -1
  74. package/dist/web/assets/VSelect-BtAJX-XJ.js +0 -1
  75. package/dist/web/assets/VSpacer-M8bpkhTH.js +0 -1
  76. package/dist/web/assets/VSwitch-C0VTnwLJ.js +0 -1
  77. package/dist/web/assets/VTabs-g61mykvY.js +0 -1
  78. package/dist/web/assets/VTextarea-CRYQYRqg.js +0 -1
  79. package/dist/web/assets/WorkspaceDownloadPreview-BrESF-82.js +0 -1
  80. package/dist/web/assets/WorkspaceDownloadPreview-DnxhH5Q7.css +0 -1
  81. package/dist/web/assets/WorkspaceMediaPreview-HYajqgZ3.js +0 -4
  82. package/dist/web/assets/WorkspacesView-BJCnzBLj.js +0 -2
  83. package/dist/web/assets/_plugin-vue_export-helper-DlAUqK2U.js +0 -1
  84. package/dist/web/assets/index-CZ-4tJlF.css +0 -1
  85. package/dist/web/assets/index-u-aADQP-.js +0 -19
  86. package/dist/web/assets/useOperationLabels-BjPIVnJW.js +0 -1
  87. 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,6 +6,8 @@ export interface ProcessStartRequest {
6
6
  args?: string[];
7
7
  env?: Record<string, string>;
8
8
  pty?: boolean;
9
+ cols?: number;
10
+ rows?: number;
9
11
  timeoutMs?: number;
10
12
  }
11
13
  export interface ProcessSnapshot {
@@ -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
- export declare function createApiRouter(application: WebRuntime, eventBus: RuntimeEventBus, auth: AuthService, passkeys: PasskeyService, ingress: IngressPolicy, cloud: CloudController, logs: LogService, runtimeStatus: () => {
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
- export function createApiRouter(application, eventBus, auth, passkeys, ingress, cloud, logs, runtimeStatus) {
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", (req, res) => {
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
- req.on("close", () => {
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,4 @@
1
+ import { Router } from "express";
2
+ import type { ChatRoomConfig } from "#config/types";
3
+ /** V2 DataChannel HTTP transport. Cloud and self-hosting use the same HTTPS signaling API. */
4
+ export declare function createRtcApiRouter(config: ChatRoomConfig): Router;
@@ -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", (req, res) => {
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
- req.on("close", () => {
28
+ res.on("close", () => {
29
29
  clearInterval(keepalive);
30
30
  unsubscribe();
31
31
  });
@@ -0,0 +1,4 @@
1
+ import { Router } from "express";
2
+ import type { WebRuntime } from "#plugins/web/runtime";
3
+ /** PTY I/O over the existing V2 HTTP stream transport; no extra socket/protocol. */
4
+ export declare function createTerminalApiRouter(app: WebRuntime): Router;