@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
appendAndPersist,
|
|
4
|
+
appendHistory,
|
|
5
|
+
persistSession,
|
|
6
|
+
serializeForStore,
|
|
7
|
+
MESSAGE_HISTORY_LIMIT,
|
|
8
|
+
} from "./ws-bridge-persist.js";
|
|
9
|
+
import type { Session } from "./ws-bridge-types.js";
|
|
10
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
11
|
+
import { SessionStateMachine } from "./session-state-machine.js";
|
|
12
|
+
import { SessionStore } from "./session-store.js";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
18
|
+
return {
|
|
19
|
+
id: "test-session",
|
|
20
|
+
backendType: "claude",
|
|
21
|
+
backendAdapter: null,
|
|
22
|
+
browserSockets: new Set(),
|
|
23
|
+
state: {
|
|
24
|
+
session_id: "test-session",
|
|
25
|
+
model: "claude-sonnet-4-6",
|
|
26
|
+
cwd: "/test",
|
|
27
|
+
tools: [],
|
|
28
|
+
permissionMode: "default",
|
|
29
|
+
claude_code_version: "1.0",
|
|
30
|
+
mcp_servers: [],
|
|
31
|
+
agents: [],
|
|
32
|
+
slash_commands: [],
|
|
33
|
+
skills: [],
|
|
34
|
+
total_cost_usd: 0,
|
|
35
|
+
num_turns: 0,
|
|
36
|
+
context_used_percent: 0,
|
|
37
|
+
is_compacting: false,
|
|
38
|
+
git_branch: "",
|
|
39
|
+
is_worktree: false,
|
|
40
|
+
is_containerized: false,
|
|
41
|
+
repo_root: "",
|
|
42
|
+
git_ahead: 0,
|
|
43
|
+
git_behind: 0,
|
|
44
|
+
total_lines_added: 0,
|
|
45
|
+
total_lines_removed: 0,
|
|
46
|
+
aiValidationEnabled: false,
|
|
47
|
+
aiValidationAutoApprove: false,
|
|
48
|
+
aiValidationAutoDeny: false,
|
|
49
|
+
},
|
|
50
|
+
pendingPermissions: new Map(),
|
|
51
|
+
messageHistory: [],
|
|
52
|
+
pendingMessages: [],
|
|
53
|
+
nextEventSeq: 1,
|
|
54
|
+
eventBuffer: [],
|
|
55
|
+
lastAckSeq: 0,
|
|
56
|
+
processedClientMessageIds: [],
|
|
57
|
+
processedClientMessageIdSet: new Set(),
|
|
58
|
+
lastCliActivityTs: Date.now(),
|
|
59
|
+
stateMachine: new SessionStateMachine("test-session"),
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeAssistantMsg(id: string): BrowserIncomingMessage {
|
|
65
|
+
return {
|
|
66
|
+
type: "assistant",
|
|
67
|
+
message: { id, type: "message", role: "assistant", model: "claude", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } },
|
|
68
|
+
parent_tool_use_id: null,
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── appendHistory ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("appendHistory", () => {
|
|
76
|
+
it("appends message to session history", () => {
|
|
77
|
+
const session = makeSession();
|
|
78
|
+
const msg = makeAssistantMsg("m1");
|
|
79
|
+
appendHistory(session, msg);
|
|
80
|
+
|
|
81
|
+
expect(session.messageHistory).toHaveLength(1);
|
|
82
|
+
expect(session.messageHistory[0]).toBe(msg);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("appends multiple messages in order", () => {
|
|
86
|
+
const session = makeSession();
|
|
87
|
+
const msg1 = makeAssistantMsg("m1");
|
|
88
|
+
const msg2 = makeAssistantMsg("m2");
|
|
89
|
+
const msg3 = makeAssistantMsg("m3");
|
|
90
|
+
|
|
91
|
+
appendHistory(session, msg1);
|
|
92
|
+
appendHistory(session, msg2);
|
|
93
|
+
appendHistory(session, msg3);
|
|
94
|
+
|
|
95
|
+
expect(session.messageHistory).toHaveLength(3);
|
|
96
|
+
// Verify ordering is preserved
|
|
97
|
+
expect((session.messageHistory[0] as any).message.id).toBe("m1");
|
|
98
|
+
expect((session.messageHistory[1] as any).message.id).toBe("m2");
|
|
99
|
+
expect((session.messageHistory[2] as any).message.id).toBe("m3");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("trims oldest messages when history exceeds limit", () => {
|
|
103
|
+
const session = makeSession();
|
|
104
|
+
const limit = 5;
|
|
105
|
+
|
|
106
|
+
// Add 7 messages with limit of 5
|
|
107
|
+
for (let i = 0; i < 7; i++) {
|
|
108
|
+
appendHistory(session, makeAssistantMsg(`m${i}`), limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(session.messageHistory).toHaveLength(5);
|
|
112
|
+
// Oldest 2 (m0, m1) should be trimmed; m2-m6 remain
|
|
113
|
+
expect((session.messageHistory[0] as any).message.id).toBe("m2");
|
|
114
|
+
expect((session.messageHistory[4] as any).message.id).toBe("m6");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses MESSAGE_HISTORY_LIMIT as default", () => {
|
|
118
|
+
expect(MESSAGE_HISTORY_LIMIT).toBe(2000);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── persistSession ───────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("persistSession", () => {
|
|
125
|
+
let tempDir: string;
|
|
126
|
+
let store: SessionStore;
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
tempDir = mkdtempSync(join(tmpdir(), "persist-test-"));
|
|
130
|
+
store = new SessionStore(tempDir);
|
|
131
|
+
// Suppress console output
|
|
132
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
133
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
134
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
store.dispose();
|
|
139
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
140
|
+
vi.restoreAllMocks();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("calls store.save with serialized session data", () => {
|
|
144
|
+
const session = makeSession();
|
|
145
|
+
const saveSpy = vi.spyOn(store, "save");
|
|
146
|
+
|
|
147
|
+
persistSession(session, store);
|
|
148
|
+
|
|
149
|
+
expect(saveSpy).toHaveBeenCalledTimes(1);
|
|
150
|
+
const saved = saveSpy.mock.calls[0][0];
|
|
151
|
+
expect(saved.id).toBe("test-session");
|
|
152
|
+
expect(saved.state).toBe(session.state);
|
|
153
|
+
expect(saved.messageHistory).toBe(session.messageHistory);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("is a no-op when store is null", () => {
|
|
157
|
+
const session = makeSession();
|
|
158
|
+
// Should not throw
|
|
159
|
+
expect(() => persistSession(session, null)).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── appendAndPersist ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe("appendAndPersist", () => {
|
|
166
|
+
let tempDir: string;
|
|
167
|
+
let store: SessionStore;
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
tempDir = mkdtempSync(join(tmpdir(), "persist-test-"));
|
|
171
|
+
store = new SessionStore(tempDir);
|
|
172
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
173
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
174
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
store.dispose();
|
|
179
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
180
|
+
vi.restoreAllMocks();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("appends message to history AND calls store.save", () => {
|
|
184
|
+
const session = makeSession();
|
|
185
|
+
const msg = makeAssistantMsg("m1");
|
|
186
|
+
const saveSpy = vi.spyOn(store, "save");
|
|
187
|
+
|
|
188
|
+
appendAndPersist(session, msg, store);
|
|
189
|
+
|
|
190
|
+
expect(session.messageHistory).toHaveLength(1);
|
|
191
|
+
expect(saveSpy).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("enforces history cap while persisting", () => {
|
|
195
|
+
const session = makeSession();
|
|
196
|
+
const limit = 3;
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < 5; i++) {
|
|
199
|
+
appendAndPersist(session, makeAssistantMsg(`m${i}`), store, limit);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
expect(session.messageHistory).toHaveLength(3);
|
|
203
|
+
expect((session.messageHistory[0] as any).message.id).toBe("m2");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("works with null store (append only)", () => {
|
|
207
|
+
const session = makeSession();
|
|
208
|
+
const msg = makeAssistantMsg("m1");
|
|
209
|
+
|
|
210
|
+
appendAndPersist(session, msg, null);
|
|
211
|
+
|
|
212
|
+
expect(session.messageHistory).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ─── serializeForStore ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
describe("serializeForStore", () => {
|
|
219
|
+
it("converts pendingPermissions Map to array of entries", () => {
|
|
220
|
+
const session = makeSession();
|
|
221
|
+
session.pendingPermissions.set("req-1", {
|
|
222
|
+
request_id: "req-1",
|
|
223
|
+
tool_name: "Bash",
|
|
224
|
+
input: { command: "ls" },
|
|
225
|
+
timestamp: 1000,
|
|
226
|
+
} as any);
|
|
227
|
+
session.pendingPermissions.set("req-2", {
|
|
228
|
+
request_id: "req-2",
|
|
229
|
+
tool_name: "Read",
|
|
230
|
+
input: { file_path: "/test" },
|
|
231
|
+
timestamp: 2000,
|
|
232
|
+
} as any);
|
|
233
|
+
|
|
234
|
+
const serialized = serializeForStore(session);
|
|
235
|
+
|
|
236
|
+
expect(serialized.pendingPermissions).toHaveLength(2);
|
|
237
|
+
expect(serialized.pendingPermissions[0][0]).toBe("req-1");
|
|
238
|
+
expect(serialized.pendingPermissions[0][1].tool_name).toBe("Bash");
|
|
239
|
+
expect(serialized.pendingPermissions[1][0]).toBe("req-2");
|
|
240
|
+
expect(serialized.pendingPermissions[1][1].tool_name).toBe("Read");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("includes eventBuffer and sequence counters", () => {
|
|
244
|
+
const session = makeSession({
|
|
245
|
+
eventBuffer: [{ seq: 1, message: { type: "cli_connected" } }],
|
|
246
|
+
nextEventSeq: 42,
|
|
247
|
+
lastAckSeq: 10,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const serialized = serializeForStore(session);
|
|
251
|
+
|
|
252
|
+
expect(serialized.eventBuffer).toHaveLength(1);
|
|
253
|
+
expect(serialized.nextEventSeq).toBe(42);
|
|
254
|
+
expect(serialized.lastAckSeq).toBe(10);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("includes processedClientMessageIds for browser dedup restoration", () => {
|
|
258
|
+
const session = makeSession({
|
|
259
|
+
processedClientMessageIds: ["id-1", "id-2", "id-3"],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const serialized = serializeForStore(session);
|
|
263
|
+
expect(serialized.processedClientMessageIds).toEqual(["id-1", "id-2", "id-3"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("preserves message ordering through serialization", () => {
|
|
267
|
+
const session = makeSession();
|
|
268
|
+
for (let i = 0; i < 5; i++) {
|
|
269
|
+
session.messageHistory.push(makeAssistantMsg(`m${i}`));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const serialized = serializeForStore(session);
|
|
273
|
+
const parsed = JSON.parse(JSON.stringify(serialized));
|
|
274
|
+
|
|
275
|
+
// Message ordering should survive JSON round-trip
|
|
276
|
+
for (let i = 0; i < 5; i++) {
|
|
277
|
+
expect(parsed.messageHistory[i].message.id).toBe(`m${i}`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("produces identical output for same session state (idempotent)", () => {
|
|
282
|
+
const session = makeSession();
|
|
283
|
+
session.messageHistory.push(makeAssistantMsg("m1"));
|
|
284
|
+
session.pendingPermissions.set("req-1", {
|
|
285
|
+
request_id: "req-1",
|
|
286
|
+
tool_name: "Bash",
|
|
287
|
+
input: {},
|
|
288
|
+
timestamp: 1000,
|
|
289
|
+
} as any);
|
|
290
|
+
|
|
291
|
+
const first = JSON.stringify(serializeForStore(session));
|
|
292
|
+
const second = JSON.stringify(serializeForStore(session));
|
|
293
|
+
|
|
294
|
+
expect(first).toBe(second);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
2
|
+
import type { Session } from "./ws-bridge-types.js";
|
|
3
|
+
import type { SessionStore, PersistedSession } from "./session-store.js";
|
|
4
|
+
|
|
5
|
+
// ─── Persistence Pipeline ───────────────────────────────────────────────────
|
|
6
|
+
// Extracted from WsBridge to consolidate history append + disk persistence
|
|
7
|
+
// into explicit, testable functions.
|
|
8
|
+
|
|
9
|
+
export const MESSAGE_HISTORY_LIMIT = 2000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Append a message to session history with cap enforcement, then persist to disk.
|
|
13
|
+
* Consolidates the common appendHistory + persistSession pattern into one call,
|
|
14
|
+
* eliminating the risk of appending without persisting.
|
|
15
|
+
*/
|
|
16
|
+
export function appendAndPersist(
|
|
17
|
+
session: Session,
|
|
18
|
+
msg: BrowserIncomingMessage,
|
|
19
|
+
store: SessionStore | null,
|
|
20
|
+
historyLimit: number = MESSAGE_HISTORY_LIMIT,
|
|
21
|
+
): void {
|
|
22
|
+
appendHistory(session, msg, historyLimit);
|
|
23
|
+
persistSession(session, store);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Append a message to session history with cap enforcement.
|
|
28
|
+
* Trims oldest messages when the history exceeds the limit.
|
|
29
|
+
*/
|
|
30
|
+
export function appendHistory(
|
|
31
|
+
session: Session,
|
|
32
|
+
msg: BrowserIncomingMessage,
|
|
33
|
+
historyLimit: number = MESSAGE_HISTORY_LIMIT,
|
|
34
|
+
): void {
|
|
35
|
+
session.messageHistory.push(msg);
|
|
36
|
+
if (session.messageHistory.length > historyLimit) {
|
|
37
|
+
session.messageHistory.splice(0, session.messageHistory.length - historyLimit);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Persist session state to disk (debounced via SessionStore).
|
|
43
|
+
* No-op if no store is attached.
|
|
44
|
+
*/
|
|
45
|
+
export function persistSession(session: Session, store: SessionStore | null): void {
|
|
46
|
+
if (!store) return;
|
|
47
|
+
store.save(serializeForStore(session));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Serialize a Session into the shape expected by SessionStore.save().
|
|
52
|
+
* Converts Maps to arrays and selects the persisted fields.
|
|
53
|
+
*/
|
|
54
|
+
export function serializeForStore(session: Session): PersistedSession {
|
|
55
|
+
return {
|
|
56
|
+
id: session.id,
|
|
57
|
+
state: session.state,
|
|
58
|
+
messageHistory: session.messageHistory,
|
|
59
|
+
pendingMessages: session.pendingMessages,
|
|
60
|
+
pendingPermissions: Array.from(session.pendingPermissions.entries()),
|
|
61
|
+
eventBuffer: session.eventBuffer,
|
|
62
|
+
nextEventSeq: session.nextEventSeq,
|
|
63
|
+
lastAckSeq: session.lastAckSeq,
|
|
64
|
+
processedClientMessageIds: session.processedClientMessageIds,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
broadcastToBrowsers,
|
|
4
|
+
sendToBrowser,
|
|
5
|
+
EVENT_BUFFER_LIMIT,
|
|
6
|
+
} from "./ws-bridge-publish.js";
|
|
7
|
+
import type { Session, SocketData } from "./ws-bridge-types.js";
|
|
8
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
9
|
+
import { SessionStateMachine } from "./session-state-machine.js";
|
|
10
|
+
import type { ServerWebSocket } from "bun";
|
|
11
|
+
|
|
12
|
+
function makeMockSocket(sessionId = "test-session") {
|
|
13
|
+
return {
|
|
14
|
+
data: { kind: "browser", sessionId } as SocketData,
|
|
15
|
+
send: vi.fn(),
|
|
16
|
+
close: vi.fn(),
|
|
17
|
+
readyState: 1,
|
|
18
|
+
} as unknown as ServerWebSocket<SocketData>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
22
|
+
return {
|
|
23
|
+
id: "test-session",
|
|
24
|
+
backendType: "claude",
|
|
25
|
+
backendAdapter: null,
|
|
26
|
+
browserSockets: new Set(),
|
|
27
|
+
state: {
|
|
28
|
+
session_id: "test-session",
|
|
29
|
+
model: "claude-sonnet-4-6",
|
|
30
|
+
cwd: "/test",
|
|
31
|
+
tools: [],
|
|
32
|
+
permissionMode: "default",
|
|
33
|
+
claude_code_version: "1.0",
|
|
34
|
+
mcp_servers: [],
|
|
35
|
+
agents: [],
|
|
36
|
+
slash_commands: [],
|
|
37
|
+
skills: [],
|
|
38
|
+
total_cost_usd: 0,
|
|
39
|
+
num_turns: 0,
|
|
40
|
+
context_used_percent: 0,
|
|
41
|
+
is_compacting: false,
|
|
42
|
+
git_branch: "",
|
|
43
|
+
is_worktree: false,
|
|
44
|
+
is_containerized: false,
|
|
45
|
+
repo_root: "",
|
|
46
|
+
git_ahead: 0,
|
|
47
|
+
git_behind: 0,
|
|
48
|
+
total_lines_added: 0,
|
|
49
|
+
total_lines_removed: 0,
|
|
50
|
+
aiValidationEnabled: false,
|
|
51
|
+
aiValidationAutoApprove: false,
|
|
52
|
+
aiValidationAutoDeny: false,
|
|
53
|
+
},
|
|
54
|
+
pendingPermissions: new Map(),
|
|
55
|
+
messageHistory: [],
|
|
56
|
+
pendingMessages: [],
|
|
57
|
+
nextEventSeq: 1,
|
|
58
|
+
eventBuffer: [],
|
|
59
|
+
lastAckSeq: 0,
|
|
60
|
+
processedClientMessageIds: [],
|
|
61
|
+
processedClientMessageIdSet: new Set(),
|
|
62
|
+
lastCliActivityTs: Date.now(),
|
|
63
|
+
stateMachine: new SessionStateMachine("test-session"),
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
70
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
71
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── broadcastToBrowsers ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("broadcastToBrowsers", () => {
|
|
81
|
+
it("sends message to all connected browser sockets", () => {
|
|
82
|
+
const ws1 = makeMockSocket();
|
|
83
|
+
const ws2 = makeMockSocket();
|
|
84
|
+
const session = makeSession();
|
|
85
|
+
session.browserSockets.add(ws1);
|
|
86
|
+
session.browserSockets.add(ws2);
|
|
87
|
+
|
|
88
|
+
const msg: BrowserIncomingMessage = { type: "cli_connected" };
|
|
89
|
+
broadcastToBrowsers(session, msg, {
|
|
90
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
91
|
+
recorder: null,
|
|
92
|
+
persistFn: vi.fn(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
97
|
+
|
|
98
|
+
// Both should receive the same JSON
|
|
99
|
+
const sent1 = (ws1.send as any).mock.calls[0][0];
|
|
100
|
+
const sent2 = (ws2.send as any).mock.calls[0][0];
|
|
101
|
+
expect(sent1).toBe(sent2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("removes broken sockets that throw on send", () => {
|
|
105
|
+
const goodWs = makeMockSocket();
|
|
106
|
+
const badWs = makeMockSocket();
|
|
107
|
+
(badWs.send as any).mockImplementation(() => { throw new Error("broken"); });
|
|
108
|
+
|
|
109
|
+
const session = makeSession();
|
|
110
|
+
session.browserSockets.add(goodWs);
|
|
111
|
+
session.browserSockets.add(badWs);
|
|
112
|
+
|
|
113
|
+
broadcastToBrowsers(session, { type: "cli_connected" }, {
|
|
114
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
115
|
+
recorder: null,
|
|
116
|
+
persistFn: vi.fn(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Good socket still connected, bad one removed
|
|
120
|
+
expect(session.browserSockets.has(goodWs)).toBe(true);
|
|
121
|
+
expect(session.browserSockets.has(badWs)).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("assigns monotonically increasing seq numbers", () => {
|
|
125
|
+
const ws = makeMockSocket();
|
|
126
|
+
const session = makeSession();
|
|
127
|
+
session.browserSockets.add(ws);
|
|
128
|
+
|
|
129
|
+
const opts = {
|
|
130
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
131
|
+
recorder: null,
|
|
132
|
+
persistFn: vi.fn(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Send 3 messages
|
|
136
|
+
broadcastToBrowsers(session, { type: "cli_connected" }, opts);
|
|
137
|
+
broadcastToBrowsers(session, { type: "cli_disconnected" }, opts);
|
|
138
|
+
broadcastToBrowsers(session, { type: "cli_connected" }, opts);
|
|
139
|
+
|
|
140
|
+
const seqs = (ws.send as any).mock.calls.map((call: any) => {
|
|
141
|
+
const parsed = JSON.parse(call[0]);
|
|
142
|
+
return parsed.seq;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// seq numbers should be strictly increasing
|
|
146
|
+
expect(seqs[0]).toBeLessThan(seqs[1]);
|
|
147
|
+
expect(seqs[1]).toBeLessThan(seqs[2]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("calls recorder.record when recorder is provided", () => {
|
|
151
|
+
const ws = makeMockSocket();
|
|
152
|
+
const session = makeSession();
|
|
153
|
+
session.browserSockets.add(ws);
|
|
154
|
+
|
|
155
|
+
const recorder = {
|
|
156
|
+
record: vi.fn(),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
broadcastToBrowsers(session, { type: "cli_connected" }, {
|
|
160
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
161
|
+
recorder: recorder as any,
|
|
162
|
+
persistFn: vi.fn(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(recorder.record).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(recorder.record).toHaveBeenCalledWith(
|
|
167
|
+
"test-session", "out", expect.any(String), "browser", "claude", "/test",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("logs warning when broadcasting to 0 browsers for assistant/stream_event/result", () => {
|
|
172
|
+
const session = makeSession(); // no browser sockets
|
|
173
|
+
const logSpy = vi.mocked(console.log);
|
|
174
|
+
|
|
175
|
+
broadcastToBrowsers(session, { type: "assistant", message: {} as any, parent_tool_use_id: null, timestamp: 1 }, {
|
|
176
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
177
|
+
recorder: null,
|
|
178
|
+
persistFn: vi.fn(),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining("Broadcasting assistant to 0 browsers"),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not warn for non-critical message types with 0 browsers", () => {
|
|
187
|
+
const session = makeSession();
|
|
188
|
+
const logSpy = vi.mocked(console.log);
|
|
189
|
+
logSpy.mockClear();
|
|
190
|
+
|
|
191
|
+
broadcastToBrowsers(session, { type: "cli_connected" }, {
|
|
192
|
+
eventBufferLimit: EVENT_BUFFER_LIMIT,
|
|
193
|
+
recorder: null,
|
|
194
|
+
persistFn: vi.fn(),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Should not have the "Broadcasting ... to 0 browsers" warning
|
|
198
|
+
const warningCalls = logSpy.mock.calls.filter(
|
|
199
|
+
(call) => typeof call[0] === "string" && call[0].includes("0 browsers"),
|
|
200
|
+
);
|
|
201
|
+
expect(warningCalls).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── sendToBrowser ────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe("sendToBrowser", () => {
|
|
208
|
+
it("sends JSON-serialized message to socket", () => {
|
|
209
|
+
const ws = makeMockSocket();
|
|
210
|
+
const msg: BrowserIncomingMessage = { type: "cli_connected" };
|
|
211
|
+
|
|
212
|
+
sendToBrowser(ws, msg);
|
|
213
|
+
|
|
214
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
215
|
+
const sent = JSON.parse((ws.send as any).mock.calls[0][0]);
|
|
216
|
+
expect(sent.type).toBe("cli_connected");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not throw when socket.send fails", () => {
|
|
220
|
+
const ws = makeMockSocket();
|
|
221
|
+
(ws.send as any).mockImplementation(() => { throw new Error("broken"); });
|
|
222
|
+
|
|
223
|
+
// Should not throw
|
|
224
|
+
expect(() => sendToBrowser(ws, { type: "cli_connected" })).not.toThrow();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── EVENT_BUFFER_LIMIT ───────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe("EVENT_BUFFER_LIMIT", () => {
|
|
231
|
+
it("is 600", () => {
|
|
232
|
+
expect(EVENT_BUFFER_LIMIT).toBe(600);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
3
|
+
import type { Session, SocketData } from "./ws-bridge-types.js";
|
|
4
|
+
import type { RecorderManager } from "./recorder.js";
|
|
5
|
+
import { sequenceEvent } from "./ws-bridge-replay.js";
|
|
6
|
+
|
|
7
|
+
// ─── Publish Pipeline ───────────────────────────────────────────────────────
|
|
8
|
+
// Transport functions for sending messages to CLI and browser sockets.
|
|
9
|
+
// Extracted from WsBridge to enable isolated testing of message delivery,
|
|
10
|
+
// sequencing, and recording behavior.
|
|
11
|
+
|
|
12
|
+
export const EVENT_BUFFER_LIMIT = 600;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Broadcast a message to all connected browsers for a session.
|
|
16
|
+
* Assigns a monotonic sequence number via sequenceEvent, records the
|
|
17
|
+
* outgoing message, and sends to every browser socket (removing broken ones).
|
|
18
|
+
*
|
|
19
|
+
* Note: sequenceEvent internally calls persistFn when buffering events.
|
|
20
|
+
* Callers that also call persistSession after broadcastToBrowsers will
|
|
21
|
+
* trigger a redundant (but harmless) debounced save. This is intentional —
|
|
22
|
+
* the caller-side persist covers state mutations beyond the event buffer
|
|
23
|
+
* (e.g. messageHistory, pendingPermissions), while the internal persist
|
|
24
|
+
* covers the event buffer/seq counters. SessionStore's debouncer coalesces
|
|
25
|
+
* them into a single write.
|
|
26
|
+
*/
|
|
27
|
+
export function broadcastToBrowsers(
|
|
28
|
+
session: Session,
|
|
29
|
+
msg: BrowserIncomingMessage,
|
|
30
|
+
opts: {
|
|
31
|
+
eventBufferLimit: number;
|
|
32
|
+
recorder: RecorderManager | null;
|
|
33
|
+
persistFn: (session: Session) => void;
|
|
34
|
+
},
|
|
35
|
+
): void {
|
|
36
|
+
// Warn when messages that should be visible to users are broadcast to 0 browsers
|
|
37
|
+
if (
|
|
38
|
+
session.browserSockets.size === 0
|
|
39
|
+
&& (msg.type === "assistant" || msg.type === "stream_event" || msg.type === "result")
|
|
40
|
+
) {
|
|
41
|
+
console.log(
|
|
42
|
+
`[ws-bridge] ⚠ Broadcasting ${msg.type} to 0 browsers for session ${session.id} (stored in history: ${msg.type === "assistant" || msg.type === "result"})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const json = JSON.stringify(
|
|
47
|
+
sequenceEvent(session, msg, opts.eventBufferLimit, opts.persistFn),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Record raw outgoing browser message
|
|
51
|
+
opts.recorder?.record(
|
|
52
|
+
session.id, "out", json, "browser", session.backendType, session.state.cwd,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
for (const ws of session.browserSockets) {
|
|
56
|
+
try {
|
|
57
|
+
ws.send(json);
|
|
58
|
+
} catch {
|
|
59
|
+
session.browserSockets.delete(ws);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a message to a single browser socket (no sequencing).
|
|
66
|
+
* Used for replay, session_init, and message_history — messages that
|
|
67
|
+
* should NOT go through the event buffer.
|
|
68
|
+
*/
|
|
69
|
+
export function sendToBrowser(
|
|
70
|
+
ws: ServerWebSocket<SocketData>,
|
|
71
|
+
msg: BrowserIncomingMessage,
|
|
72
|
+
): void {
|
|
73
|
+
try {
|
|
74
|
+
ws.send(JSON.stringify(msg));
|
|
75
|
+
} catch {
|
|
76
|
+
// Socket will be cleaned up on close
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|