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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|