codepiper 0.1.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 (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,1588 @@
1
+ /**
2
+ * WebSocket streaming support for real-time event delivery
3
+ */
4
+
5
+ import type { ProviderEvent } from "@codepiper/core";
6
+ import type { ServerWebSocket } from "bun";
7
+ import type { TerminalCursor } from "../sessions/tmuxSession";
8
+
9
+ export interface WSMessage {
10
+ op: string;
11
+ topic?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ type SessionEventsTopic = `session:${string}:events`;
16
+ type SessionPtyTopic = `session:${string}:pty`;
17
+ type SessionsTopic = "sessions";
18
+ type NotificationsTopic = "notifications";
19
+ type WSTopic = SessionEventsTopic | SessionPtyTopic | SessionsTopic | NotificationsTopic;
20
+
21
+ interface PtyOutputMessage {
22
+ topic: SessionPtyTopic;
23
+ type: "pty_output";
24
+ data: string;
25
+ seq: number;
26
+ cursor?: TerminalCursor;
27
+ }
28
+
29
+ interface PtyPatchMessage {
30
+ topic: SessionPtyTopic;
31
+ type: "pty_patch";
32
+ baseSeq: number;
33
+ seq: number;
34
+ start: number;
35
+ deleteCount: number;
36
+ data: string;
37
+ cursor?: TerminalCursor;
38
+ }
39
+
40
+ interface SessionEventsMessage {
41
+ topic: SessionEventsTopic;
42
+ data: ProviderEvent | Record<string, unknown>;
43
+ }
44
+
45
+ interface HelloAckMessage {
46
+ op: "hello_ack";
47
+ version: number;
48
+ features: {
49
+ ptyReplay: boolean;
50
+ ptyPatch: boolean;
51
+ ptyBinary: boolean;
52
+ ptyPaste: boolean;
53
+ };
54
+ negotiated: {
55
+ ptyPatch: boolean;
56
+ ptyBinary: boolean;
57
+ ptyPaste: boolean;
58
+ };
59
+ }
60
+
61
+ type SessionsMessage = {
62
+ topic: SessionsTopic;
63
+ } & Record<string, unknown>;
64
+
65
+ interface NotificationsMessage {
66
+ topic: NotificationsTopic;
67
+ type: "notification_created" | "notification_read" | "notification_counts_updated";
68
+ data: Record<string, unknown>;
69
+ }
70
+
71
+ type PtyFrameMessage = PtyOutputMessage | PtyPatchMessage;
72
+ type OutboundWSMessage =
73
+ | PtyFrameMessage
74
+ | SessionEventsMessage
75
+ | SessionsMessage
76
+ | NotificationsMessage;
77
+ type InputOperation = "pty_input" | "pty_key" | "pty_paste";
78
+ type InboundRateClass = "control" | "pty_input" | "pty_key" | "pty_paste";
79
+ type PolicyAction = "allow" | "deny" | "ask";
80
+ type DisconnectReason =
81
+ | "client_disconnect"
82
+ | "rate_limit"
83
+ | "backpressure"
84
+ | "drop"
85
+ | "send_error"
86
+ | "shutdown";
87
+
88
+ export interface WsTransportTelemetrySnapshot {
89
+ startedAt: number;
90
+ uptimeMs: number;
91
+ activeConnections: number;
92
+ totalConnectionsAccepted: number;
93
+ totalConnectionsDisconnected: number;
94
+ disconnectReasons: Record<DisconnectReason, number>;
95
+ inboundMessages: number;
96
+ rateLimitedMessages: number;
97
+ rateLimitedControlMessages: number;
98
+ rateLimitedPtyInputMessages: number;
99
+ rateLimitedPtyKeyMessages: number;
100
+ rateLimitedPtyPasteMessages: number;
101
+ subscribeOps: number;
102
+ unsubscribeOps: number;
103
+ ptyInputOps: number;
104
+ ptyKeyOps: number;
105
+ ptyPasteOps: number;
106
+ ptyInputDispatchErrors: number;
107
+ helloOps: number;
108
+ helloAckSent: number;
109
+ ptyFramesBroadcast: number;
110
+ ptyPatchFramesBroadcast: number;
111
+ ptyBinaryFramesBroadcast: number;
112
+ ptyBytesBroadcast: number;
113
+ replayRequests: number;
114
+ replayFramesSent: number;
115
+ replayMisses: number;
116
+ sendSuccess: number;
117
+ sendBackpressureSignals: number;
118
+ sendQueued: number;
119
+ sendQueueFlushes: number;
120
+ sendQueueOverflows: number;
121
+ sendDrops: number;
122
+ sendErrors: number;
123
+ }
124
+
125
+ interface WsTransportTelemetryCounters {
126
+ totalConnectionsAccepted: number;
127
+ totalConnectionsDisconnected: number;
128
+ disconnectReasons: Record<DisconnectReason, number>;
129
+ inboundMessages: number;
130
+ rateLimitedMessages: number;
131
+ rateLimitedControlMessages: number;
132
+ rateLimitedPtyInputMessages: number;
133
+ rateLimitedPtyKeyMessages: number;
134
+ rateLimitedPtyPasteMessages: number;
135
+ subscribeOps: number;
136
+ unsubscribeOps: number;
137
+ ptyInputOps: number;
138
+ ptyKeyOps: number;
139
+ ptyPasteOps: number;
140
+ ptyInputDispatchErrors: number;
141
+ helloOps: number;
142
+ helloAckSent: number;
143
+ ptyFramesBroadcast: number;
144
+ ptyPatchFramesBroadcast: number;
145
+ ptyBinaryFramesBroadcast: number;
146
+ ptyBytesBroadcast: number;
147
+ replayRequests: number;
148
+ replayFramesSent: number;
149
+ replayMisses: number;
150
+ sendSuccess: number;
151
+ sendBackpressureSignals: number;
152
+ sendQueued: number;
153
+ sendQueueFlushes: number;
154
+ sendQueueOverflows: number;
155
+ sendDrops: number;
156
+ sendErrors: number;
157
+ }
158
+
159
+ export const MAX_WS_MESSAGE_BYTES = 64 * 1024; // 64KB
160
+ export const WS_PROTOCOL_VERSION = 1;
161
+ export const WS_RATE_WINDOW_MS = 10_000; // 10 seconds
162
+ export const WS_MAX_CONTROL_MESSAGES_PER_WINDOW = 120;
163
+ export const WS_MAX_PTY_INPUT_MESSAGES_PER_WINDOW = 600;
164
+ export const WS_MAX_PTY_KEY_MESSAGES_PER_WINDOW = 2000;
165
+ export const WS_MAX_PTY_PASTE_MESSAGES_PER_WINDOW = 600;
166
+ export const WS_MAX_PTY_PASTE_CHUNKS = 512;
167
+ export const WS_MAX_PTY_PASTE_BYTES = 2 * 1024 * 1024;
168
+ export const WS_MAX_ACTIVE_PASTE_ASSEMBLIES_PER_CLIENT = 8;
169
+ export const WS_PTY_PASTE_ASSEMBLY_TTL_MS = 30_000;
170
+ // Backward-compatible alias for tests/tools using the previous single-quota name.
171
+ export const WS_MAX_MESSAGES_PER_WINDOW = WS_MAX_CONTROL_MESSAGES_PER_WINDOW;
172
+ export const WS_MAX_SUBSCRIPTIONS = 200;
173
+ export const WS_MAX_CONSECUTIVE_BACKPRESSURE = 8;
174
+ export const WS_MAX_PENDING_OUTBOUND_MESSAGES = 256;
175
+ export const WS_MAX_PENDING_OUTBOUND_BYTES = 2 * 1024 * 1024;
176
+ export const WS_PTY_REPLAY_BUFFER_SIZE = 64;
177
+ const WS_PTY_PATCH_ENABLED = process.env.CODEPIPER_WS_PTY_PATCH === "1";
178
+ const WS_PTY_BINARY_ENABLED = process.env.CODEPIPER_WS_PTY_BINARY === "1";
179
+ const WS_PTY_PASTE_ENABLED = process.env.CODEPIPER_WS_PTY_PASTE !== "0";
180
+ const WS_PTY_BINARY_MAGIC = 0x43; // "C"
181
+ const WS_PTY_BINARY_VERSION = 1;
182
+ const WS_PTY_BINARY_TYPE_OUTPUT = 1;
183
+ const WS_PTY_BINARY_TYPE_PATCH = 2;
184
+ const WS_PTY_BINARY_HEADER_BYTES = 10;
185
+ const WS_PTY_BINARY_PATCH_META_BYTES = 16;
186
+ const WS_PTY_BINARY_OUTPUT_META_BYTES = 4;
187
+ const WS_PTY_BINARY_MAX_TOPIC_BYTES = 0xffff;
188
+ const WS_PTY_BINARY_MAX_UINT32 = 0xffffffff;
189
+ const WS_BINARY_TEXT_ENCODER = new TextEncoder();
190
+
191
+ /**
192
+ * Parse and validate incoming WS message with size guard.
193
+ */
194
+ export function parseWsMessage(raw: string | Buffer): WSMessage {
195
+ const size = typeof raw === "string" ? Buffer.byteLength(raw, "utf-8") : raw.byteLength;
196
+ if (size > MAX_WS_MESSAGE_BYTES) {
197
+ throw new Error(`WebSocket message too large (max ${MAX_WS_MESSAGE_BYTES} bytes)`);
198
+ }
199
+
200
+ const text = typeof raw === "string" ? raw : raw.toString("utf-8");
201
+ let parsed: unknown;
202
+ try {
203
+ parsed = JSON.parse(text);
204
+ } catch {
205
+ throw new Error("Invalid JSON message");
206
+ }
207
+
208
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
209
+ throw new Error("Invalid message: expected JSON object");
210
+ }
211
+
212
+ return parsed as WSMessage;
213
+ }
214
+
215
+ interface ClientState {
216
+ ws: ServerWebSocket<unknown> | any;
217
+ subscriptions: Set<WSTopic>;
218
+ rateWindowStartedAt: number;
219
+ controlMessagesInWindow: number;
220
+ ptyInputMessagesInWindow: number;
221
+ ptyKeyMessagesInWindow: number;
222
+ ptyPasteMessagesInWindow: number;
223
+ consecutiveBackpressure: number;
224
+ supportsPtyPatch: boolean;
225
+ supportsPtyBinary: boolean;
226
+ supportsPtyPaste: boolean;
227
+ backpressureActive: boolean;
228
+ pendingOutbound: Array<{ payload: string | Uint8Array; bytes: number }>;
229
+ pendingOutboundBytes: number;
230
+ pasteAssemblies: Map<string, PendingPasteAssembly>;
231
+ pendingDisconnectReason?: DisconnectReason;
232
+ }
233
+
234
+ interface PendingPasteAssembly {
235
+ sessionId: string;
236
+ chunkCount: number;
237
+ nextChunkIndex: number;
238
+ chunks: string[];
239
+ totalBytes: number;
240
+ startedAt: number;
241
+ }
242
+
243
+ interface WebSocketManagerOptions {
244
+ enablePtyPatch?: boolean;
245
+ enablePtyBinary?: boolean;
246
+ enablePtyPaste?: boolean;
247
+ onPtyInput?: (sessionId: string, data: string) => Promise<void> | void;
248
+ onPtyKey?: (sessionId: string, key: string) => Promise<void> | void;
249
+ onPtyPaste?: (sessionId: string, data: string) => Promise<void> | void;
250
+ }
251
+
252
+ /**
253
+ * WebSocket manager for handling client connections and event broadcasting
254
+ */
255
+ export class WebSocketManager {
256
+ private clients = new Map<ServerWebSocket<unknown> | any, ClientState>();
257
+ private ptySequenceBySession = new Map<string, number>();
258
+ private ptyReplayBufferBySession = new Map<string, PtyOutputMessage[]>();
259
+ private ptyLastFrameBySession = new Map<string, string>();
260
+ private readonly enablePtyPatch: boolean;
261
+ private readonly enablePtyBinary: boolean;
262
+ private readonly enablePtyPaste: boolean;
263
+ private readonly onPtyInput?: (sessionId: string, data: string) => Promise<void> | void;
264
+ private readonly onPtyKey?: (sessionId: string, key: string) => Promise<void> | void;
265
+ private readonly onPtyPaste?: (sessionId: string, data: string) => Promise<void> | void;
266
+ private telemetryStartedAt = Date.now();
267
+ private telemetry: WsTransportTelemetryCounters = {
268
+ totalConnectionsAccepted: 0,
269
+ totalConnectionsDisconnected: 0,
270
+ disconnectReasons: {
271
+ client_disconnect: 0,
272
+ rate_limit: 0,
273
+ backpressure: 0,
274
+ drop: 0,
275
+ send_error: 0,
276
+ shutdown: 0,
277
+ },
278
+ inboundMessages: 0,
279
+ rateLimitedMessages: 0,
280
+ rateLimitedControlMessages: 0,
281
+ rateLimitedPtyInputMessages: 0,
282
+ rateLimitedPtyKeyMessages: 0,
283
+ rateLimitedPtyPasteMessages: 0,
284
+ subscribeOps: 0,
285
+ unsubscribeOps: 0,
286
+ ptyInputOps: 0,
287
+ ptyKeyOps: 0,
288
+ ptyPasteOps: 0,
289
+ ptyInputDispatchErrors: 0,
290
+ helloOps: 0,
291
+ helloAckSent: 0,
292
+ ptyFramesBroadcast: 0,
293
+ ptyPatchFramesBroadcast: 0,
294
+ ptyBinaryFramesBroadcast: 0,
295
+ ptyBytesBroadcast: 0,
296
+ replayRequests: 0,
297
+ replayFramesSent: 0,
298
+ replayMisses: 0,
299
+ sendSuccess: 0,
300
+ sendBackpressureSignals: 0,
301
+ sendQueued: 0,
302
+ sendQueueFlushes: 0,
303
+ sendQueueOverflows: 0,
304
+ sendDrops: 0,
305
+ sendErrors: 0,
306
+ };
307
+ private eventBusUnsubscribes: Array<() => void> = [];
308
+ private isShutdown = false;
309
+
310
+ // Valid topic patterns
311
+ private readonly topicPatterns = {
312
+ sessionEvents: /^session:[a-zA-Z0-9-]+:events$/,
313
+ sessionPty: /^session:[a-zA-Z0-9-]+:pty$/,
314
+ sessions: /^sessions$/,
315
+ notifications: /^notifications$/,
316
+ };
317
+
318
+ constructor(eventBus: EventBusSubscriber, options: WebSocketManagerOptions = {}) {
319
+ this.enablePtyPatch = options.enablePtyPatch ?? WS_PTY_PATCH_ENABLED;
320
+ this.enablePtyBinary = options.enablePtyBinary ?? WS_PTY_BINARY_ENABLED;
321
+ this.enablePtyPaste = options.enablePtyPaste ?? WS_PTY_PASTE_ENABLED;
322
+ this.onPtyInput = options.onPtyInput;
323
+ this.onPtyKey = options.onPtyKey;
324
+ this.onPtyPaste = options.onPtyPaste;
325
+ // Listen to event bus for session events
326
+ this.eventBusUnsubscribes.push(
327
+ eventBus.on("session:event", (event) => {
328
+ this.handleProviderEvent(event as ProviderEvent);
329
+ })
330
+ );
331
+ this.eventBusUnsubscribes.push(
332
+ eventBus.on("notification:created", (event) => {
333
+ this.handleNotificationEvent("notification_created", event);
334
+ })
335
+ );
336
+ this.eventBusUnsubscribes.push(
337
+ eventBus.on("notification:read", (event) => {
338
+ this.handleNotificationEvent("notification_read", event);
339
+ })
340
+ );
341
+ this.eventBusUnsubscribes.push(
342
+ eventBus.on("notification:counts_updated", (event) => {
343
+ this.handleNotificationEvent("notification_counts_updated", event);
344
+ })
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Handle new WebSocket connection
350
+ */
351
+ handleConnection(ws: ServerWebSocket<unknown> | any): void {
352
+ if (this.isShutdown) {
353
+ ws.close();
354
+ return;
355
+ }
356
+
357
+ this.clients.set(ws, {
358
+ ws,
359
+ subscriptions: new Set(),
360
+ rateWindowStartedAt: Date.now(),
361
+ controlMessagesInWindow: 0,
362
+ ptyInputMessagesInWindow: 0,
363
+ ptyKeyMessagesInWindow: 0,
364
+ ptyPasteMessagesInWindow: 0,
365
+ consecutiveBackpressure: 0,
366
+ supportsPtyPatch: false,
367
+ supportsPtyBinary: false,
368
+ supportsPtyPaste: false,
369
+ backpressureActive: false,
370
+ pendingOutbound: [],
371
+ pendingOutboundBytes: 0,
372
+ pasteAssemblies: new Map(),
373
+ });
374
+ this.telemetry.totalConnectionsAccepted += 1;
375
+ }
376
+
377
+ /**
378
+ * Handle incoming WebSocket message
379
+ */
380
+ handleMessage(ws: ServerWebSocket<unknown> | any, message: WSMessage): void {
381
+ if (this.isShutdown) {
382
+ return;
383
+ }
384
+
385
+ const client = this.clients.get(ws);
386
+ if (!client) {
387
+ throw new Error("Client not found");
388
+ }
389
+
390
+ // Validate message structure
391
+ if (typeof message !== "object" || typeof message.op !== "string" || !message.op) {
392
+ throw new Error("Invalid message: missing 'op' field");
393
+ }
394
+
395
+ const op = message.op;
396
+ const rateClass = this.resolveInboundRateClass(op);
397
+ this.telemetry.inboundMessages += 1;
398
+ if (!this.consumeMessageQuota(client, rateClass)) {
399
+ this.telemetry.rateLimitedMessages += 1;
400
+ this.incrementRateLimitTelemetry(rateClass);
401
+ this.removeClient(ws, "rate_limit", 1008, "Rate limit exceeded");
402
+ throw new Error("WebSocket rate limit exceeded");
403
+ }
404
+
405
+ const topic = typeof message.topic === "string" ? message.topic : "";
406
+
407
+ switch (op) {
408
+ case "hello":
409
+ this.handleHello(ws, message);
410
+ break;
411
+
412
+ case "pty_input":
413
+ this.handlePtyInput(ws, message);
414
+ break;
415
+
416
+ case "pty_key":
417
+ this.handlePtyKey(ws, message);
418
+ break;
419
+
420
+ case "pty_paste":
421
+ this.handlePtyPaste(ws, message);
422
+ break;
423
+
424
+ case "subscribe": {
425
+ const sinceSeq = this.parseOptionalSinceSeq(message.sinceSeq);
426
+ this.handleSubscribe(ws, topic, sinceSeq);
427
+ break;
428
+ }
429
+
430
+ case "unsubscribe":
431
+ this.handleUnsubscribe(ws, topic);
432
+ break;
433
+
434
+ default:
435
+ throw new Error(`Unknown operation: ${op}`);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Handle client disconnect
441
+ */
442
+ handleDisconnect(ws: ServerWebSocket<unknown> | any): void {
443
+ this.removeClient(ws, "client_disconnect");
444
+ }
445
+
446
+ /**
447
+ * Flush queued outbound payloads when Bun notifies socket drain.
448
+ */
449
+ handleDrain(ws: ServerWebSocket<unknown> | any): void {
450
+ const client = this.clients.get(ws);
451
+ if (!client || client.ws.readyState !== 1) {
452
+ return;
453
+ }
454
+ this.flushPendingOutbound(client);
455
+ }
456
+
457
+ /**
458
+ * Broadcast PTY data to subscribers
459
+ */
460
+ broadcastPtyData(sessionId: string, output: string, cursor?: TerminalCursor): void {
461
+ if (this.isShutdown) {
462
+ return;
463
+ }
464
+
465
+ const topic = `session:${sessionId}:pty` as SessionPtyTopic;
466
+ const previousSeq = this.ptySequenceBySession.get(sessionId) ?? 0;
467
+ const previousFrame = this.ptyLastFrameBySession.get(sessionId);
468
+ const seq = this.nextPtySequence(sessionId);
469
+ this.telemetry.ptyFramesBroadcast += 1;
470
+ this.telemetry.ptyBytesBroadcast += Buffer.byteLength(output, "utf-8");
471
+ const frames = this.createPtyFrames(topic, output, seq, previousSeq, previousFrame, cursor);
472
+ this.ptyLastFrameBySession.set(sessionId, output);
473
+
474
+ this.appendToReplayBuffer(sessionId, frames.full);
475
+ this.broadcastPtyFrame(topic, frames.full, frames.patch);
476
+ }
477
+
478
+ /**
479
+ * Clear PTY sequence tracking for a completed session.
480
+ */
481
+ clearPtySequence(sessionId: string): void {
482
+ this.ptySequenceBySession.delete(sessionId);
483
+ this.ptyReplayBufferBySession.delete(sessionId);
484
+ this.ptyLastFrameBySession.delete(sessionId);
485
+ }
486
+
487
+ /**
488
+ * Broadcast session state change to subscribers
489
+ */
490
+ broadcastSessionChange(message: Record<string, unknown>): void {
491
+ if (this.isShutdown) {
492
+ return;
493
+ }
494
+
495
+ const topic: SessionsTopic = "sessions";
496
+ const wsMessage: SessionsMessage = {
497
+ topic,
498
+ ...message,
499
+ };
500
+
501
+ this.broadcastToTopic(topic, wsMessage);
502
+ }
503
+
504
+ /**
505
+ * Broadcast an event to session event subscribers
506
+ */
507
+ broadcastSessionEvent(sessionId: string, event: Record<string, unknown>): void {
508
+ if (this.isShutdown) return;
509
+
510
+ const topic = `session:${sessionId}:events` as SessionEventsTopic;
511
+ const message: SessionEventsMessage = { topic, data: event };
512
+ this.broadcastToTopic(topic, message);
513
+ }
514
+
515
+ /**
516
+ * Get connection count (for testing)
517
+ */
518
+ getConnectionCount(): number {
519
+ return this.clients.size;
520
+ }
521
+
522
+ /**
523
+ * Get subscriptions for a client (for testing)
524
+ */
525
+ getSubscriptions(ws: ServerWebSocket<unknown> | any): string[] {
526
+ const client = this.clients.get(ws);
527
+ return client ? Array.from(client.subscriptions) : [];
528
+ }
529
+
530
+ /**
531
+ * Get a transport telemetry snapshot for diagnostics and tests.
532
+ */
533
+ getTelemetrySnapshot(now: number = Date.now()): WsTransportTelemetrySnapshot {
534
+ return {
535
+ startedAt: this.telemetryStartedAt,
536
+ uptimeMs: Math.max(0, now - this.telemetryStartedAt),
537
+ activeConnections: this.clients.size,
538
+ totalConnectionsAccepted: this.telemetry.totalConnectionsAccepted,
539
+ totalConnectionsDisconnected: this.telemetry.totalConnectionsDisconnected,
540
+ disconnectReasons: {
541
+ ...this.telemetry.disconnectReasons,
542
+ },
543
+ inboundMessages: this.telemetry.inboundMessages,
544
+ rateLimitedMessages: this.telemetry.rateLimitedMessages,
545
+ rateLimitedControlMessages: this.telemetry.rateLimitedControlMessages,
546
+ rateLimitedPtyInputMessages: this.telemetry.rateLimitedPtyInputMessages,
547
+ rateLimitedPtyKeyMessages: this.telemetry.rateLimitedPtyKeyMessages,
548
+ rateLimitedPtyPasteMessages: this.telemetry.rateLimitedPtyPasteMessages,
549
+ subscribeOps: this.telemetry.subscribeOps,
550
+ unsubscribeOps: this.telemetry.unsubscribeOps,
551
+ ptyInputOps: this.telemetry.ptyInputOps,
552
+ ptyKeyOps: this.telemetry.ptyKeyOps,
553
+ ptyPasteOps: this.telemetry.ptyPasteOps,
554
+ ptyInputDispatchErrors: this.telemetry.ptyInputDispatchErrors,
555
+ helloOps: this.telemetry.helloOps,
556
+ helloAckSent: this.telemetry.helloAckSent,
557
+ ptyFramesBroadcast: this.telemetry.ptyFramesBroadcast,
558
+ ptyPatchFramesBroadcast: this.telemetry.ptyPatchFramesBroadcast,
559
+ ptyBinaryFramesBroadcast: this.telemetry.ptyBinaryFramesBroadcast,
560
+ ptyBytesBroadcast: this.telemetry.ptyBytesBroadcast,
561
+ replayRequests: this.telemetry.replayRequests,
562
+ replayFramesSent: this.telemetry.replayFramesSent,
563
+ replayMisses: this.telemetry.replayMisses,
564
+ sendSuccess: this.telemetry.sendSuccess,
565
+ sendBackpressureSignals: this.telemetry.sendBackpressureSignals,
566
+ sendQueued: this.telemetry.sendQueued,
567
+ sendQueueFlushes: this.telemetry.sendQueueFlushes,
568
+ sendQueueOverflows: this.telemetry.sendQueueOverflows,
569
+ sendDrops: this.telemetry.sendDrops,
570
+ sendErrors: this.telemetry.sendErrors,
571
+ };
572
+ }
573
+
574
+ /**
575
+ * Shutdown and clean up
576
+ */
577
+ shutdown(): void {
578
+ this.isShutdown = true;
579
+ this.ptySequenceBySession.clear();
580
+ this.ptyReplayBufferBySession.clear();
581
+ this.ptyLastFrameBySession.clear();
582
+
583
+ // Unsubscribe from event bus
584
+ for (const unsubscribe of this.eventBusUnsubscribes) {
585
+ unsubscribe();
586
+ }
587
+ this.eventBusUnsubscribes = [];
588
+
589
+ // Close all connections
590
+ for (const ws of this.clients.keys()) {
591
+ this.removeClient(ws, "shutdown");
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Handle subscribe operation
597
+ */
598
+ private handleHello(ws: ServerWebSocket<unknown> | any, message: WSMessage): void {
599
+ const client = this.clients.get(ws);
600
+ if (!client) {
601
+ throw new Error("Client not found");
602
+ }
603
+
604
+ const version = this.parseOptionalProtocolVersion(message.version);
605
+ if (version !== undefined && version !== WS_PROTOCOL_VERSION) {
606
+ throw new Error(`Unsupported protocol version: ${version} (server=${WS_PROTOCOL_VERSION})`);
607
+ }
608
+ client.supportsPtyPatch =
609
+ this.enablePtyPatch && this.parseOptionalPtyPatchSupport(message.supports);
610
+ client.supportsPtyBinary =
611
+ this.enablePtyBinary && this.parseOptionalPtyBinarySupport(message.supports);
612
+ client.supportsPtyPaste =
613
+ this.enablePtyPaste && this.parseOptionalPtyPasteSupport(message.supports);
614
+ this.telemetry.helloOps += 1;
615
+
616
+ const ack: HelloAckMessage = {
617
+ op: "hello_ack",
618
+ version: WS_PROTOCOL_VERSION,
619
+ features: {
620
+ ptyReplay: true,
621
+ ptyPatch: this.enablePtyPatch,
622
+ ptyBinary: this.enablePtyBinary,
623
+ ptyPaste: this.enablePtyPaste,
624
+ },
625
+ negotiated: {
626
+ ptyPatch: client.supportsPtyPatch,
627
+ ptyBinary: client.supportsPtyBinary,
628
+ ptyPaste: client.supportsPtyPaste,
629
+ },
630
+ };
631
+
632
+ const ok = this.sendToClient(client, JSON.stringify(ack));
633
+ if (!ok) {
634
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
635
+ return;
636
+ }
637
+ this.telemetry.helloAckSent += 1;
638
+ }
639
+
640
+ /**
641
+ * Handle live PTY text input operation.
642
+ */
643
+ private handlePtyInput(ws: ServerWebSocket<unknown> | any, message: WSMessage): void {
644
+ if (!this.onPtyInput) {
645
+ throw new Error("PTY input operation is not enabled");
646
+ }
647
+
648
+ const client = this.clients.get(ws);
649
+ if (!client) {
650
+ throw new Error("Client not found");
651
+ }
652
+
653
+ const sessionId = this.parseRequiredSessionId(message.sessionId);
654
+ const data = this.parseRequiredInputData(message.data);
655
+ const requestId = this.parseOptionalInputRequestId(message.requestId);
656
+ this.telemetry.ptyInputOps += 1;
657
+
658
+ this.dispatchInputOperation(client, "pty_input", sessionId, requestId, async () => {
659
+ await this.onPtyInput?.(sessionId, data);
660
+ });
661
+ }
662
+
663
+ /**
664
+ * Handle live PTY key input operation.
665
+ */
666
+ private handlePtyKey(ws: ServerWebSocket<unknown> | any, message: WSMessage): void {
667
+ if (!this.onPtyKey) {
668
+ throw new Error("PTY key operation is not enabled");
669
+ }
670
+
671
+ const client = this.clients.get(ws);
672
+ if (!client) {
673
+ throw new Error("Client not found");
674
+ }
675
+
676
+ const sessionId = this.parseRequiredSessionId(message.sessionId);
677
+ const key = this.parseRequiredInputKey(message.key);
678
+ const requestId = this.parseOptionalInputRequestId(message.requestId);
679
+ this.telemetry.ptyKeyOps += 1;
680
+
681
+ this.dispatchInputOperation(client, "pty_key", sessionId, requestId, async () => {
682
+ await this.onPtyKey?.(sessionId, key);
683
+ });
684
+ }
685
+
686
+ /**
687
+ * Handle chunked paste operation.
688
+ *
689
+ * Chunks are assembled server-side and dispatched atomically once all chunks
690
+ * arrive in strict order, preventing partial writes on transport interruption.
691
+ */
692
+ private handlePtyPaste(ws: ServerWebSocket<unknown> | any, message: WSMessage): void {
693
+ if (!this.enablePtyPaste) {
694
+ throw new Error("PTY paste operation is disabled");
695
+ }
696
+ if (!(this.onPtyPaste || this.onPtyInput)) {
697
+ throw new Error("PTY paste operation is not enabled");
698
+ }
699
+
700
+ const client = this.clients.get(ws);
701
+ if (!client) {
702
+ throw new Error("Client not found");
703
+ }
704
+
705
+ this.purgeExpiredPasteAssemblies(client);
706
+
707
+ const sessionId = this.parseRequiredSessionId(message.sessionId);
708
+ const chunkData = this.parseRequiredInputData(message.data);
709
+ const chunkIndex = this.parseRequiredPasteChunkIndex(message.chunkIndex);
710
+ const chunkCount = this.parseRequiredPasteChunkCount(message.chunkCount);
711
+ const requestId = this.parseOptionalInputRequestId(message.requestId);
712
+
713
+ if (!requestId) {
714
+ throw new Error("Invalid message: 'requestId' is required for pty_paste");
715
+ }
716
+
717
+ const chunkBytes = Buffer.byteLength(chunkData, "utf-8");
718
+ if (chunkBytes > MAX_WS_MESSAGE_BYTES) {
719
+ throw new Error("Invalid message: 'data' exceeds max chunk size");
720
+ }
721
+
722
+ this.telemetry.ptyPasteOps += 1;
723
+ const now = Date.now();
724
+ let assembly = client.pasteAssemblies.get(requestId);
725
+
726
+ if (!assembly) {
727
+ if (client.pasteAssemblies.size >= WS_MAX_ACTIVE_PASTE_ASSEMBLIES_PER_CLIENT) {
728
+ throw new Error("Too many concurrent paste operations");
729
+ }
730
+ if (chunkIndex !== 0) {
731
+ throw new Error("Invalid message: pty_paste chunk stream must start at index 0");
732
+ }
733
+
734
+ assembly = {
735
+ sessionId,
736
+ chunkCount,
737
+ nextChunkIndex: 0,
738
+ chunks: new Array(chunkCount),
739
+ totalBytes: 0,
740
+ startedAt: now,
741
+ };
742
+ client.pasteAssemblies.set(requestId, assembly);
743
+ } else {
744
+ if (assembly.sessionId !== sessionId) {
745
+ client.pasteAssemblies.delete(requestId);
746
+ throw new Error("Invalid message: pty_paste requestId/sessionId mismatch");
747
+ }
748
+ if (assembly.chunkCount !== chunkCount) {
749
+ client.pasteAssemblies.delete(requestId);
750
+ throw new Error("Invalid message: pty_paste chunkCount mismatch");
751
+ }
752
+ if (now - assembly.startedAt > WS_PTY_PASTE_ASSEMBLY_TTL_MS) {
753
+ client.pasteAssemblies.delete(requestId);
754
+ throw new Error("Invalid message: pty_paste assembly timed out");
755
+ }
756
+ }
757
+
758
+ if (chunkIndex !== assembly.nextChunkIndex) {
759
+ client.pasteAssemblies.delete(requestId);
760
+ throw new Error("Invalid message: pty_paste chunks must arrive in order");
761
+ }
762
+
763
+ assembly.chunks[chunkIndex] = chunkData;
764
+ assembly.nextChunkIndex += 1;
765
+ assembly.totalBytes += chunkBytes;
766
+
767
+ if (assembly.totalBytes > WS_MAX_PTY_PASTE_BYTES) {
768
+ client.pasteAssemblies.delete(requestId);
769
+ throw new Error(`Invalid message: pty_paste exceeds ${WS_MAX_PTY_PASTE_BYTES} bytes`);
770
+ }
771
+
772
+ if (assembly.nextChunkIndex < assembly.chunkCount) {
773
+ return;
774
+ }
775
+
776
+ const fullPaste = assembly.chunks.join("");
777
+ client.pasteAssemblies.delete(requestId);
778
+
779
+ this.dispatchInputOperation(client, "pty_paste", sessionId, requestId, async () => {
780
+ if (this.onPtyPaste) {
781
+ await this.onPtyPaste(sessionId, fullPaste);
782
+ return;
783
+ }
784
+ await this.onPtyInput?.(sessionId, fullPaste);
785
+ });
786
+ }
787
+
788
+ /**
789
+ * Handle subscribe operation
790
+ */
791
+ private handleSubscribe(
792
+ ws: ServerWebSocket<unknown> | any,
793
+ topic: string,
794
+ sinceSeq?: number
795
+ ): void {
796
+ if (!topic) {
797
+ throw new Error("Invalid message: missing 'topic' field");
798
+ }
799
+
800
+ // Validate topic format
801
+ this.validateTopic(topic);
802
+ const normalizedTopic = topic as WSTopic;
803
+
804
+ const client = this.clients.get(ws);
805
+ if (!client) {
806
+ throw new Error("Client not found");
807
+ }
808
+
809
+ if (
810
+ !client.subscriptions.has(normalizedTopic) &&
811
+ client.subscriptions.size >= WS_MAX_SUBSCRIPTIONS
812
+ ) {
813
+ throw new Error(`Too many subscriptions (max ${WS_MAX_SUBSCRIPTIONS})`);
814
+ }
815
+
816
+ client.subscriptions.add(normalizedTopic);
817
+ this.telemetry.subscribeOps += 1;
818
+
819
+ // Optional replay for PTY topic subscriptions.
820
+ if (sinceSeq !== undefined && this.topicPatterns.sessionPty.test(normalizedTopic)) {
821
+ this.telemetry.replayRequests += 1;
822
+ this.telemetry.replayFramesSent += this.replayPtySince(
823
+ client,
824
+ normalizedTopic as SessionPtyTopic,
825
+ sinceSeq
826
+ );
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Handle unsubscribe operation
832
+ */
833
+ private handleUnsubscribe(ws: ServerWebSocket<unknown> | any, topic: string): void {
834
+ if (!topic) {
835
+ throw new Error("Invalid message: missing 'topic' field");
836
+ }
837
+ this.validateTopic(topic);
838
+ const normalizedTopic = topic as WSTopic;
839
+
840
+ const client = this.clients.get(ws);
841
+ if (!client) {
842
+ return; // Silently ignore if client not found
843
+ }
844
+
845
+ client.subscriptions.delete(normalizedTopic);
846
+ this.telemetry.unsubscribeOps += 1;
847
+ }
848
+
849
+ /**
850
+ * Validate topic format
851
+ */
852
+ private validateTopic(topic: string): void {
853
+ const isValid = Object.values(this.topicPatterns).some((pattern) => pattern.test(topic));
854
+
855
+ if (!isValid) {
856
+ throw new Error(`Invalid topic format: ${topic}`);
857
+ }
858
+ }
859
+
860
+ private resolveInboundRateClass(op: string): InboundRateClass {
861
+ switch (op) {
862
+ case "pty_input":
863
+ return "pty_input";
864
+ case "pty_key":
865
+ return "pty_key";
866
+ case "pty_paste":
867
+ return "pty_paste";
868
+ default:
869
+ return "control";
870
+ }
871
+ }
872
+
873
+ private incrementRateLimitTelemetry(rateClass: InboundRateClass): void {
874
+ switch (rateClass) {
875
+ case "pty_input":
876
+ this.telemetry.rateLimitedPtyInputMessages += 1;
877
+ break;
878
+ case "pty_key":
879
+ this.telemetry.rateLimitedPtyKeyMessages += 1;
880
+ break;
881
+ case "pty_paste":
882
+ this.telemetry.rateLimitedPtyPasteMessages += 1;
883
+ break;
884
+ default:
885
+ this.telemetry.rateLimitedControlMessages += 1;
886
+ break;
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Sliding fixed-window quotas for inbound messages per client.
892
+ */
893
+ private consumeMessageQuota(client: ClientState, rateClass: InboundRateClass): boolean {
894
+ const now = Date.now();
895
+ if (now - client.rateWindowStartedAt >= WS_RATE_WINDOW_MS) {
896
+ client.rateWindowStartedAt = now;
897
+ client.controlMessagesInWindow = 0;
898
+ client.ptyInputMessagesInWindow = 0;
899
+ client.ptyKeyMessagesInWindow = 0;
900
+ client.ptyPasteMessagesInWindow = 0;
901
+ }
902
+
903
+ switch (rateClass) {
904
+ case "pty_input":
905
+ client.ptyInputMessagesInWindow += 1;
906
+ return client.ptyInputMessagesInWindow <= WS_MAX_PTY_INPUT_MESSAGES_PER_WINDOW;
907
+ case "pty_key":
908
+ client.ptyKeyMessagesInWindow += 1;
909
+ return client.ptyKeyMessagesInWindow <= WS_MAX_PTY_KEY_MESSAGES_PER_WINDOW;
910
+ case "pty_paste":
911
+ client.ptyPasteMessagesInWindow += 1;
912
+ return client.ptyPasteMessagesInWindow <= WS_MAX_PTY_PASTE_MESSAGES_PER_WINDOW;
913
+ default:
914
+ client.controlMessagesInWindow += 1;
915
+ return client.controlMessagesInWindow <= WS_MAX_CONTROL_MESSAGES_PER_WINDOW;
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Handle provider event from event bus
921
+ */
922
+ private handleProviderEvent(event: ProviderEvent): void {
923
+ if (this.isShutdown) {
924
+ return;
925
+ }
926
+
927
+ const topic = `session:${event.sessionId}:events` as SessionEventsTopic;
928
+ const message: SessionEventsMessage = {
929
+ topic,
930
+ data: event,
931
+ };
932
+
933
+ this.broadcastToTopic(topic, message);
934
+ }
935
+
936
+ /**
937
+ * Handle notification-domain events from event bus.
938
+ */
939
+ private handleNotificationEvent(type: NotificationsMessage["type"], event: unknown): void {
940
+ if (this.isShutdown) {
941
+ return;
942
+ }
943
+
944
+ const topic: NotificationsTopic = "notifications";
945
+ const message: NotificationsMessage = {
946
+ topic,
947
+ type,
948
+ data:
949
+ event && typeof event === "object" && !Array.isArray(event)
950
+ ? (event as Record<string, unknown>)
951
+ : {},
952
+ };
953
+
954
+ this.broadcastToTopic(topic, message);
955
+ }
956
+
957
+ private broadcastPtyFrame(
958
+ topic: SessionPtyTopic,
959
+ fullFrame: PtyOutputMessage,
960
+ patchFrame?: PtyPatchMessage
961
+ ): void {
962
+ const fullMessageStr = JSON.stringify(fullFrame);
963
+ const patchMessageStr = patchFrame ? JSON.stringify(patchFrame) : null;
964
+ const fullMessageBinary = this.enablePtyBinary ? encodePtyBinaryFrame(fullFrame) : null;
965
+ const patchMessageBinary =
966
+ this.enablePtyBinary && patchFrame ? encodePtyBinaryFrame(patchFrame) : null;
967
+
968
+ for (const client of this.clients.values()) {
969
+ if (!client.subscriptions.has(topic)) {
970
+ continue;
971
+ }
972
+ if (client.ws.readyState !== 1) {
973
+ continue;
974
+ }
975
+
976
+ const shouldUsePatch = Boolean(
977
+ patchFrame && patchMessageStr && this.enablePtyPatch && client.supportsPtyPatch
978
+ );
979
+ const shouldUseBinary = Boolean(this.enablePtyBinary && client.supportsPtyBinary);
980
+ const outboundPayload = shouldUseBinary
981
+ ? shouldUsePatch
982
+ ? (patchMessageBinary ?? (patchMessageStr as string))
983
+ : (fullMessageBinary ?? fullMessageStr)
984
+ : shouldUsePatch
985
+ ? (patchMessageStr as string)
986
+ : fullMessageStr;
987
+ const ok = this.sendToClient(client, outboundPayload);
988
+ if (shouldUsePatch) {
989
+ this.telemetry.ptyPatchFramesBroadcast += 1;
990
+ }
991
+ if (shouldUseBinary && outboundPayload instanceof Uint8Array) {
992
+ this.telemetry.ptyBinaryFramesBroadcast += 1;
993
+ }
994
+
995
+ if (!ok) {
996
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
997
+ }
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Broadcast message to all clients subscribed to a topic
1003
+ */
1004
+ private broadcastToTopic(topic: WSTopic, message: OutboundWSMessage): void {
1005
+ const messageStr = JSON.stringify(message);
1006
+
1007
+ for (const client of this.clients.values()) {
1008
+ // Check if client is subscribed to this topic
1009
+ if (!client.subscriptions.has(topic)) {
1010
+ continue;
1011
+ }
1012
+
1013
+ // Skip closed connections
1014
+ if (client.ws.readyState !== 1) {
1015
+ // Not OPEN
1016
+ continue;
1017
+ }
1018
+
1019
+ if (!this.sendToClient(client, messageStr)) {
1020
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ private parseOptionalSinceSeq(value: unknown): number | undefined {
1026
+ if (value === undefined) {
1027
+ return undefined;
1028
+ }
1029
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1030
+ throw new Error("Invalid message: 'sinceSeq' must be a non-negative integer");
1031
+ }
1032
+ return value;
1033
+ }
1034
+
1035
+ private parseOptionalProtocolVersion(value: unknown): number | undefined {
1036
+ if (value === undefined) {
1037
+ return undefined;
1038
+ }
1039
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
1040
+ throw new Error("Invalid message: 'version' must be a positive integer");
1041
+ }
1042
+ return value;
1043
+ }
1044
+
1045
+ private parseRequiredSessionId(value: unknown): string {
1046
+ if (typeof value !== "string" || !value) {
1047
+ throw new Error("Invalid message: 'sessionId' must be a non-empty string");
1048
+ }
1049
+ if (!/^[a-zA-Z0-9-]+$/.test(value)) {
1050
+ throw new Error("Invalid message: 'sessionId' format is invalid");
1051
+ }
1052
+ return value;
1053
+ }
1054
+
1055
+ private parseRequiredInputData(value: unknown): string {
1056
+ if (typeof value !== "string") {
1057
+ throw new Error("Invalid message: 'data' must be a string");
1058
+ }
1059
+ if (!value) {
1060
+ throw new Error("Invalid message: 'data' must not be empty");
1061
+ }
1062
+ return value;
1063
+ }
1064
+
1065
+ private parseRequiredInputKey(value: unknown): string {
1066
+ if (typeof value !== "string") {
1067
+ throw new Error("Invalid message: 'key' must be a string");
1068
+ }
1069
+ if (!value) {
1070
+ throw new Error("Invalid message: 'key' must not be empty");
1071
+ }
1072
+ if (value.length > 128) {
1073
+ throw new Error("Invalid message: 'key' exceeds max length");
1074
+ }
1075
+ return value;
1076
+ }
1077
+
1078
+ private parseRequiredPasteChunkIndex(value: unknown): number {
1079
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1080
+ throw new Error("Invalid message: 'chunkIndex' must be a non-negative integer");
1081
+ }
1082
+ return value;
1083
+ }
1084
+
1085
+ private parseRequiredPasteChunkCount(value: unknown): number {
1086
+ if (
1087
+ typeof value !== "number" ||
1088
+ !Number.isInteger(value) ||
1089
+ value <= 0 ||
1090
+ value > WS_MAX_PTY_PASTE_CHUNKS
1091
+ ) {
1092
+ throw new Error(
1093
+ `Invalid message: 'chunkCount' must be an integer between 1 and ${WS_MAX_PTY_PASTE_CHUNKS}`
1094
+ );
1095
+ }
1096
+ return value;
1097
+ }
1098
+
1099
+ private parseOptionalPtyPatchSupport(value: unknown): boolean {
1100
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1101
+ return false;
1102
+ }
1103
+ const candidate = value as Record<string, unknown>;
1104
+ return candidate.ptyPatch === true;
1105
+ }
1106
+
1107
+ private parseOptionalPtyBinarySupport(value: unknown): boolean {
1108
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1109
+ return false;
1110
+ }
1111
+ const candidate = value as Record<string, unknown>;
1112
+ return candidate.ptyBinary === true;
1113
+ }
1114
+
1115
+ private parseOptionalPtyPasteSupport(value: unknown): boolean {
1116
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1117
+ return false;
1118
+ }
1119
+ const candidate = value as Record<string, unknown>;
1120
+ return candidate.ptyPaste === true;
1121
+ }
1122
+
1123
+ private parseOptionalInputRequestId(value: unknown): string | undefined {
1124
+ if (value === undefined) {
1125
+ return undefined;
1126
+ }
1127
+ if (typeof value !== "string") {
1128
+ throw new Error("Invalid message: 'requestId' must be a string");
1129
+ }
1130
+ if (!value) {
1131
+ throw new Error("Invalid message: 'requestId' must not be empty");
1132
+ }
1133
+ if (value.length > 128) {
1134
+ throw new Error("Invalid message: 'requestId' exceeds max length");
1135
+ }
1136
+ if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
1137
+ throw new Error("Invalid message: 'requestId' format is invalid");
1138
+ }
1139
+ return value;
1140
+ }
1141
+
1142
+ private parsePolicyAction(value: unknown): PolicyAction | undefined {
1143
+ if (value === "allow" || value === "deny" || value === "ask") {
1144
+ return value;
1145
+ }
1146
+ return undefined;
1147
+ }
1148
+
1149
+ private dispatchInputOperation(
1150
+ client: ClientState,
1151
+ op: InputOperation,
1152
+ sessionId: string,
1153
+ requestId: string | undefined,
1154
+ dispatch: () => Promise<void>
1155
+ ): void {
1156
+ void Promise.resolve()
1157
+ .then(dispatch)
1158
+ .then(() => {
1159
+ if (!requestId || client.ws.readyState !== 1) {
1160
+ return;
1161
+ }
1162
+
1163
+ const payload = JSON.stringify({
1164
+ op: `${op}_ack`,
1165
+ sessionId,
1166
+ requestId,
1167
+ });
1168
+ if (!this.sendToClient(client, payload)) {
1169
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
1170
+ }
1171
+ })
1172
+ .catch((error) => {
1173
+ this.telemetry.ptyInputDispatchErrors += 1;
1174
+ const errorMsg = error instanceof Error ? error.message : "Unknown dispatch error";
1175
+ console.warn(`[ws] ${op} dispatch failed for session ${sessionId}:`, errorMsg);
1176
+
1177
+ if (client.ws.readyState !== 1) {
1178
+ return;
1179
+ }
1180
+
1181
+ const fallbackErrorMessage =
1182
+ op === "pty_key"
1183
+ ? "Failed to deliver PTY key input"
1184
+ : op === "pty_paste"
1185
+ ? "Failed to deliver PTY paste input"
1186
+ : "Failed to deliver PTY input";
1187
+ const errorRecord =
1188
+ typeof error === "object" && error !== null
1189
+ ? (error as Record<string, unknown>)
1190
+ : undefined;
1191
+ const errorCode = typeof errorRecord?.code === "string" ? errorRecord.code : undefined;
1192
+ const payloadObject: Record<string, unknown> = {
1193
+ op: `${op}_error`,
1194
+ sessionId,
1195
+ ...(requestId ? { requestId } : {}),
1196
+ error: errorCode === "policy_blocked" ? errorMsg : fallbackErrorMessage,
1197
+ };
1198
+
1199
+ if (errorCode === "policy_blocked") {
1200
+ payloadObject.code = errorCode;
1201
+ if (typeof errorRecord?.status === "number" && Number.isFinite(errorRecord.status)) {
1202
+ payloadObject.status = Math.trunc(errorRecord.status);
1203
+ }
1204
+ const policyAction = this.parsePolicyAction(errorRecord?.policyAction);
1205
+ if (policyAction) {
1206
+ payloadObject.policyAction = policyAction;
1207
+ }
1208
+ if (typeof errorRecord?.provider === "string") {
1209
+ payloadObject.provider = errorRecord.provider;
1210
+ }
1211
+ }
1212
+
1213
+ const payload = JSON.stringify(payloadObject);
1214
+ if (!this.sendToClient(client, payload)) {
1215
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
1216
+ }
1217
+ });
1218
+ }
1219
+
1220
+ private purgeExpiredPasteAssemblies(client: ClientState): void {
1221
+ if (client.pasteAssemblies.size === 0) {
1222
+ return;
1223
+ }
1224
+ const now = Date.now();
1225
+ for (const [requestId, assembly] of client.pasteAssemblies.entries()) {
1226
+ if (now - assembly.startedAt > WS_PTY_PASTE_ASSEMBLY_TTL_MS) {
1227
+ client.pasteAssemblies.delete(requestId);
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ private appendToReplayBuffer(sessionId: string, message: PtyOutputMessage): void {
1233
+ const buffer = this.ptyReplayBufferBySession.get(sessionId) ?? [];
1234
+ buffer.push(message);
1235
+ if (buffer.length > WS_PTY_REPLAY_BUFFER_SIZE) {
1236
+ buffer.shift();
1237
+ }
1238
+ this.ptyReplayBufferBySession.set(sessionId, buffer);
1239
+ }
1240
+
1241
+ private replayPtySince(client: ClientState, topic: SessionPtyTopic, sinceSeq: number): number {
1242
+ const sessionIdMatch = topic.match(/^session:([a-zA-Z0-9-]+):pty$/);
1243
+ const sessionId = sessionIdMatch?.[1];
1244
+ if (!sessionId) {
1245
+ this.telemetry.replayMisses += 1;
1246
+ return 0;
1247
+ }
1248
+
1249
+ const buffer = this.ptyReplayBufferBySession.get(sessionId);
1250
+ if (!buffer || buffer.length === 0) {
1251
+ this.telemetry.replayMisses += 1;
1252
+ return 0;
1253
+ }
1254
+
1255
+ const replayMessages = buffer.filter((msg) => msg.seq > sinceSeq);
1256
+ if (replayMessages.length === 0) {
1257
+ this.telemetry.replayMisses += 1;
1258
+ return 0;
1259
+ }
1260
+
1261
+ let replayedCount = 0;
1262
+ for (const message of replayMessages) {
1263
+ if (client.ws.readyState !== 1) {
1264
+ break;
1265
+ }
1266
+
1267
+ const ok = this.sendToClient(client, JSON.stringify(message));
1268
+ if (!ok) {
1269
+ this.removeClient(client.ws, client.pendingDisconnectReason ?? "send_error");
1270
+ break;
1271
+ }
1272
+ replayedCount += 1;
1273
+ }
1274
+ return replayedCount;
1275
+ }
1276
+
1277
+ private createPtyFrames(
1278
+ topic: SessionPtyTopic,
1279
+ output: string,
1280
+ seq: number,
1281
+ previousSeq: number,
1282
+ previousFrame?: string,
1283
+ cursor?: TerminalCursor
1284
+ ): { full: PtyOutputMessage; patch?: PtyPatchMessage } {
1285
+ const full: PtyOutputMessage = {
1286
+ topic,
1287
+ type: "pty_output",
1288
+ data: output,
1289
+ seq,
1290
+ ...(cursor ? { cursor } : {}),
1291
+ };
1292
+
1293
+ if (!this.enablePtyPatch || previousFrame === undefined || previousSeq <= 0) {
1294
+ return { full };
1295
+ }
1296
+
1297
+ const patchRange = this.computePatch(previousFrame, output);
1298
+ if (!patchRange) {
1299
+ return { full };
1300
+ }
1301
+
1302
+ const patch: PtyPatchMessage = {
1303
+ topic,
1304
+ type: "pty_patch",
1305
+ baseSeq: previousSeq,
1306
+ seq,
1307
+ start: patchRange.start,
1308
+ deleteCount: patchRange.deleteCount,
1309
+ data: patchRange.data,
1310
+ ...(cursor ? { cursor } : {}),
1311
+ };
1312
+
1313
+ // Only emit patch when it is actually smaller than full-frame payload.
1314
+ if (JSON.stringify(patch).length >= JSON.stringify(full).length) {
1315
+ return { full };
1316
+ }
1317
+ return { full, patch };
1318
+ }
1319
+
1320
+ private computePatch(
1321
+ previous: string,
1322
+ current: string
1323
+ ): { start: number; deleteCount: number; data: string } | null {
1324
+ if (previous === current) {
1325
+ return null;
1326
+ }
1327
+
1328
+ let start = 0;
1329
+ const minLength = Math.min(previous.length, current.length);
1330
+ while (start < minLength && previous.charCodeAt(start) === current.charCodeAt(start)) {
1331
+ start += 1;
1332
+ }
1333
+
1334
+ let previousEnd = previous.length - 1;
1335
+ let currentEnd = current.length - 1;
1336
+ while (
1337
+ previousEnd >= start &&
1338
+ currentEnd >= start &&
1339
+ previous.charCodeAt(previousEnd) === current.charCodeAt(currentEnd)
1340
+ ) {
1341
+ previousEnd -= 1;
1342
+ currentEnd -= 1;
1343
+ }
1344
+
1345
+ return {
1346
+ start,
1347
+ deleteCount: Math.max(0, previousEnd - start + 1),
1348
+ data: current.slice(start, currentEnd + 1),
1349
+ };
1350
+ }
1351
+
1352
+ private sendToClient(client: ClientState, message: string | Uint8Array): boolean {
1353
+ if (client.backpressureActive || client.pendingOutbound.length > 0) {
1354
+ return this.enqueuePendingOutbound(client, message);
1355
+ }
1356
+
1357
+ try {
1358
+ const status = client.ws.send(message);
1359
+ if (typeof status === "number" && status > 0) {
1360
+ client.consecutiveBackpressure = 0;
1361
+ client.backpressureActive = false;
1362
+ client.pendingDisconnectReason = undefined;
1363
+ this.telemetry.sendSuccess += 1;
1364
+ return true;
1365
+ }
1366
+
1367
+ // 0 means dropped, -1 means backpressure.
1368
+ if (status === -1) {
1369
+ this.telemetry.sendBackpressureSignals += 1;
1370
+ client.backpressureActive = true;
1371
+ client.consecutiveBackpressure += 1;
1372
+ if (client.consecutiveBackpressure < WS_MAX_CONSECUTIVE_BACKPRESSURE) {
1373
+ return true;
1374
+ }
1375
+
1376
+ client.pendingDisconnectReason = "backpressure";
1377
+ } else {
1378
+ this.telemetry.sendDrops += 1;
1379
+ client.pendingDisconnectReason = "drop";
1380
+ }
1381
+
1382
+ // Disconnect persistently slow or dropped clients to avoid unbounded pressure.
1383
+ try {
1384
+ client.ws.close(1013, "Backpressure");
1385
+ } catch {
1386
+ // best-effort close
1387
+ }
1388
+ return false;
1389
+ } catch (error) {
1390
+ console.error(`Error sending to WebSocket:`, error);
1391
+ this.telemetry.sendErrors += 1;
1392
+ client.pendingDisconnectReason = "send_error";
1393
+ // Connection might be broken, clean it up
1394
+ return false;
1395
+ }
1396
+ }
1397
+
1398
+ private flushPendingOutbound(client: ClientState): void {
1399
+ // Bun emitted "drain". If there is no queued payload, treat this as
1400
+ // full recovery and reset the backpressure streak.
1401
+ if (client.pendingOutbound.length === 0) {
1402
+ client.consecutiveBackpressure = 0;
1403
+ }
1404
+ client.backpressureActive = false;
1405
+ client.pendingDisconnectReason = undefined;
1406
+
1407
+ while (client.pendingOutbound.length > 0) {
1408
+ if (client.ws.readyState !== 1) {
1409
+ return;
1410
+ }
1411
+ const next = client.pendingOutbound.shift();
1412
+ if (!next) {
1413
+ break;
1414
+ }
1415
+ client.pendingOutboundBytes = Math.max(0, client.pendingOutboundBytes - next.bytes);
1416
+
1417
+ try {
1418
+ const status = client.ws.send(next.payload);
1419
+ if (typeof status === "number" && status > 0) {
1420
+ client.consecutiveBackpressure = 0;
1421
+ this.telemetry.sendSuccess += 1;
1422
+ this.telemetry.sendQueueFlushes += 1;
1423
+ continue;
1424
+ }
1425
+
1426
+ if (status === -1) {
1427
+ this.telemetry.sendBackpressureSignals += 1;
1428
+ client.backpressureActive = true;
1429
+ client.consecutiveBackpressure += 1;
1430
+ if (client.consecutiveBackpressure >= WS_MAX_CONSECUTIVE_BACKPRESSURE) {
1431
+ client.pendingDisconnectReason = "backpressure";
1432
+ this.removeClient(client.ws, "backpressure", 1013, "Backpressure");
1433
+ }
1434
+ return;
1435
+ }
1436
+
1437
+ this.telemetry.sendDrops += 1;
1438
+ client.pendingDisconnectReason = "drop";
1439
+ this.removeClient(client.ws, "drop", 1013, "Backpressure");
1440
+ return;
1441
+ } catch (error) {
1442
+ console.error(`Error sending to WebSocket:`, error);
1443
+ this.telemetry.sendErrors += 1;
1444
+ client.pendingDisconnectReason = "send_error";
1445
+ this.removeClient(client.ws, "send_error");
1446
+ return;
1447
+ }
1448
+ }
1449
+ }
1450
+
1451
+ private enqueuePendingOutbound(client: ClientState, message: string | Uint8Array): boolean {
1452
+ const bytes =
1453
+ typeof message === "string" ? Buffer.byteLength(message, "utf-8") : message.byteLength;
1454
+ const nextMessageCount = client.pendingOutbound.length + 1;
1455
+ const nextByteSize = client.pendingOutboundBytes + bytes;
1456
+
1457
+ if (
1458
+ nextMessageCount > WS_MAX_PENDING_OUTBOUND_MESSAGES ||
1459
+ nextByteSize > WS_MAX_PENDING_OUTBOUND_BYTES
1460
+ ) {
1461
+ this.telemetry.sendQueueOverflows += 1;
1462
+ client.pendingDisconnectReason = "backpressure";
1463
+ try {
1464
+ client.ws.close(1013, "Backpressure");
1465
+ } catch {
1466
+ // best-effort close
1467
+ }
1468
+ return false;
1469
+ }
1470
+
1471
+ client.pendingOutbound.push({ payload: message, bytes });
1472
+ client.pendingOutboundBytes = nextByteSize;
1473
+ this.telemetry.sendQueued += 1;
1474
+ return true;
1475
+ }
1476
+
1477
+ private nextPtySequence(sessionId: string): number {
1478
+ const current = this.ptySequenceBySession.get(sessionId) ?? 0;
1479
+ const next = current >= Number.MAX_SAFE_INTEGER ? 1 : current + 1;
1480
+ this.ptySequenceBySession.set(sessionId, next);
1481
+ return next;
1482
+ }
1483
+
1484
+ private removeClient(
1485
+ ws: ServerWebSocket<unknown> | any,
1486
+ reason: DisconnectReason,
1487
+ closeCode?: number,
1488
+ closeReason?: string
1489
+ ): void {
1490
+ const client = this.clients.get(ws);
1491
+ if (!client) {
1492
+ return;
1493
+ }
1494
+
1495
+ this.clients.delete(ws);
1496
+ client.pendingOutbound = [];
1497
+ client.pendingOutboundBytes = 0;
1498
+ this.telemetry.totalConnectionsDisconnected += 1;
1499
+ this.telemetry.disconnectReasons[reason] += 1;
1500
+
1501
+ try {
1502
+ if (client.ws.readyState === 1) {
1503
+ // OPEN
1504
+ if (closeCode !== undefined || closeReason !== undefined) {
1505
+ client.ws.close(closeCode, closeReason);
1506
+ } else {
1507
+ client.ws.close();
1508
+ }
1509
+ }
1510
+ } catch {
1511
+ // best-effort close
1512
+ }
1513
+ }
1514
+ }
1515
+
1516
+ function isValidUint32(value: number): boolean {
1517
+ return Number.isInteger(value) && value >= 0 && value <= WS_PTY_BINARY_MAX_UINT32;
1518
+ }
1519
+
1520
+ function encodePtyBinaryFrame(frame: PtyFrameMessage): Uint8Array | null {
1521
+ if (!isValidUint32(frame.seq)) {
1522
+ return null;
1523
+ }
1524
+
1525
+ const topicBytes = WS_BINARY_TEXT_ENCODER.encode(frame.topic);
1526
+ const dataBytes = WS_BINARY_TEXT_ENCODER.encode(frame.data);
1527
+ if (
1528
+ topicBytes.length > WS_PTY_BINARY_MAX_TOPIC_BYTES ||
1529
+ dataBytes.length > WS_PTY_BINARY_MAX_UINT32
1530
+ ) {
1531
+ return null;
1532
+ }
1533
+
1534
+ if (
1535
+ frame.type === "pty_patch" &&
1536
+ !(
1537
+ isValidUint32(frame.baseSeq) &&
1538
+ isValidUint32(frame.start) &&
1539
+ isValidUint32(frame.deleteCount)
1540
+ )
1541
+ ) {
1542
+ return null;
1543
+ }
1544
+
1545
+ const payloadMetaBytes =
1546
+ frame.type === "pty_patch" ? WS_PTY_BINARY_PATCH_META_BYTES : WS_PTY_BINARY_OUTPUT_META_BYTES;
1547
+ const totalBytes =
1548
+ WS_PTY_BINARY_HEADER_BYTES + topicBytes.length + payloadMetaBytes + dataBytes.length;
1549
+ if (totalBytes > WS_PTY_BINARY_MAX_UINT32) {
1550
+ return null;
1551
+ }
1552
+
1553
+ const bytes = new Uint8Array(totalBytes);
1554
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1555
+
1556
+ view.setUint8(0, WS_PTY_BINARY_MAGIC);
1557
+ view.setUint8(1, WS_PTY_BINARY_VERSION);
1558
+ view.setUint8(
1559
+ 2,
1560
+ frame.type === "pty_patch" ? WS_PTY_BINARY_TYPE_PATCH : WS_PTY_BINARY_TYPE_OUTPUT
1561
+ );
1562
+ view.setUint8(3, 0);
1563
+ view.setUint16(4, topicBytes.length, true);
1564
+ view.setUint32(6, frame.seq, true);
1565
+
1566
+ let offset = WS_PTY_BINARY_HEADER_BYTES;
1567
+ bytes.set(topicBytes, offset);
1568
+ offset += topicBytes.length;
1569
+
1570
+ if (frame.type === "pty_patch") {
1571
+ view.setUint32(offset, frame.baseSeq, true);
1572
+ offset += 4;
1573
+ view.setUint32(offset, frame.start, true);
1574
+ offset += 4;
1575
+ view.setUint32(offset, frame.deleteCount, true);
1576
+ offset += 4;
1577
+ }
1578
+
1579
+ view.setUint32(offset, dataBytes.length, true);
1580
+ offset += 4;
1581
+ bytes.set(dataBytes, offset);
1582
+
1583
+ return bytes;
1584
+ }
1585
+
1586
+ interface EventBusSubscriber {
1587
+ on(event: string, handler: (event: unknown) => void): () => void;
1588
+ }