@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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for NoVncProxy — WebSocket relay between browser and container's websockify.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - handleOpen: no container → closes with 1011
|
|
6
|
+
* - handleOpen: no port mapping → closes with 1011
|
|
7
|
+
* - handleOpen: successful upstream connection + message relay (both directions)
|
|
8
|
+
* - handleMessage: relays binary and text frames to upstream
|
|
9
|
+
* - handleMessage: no-op when pair not found or upstream not open
|
|
10
|
+
* - handleClose: closes upstream and cleans up pair
|
|
11
|
+
* - Upstream close propagates to browser socket
|
|
12
|
+
* - Upstream error propagates to browser socket
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
15
|
+
|
|
16
|
+
// ── Mock container-manager ──────────────────────────────────────────────────
|
|
17
|
+
const mockGetContainer = vi.hoisted(() => vi.fn());
|
|
18
|
+
|
|
19
|
+
vi.mock("./container-manager.js", () => ({
|
|
20
|
+
containerManager: {
|
|
21
|
+
getContainer: mockGetContainer,
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// ── Mock WebSocket ──────────────────────────────────────────────────────────
|
|
26
|
+
// The NoVncProxy creates native WebSocket instances to connect to the container.
|
|
27
|
+
// We intercept these with a fake implementation that captures event listeners.
|
|
28
|
+
|
|
29
|
+
interface FakeWebSocketInstance {
|
|
30
|
+
binaryType: string;
|
|
31
|
+
readyState: number;
|
|
32
|
+
listeners: Record<string, Array<(ev: unknown) => void>>;
|
|
33
|
+
send: ReturnType<typeof vi.fn>;
|
|
34
|
+
close: ReturnType<typeof vi.fn>;
|
|
35
|
+
addEventListener: (type: string, cb: (ev: unknown) => void) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let lastCreatedUpstream: FakeWebSocketInstance | null = null;
|
|
39
|
+
|
|
40
|
+
class FakeWebSocket {
|
|
41
|
+
static OPEN = 1;
|
|
42
|
+
static CLOSED = 3;
|
|
43
|
+
|
|
44
|
+
binaryType = "blob";
|
|
45
|
+
readyState = 0; // CONNECTING
|
|
46
|
+
listeners: Record<string, Array<(ev: unknown) => void>> = {};
|
|
47
|
+
send = vi.fn();
|
|
48
|
+
close = vi.fn();
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
public url: string,
|
|
52
|
+
public protocols?: string | string[],
|
|
53
|
+
) {
|
|
54
|
+
// Store for test access
|
|
55
|
+
lastCreatedUpstream = this as unknown as FakeWebSocketInstance;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
addEventListener(type: string, cb: (ev: unknown) => void) {
|
|
59
|
+
if (!this.listeners[type]) this.listeners[type] = [];
|
|
60
|
+
this.listeners[type].push(cb);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Patch global WebSocket so NoVncProxy uses our fake
|
|
65
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
66
|
+
|
|
67
|
+
import { NoVncProxy } from "./novnc-proxy.js";
|
|
68
|
+
|
|
69
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Creates a mock ServerWebSocket that behaves like Bun's ServerWebSocket. */
|
|
72
|
+
function makeBrowserWs() {
|
|
73
|
+
return {
|
|
74
|
+
data: { kind: "novnc" as const, sessionId: "s1" },
|
|
75
|
+
send: vi.fn(),
|
|
76
|
+
close: vi.fn(),
|
|
77
|
+
} as unknown as import("bun").ServerWebSocket<import("./ws-bridge-types.js").SocketData>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fireUpstreamEvent(upstream: FakeWebSocketInstance, type: string, payload?: unknown) {
|
|
81
|
+
for (const cb of upstream.listeners[type] ?? []) {
|
|
82
|
+
cb(payload);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("NoVncProxy", () => {
|
|
89
|
+
let proxy: NoVncProxy;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.clearAllMocks();
|
|
93
|
+
lastCreatedUpstream = null;
|
|
94
|
+
proxy = new NoVncProxy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── handleOpen ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
it("closes browser socket with 1011 when container not found", () => {
|
|
100
|
+
mockGetContainer.mockReturnValue(undefined);
|
|
101
|
+
const ws = makeBrowserWs();
|
|
102
|
+
|
|
103
|
+
proxy.handleOpen(ws, "s1");
|
|
104
|
+
|
|
105
|
+
expect(ws.close).toHaveBeenCalledWith(1011, "Container not found");
|
|
106
|
+
// No upstream should have been created
|
|
107
|
+
expect(lastCreatedUpstream).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("closes browser socket with 1011 when noVNC port not mapped", () => {
|
|
111
|
+
mockGetContainer.mockReturnValue({
|
|
112
|
+
portMappings: [{ containerPort: 3000, hostPort: 49100 }],
|
|
113
|
+
});
|
|
114
|
+
const ws = makeBrowserWs();
|
|
115
|
+
|
|
116
|
+
proxy.handleOpen(ws, "s1");
|
|
117
|
+
|
|
118
|
+
expect(ws.close).toHaveBeenCalledWith(1011, "noVNC port not mapped");
|
|
119
|
+
expect(lastCreatedUpstream).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("connects to upstream websockify when container and port mapping exist", () => {
|
|
123
|
+
mockGetContainer.mockReturnValue({
|
|
124
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
125
|
+
});
|
|
126
|
+
const ws = makeBrowserWs();
|
|
127
|
+
|
|
128
|
+
proxy.handleOpen(ws, "s1");
|
|
129
|
+
|
|
130
|
+
expect(lastCreatedUpstream).not.toBeNull();
|
|
131
|
+
expect(lastCreatedUpstream!.binaryType).toBe("arraybuffer");
|
|
132
|
+
// Upstream URL should use the host port from the mapping
|
|
133
|
+
expect((lastCreatedUpstream as unknown as FakeWebSocket).url).toBe(
|
|
134
|
+
"ws://127.0.0.1:49200",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("relays ArrayBuffer messages from upstream to browser socket", () => {
|
|
139
|
+
mockGetContainer.mockReturnValue({
|
|
140
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
141
|
+
});
|
|
142
|
+
const ws = makeBrowserWs();
|
|
143
|
+
|
|
144
|
+
proxy.handleOpen(ws, "s1");
|
|
145
|
+
const upstream = lastCreatedUpstream!;
|
|
146
|
+
|
|
147
|
+
// Simulate upstream sending an ArrayBuffer (binary VNC frame)
|
|
148
|
+
const buf = new ArrayBuffer(4);
|
|
149
|
+
fireUpstreamEvent(upstream, "message", { data: buf });
|
|
150
|
+
|
|
151
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
152
|
+
// Should send as Uint8Array wrapping the ArrayBuffer
|
|
153
|
+
const sent = (ws.send as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
154
|
+
expect(sent).toBeInstanceOf(Uint8Array);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("relays text messages from upstream to browser socket", () => {
|
|
158
|
+
mockGetContainer.mockReturnValue({
|
|
159
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
160
|
+
});
|
|
161
|
+
const ws = makeBrowserWs();
|
|
162
|
+
|
|
163
|
+
proxy.handleOpen(ws, "s1");
|
|
164
|
+
const upstream = lastCreatedUpstream!;
|
|
165
|
+
|
|
166
|
+
// Simulate upstream sending a text message
|
|
167
|
+
fireUpstreamEvent(upstream, "message", { data: "hello" });
|
|
168
|
+
|
|
169
|
+
expect(ws.send).toHaveBeenCalledWith("hello");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("cleans up and closes browser socket when upstream closes", () => {
|
|
173
|
+
mockGetContainer.mockReturnValue({
|
|
174
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
175
|
+
});
|
|
176
|
+
const ws = makeBrowserWs();
|
|
177
|
+
|
|
178
|
+
proxy.handleOpen(ws, "s1");
|
|
179
|
+
const upstream = lastCreatedUpstream!;
|
|
180
|
+
|
|
181
|
+
fireUpstreamEvent(upstream, "close");
|
|
182
|
+
|
|
183
|
+
expect(ws.close).toHaveBeenCalled();
|
|
184
|
+
// Pair should be removed — subsequent handleMessage should be no-op
|
|
185
|
+
proxy.handleMessage(ws, "test");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("cleans up and closes browser socket on upstream error", () => {
|
|
189
|
+
mockGetContainer.mockReturnValue({
|
|
190
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
191
|
+
});
|
|
192
|
+
const ws = makeBrowserWs();
|
|
193
|
+
|
|
194
|
+
proxy.handleOpen(ws, "s1");
|
|
195
|
+
const upstream = lastCreatedUpstream!;
|
|
196
|
+
|
|
197
|
+
fireUpstreamEvent(upstream, "error", new Error("connection refused"));
|
|
198
|
+
|
|
199
|
+
expect(ws.close).toHaveBeenCalledWith(1011, "Upstream connection failed");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── handleMessage ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
it("relays text messages from browser to upstream", () => {
|
|
205
|
+
mockGetContainer.mockReturnValue({
|
|
206
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
207
|
+
});
|
|
208
|
+
const ws = makeBrowserWs();
|
|
209
|
+
|
|
210
|
+
proxy.handleOpen(ws, "s1");
|
|
211
|
+
const upstream = lastCreatedUpstream!;
|
|
212
|
+
upstream.readyState = WebSocket.OPEN;
|
|
213
|
+
|
|
214
|
+
proxy.handleMessage(ws, "browser-text");
|
|
215
|
+
|
|
216
|
+
expect(upstream.send).toHaveBeenCalledWith("browser-text");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("relays Buffer messages from browser to upstream", () => {
|
|
220
|
+
mockGetContainer.mockReturnValue({
|
|
221
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
222
|
+
});
|
|
223
|
+
const ws = makeBrowserWs();
|
|
224
|
+
|
|
225
|
+
proxy.handleOpen(ws, "s1");
|
|
226
|
+
const upstream = lastCreatedUpstream!;
|
|
227
|
+
upstream.readyState = WebSocket.OPEN;
|
|
228
|
+
|
|
229
|
+
const buf = Buffer.from([0x01, 0x02, 0x03]);
|
|
230
|
+
proxy.handleMessage(ws, buf);
|
|
231
|
+
|
|
232
|
+
// After dead-code removal, Buffer input is always converted to Uint8Array
|
|
233
|
+
expect(upstream.send).toHaveBeenCalledWith(new Uint8Array(buf));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("is a no-op when pair not found", () => {
|
|
237
|
+
const ws = makeBrowserWs();
|
|
238
|
+
// No handleOpen called — no pair exists
|
|
239
|
+
proxy.handleMessage(ws, "test");
|
|
240
|
+
// Should not throw
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("is a no-op when upstream is not open", () => {
|
|
244
|
+
mockGetContainer.mockReturnValue({
|
|
245
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
246
|
+
});
|
|
247
|
+
const ws = makeBrowserWs();
|
|
248
|
+
|
|
249
|
+
proxy.handleOpen(ws, "s1");
|
|
250
|
+
const upstream = lastCreatedUpstream!;
|
|
251
|
+
upstream.readyState = 0; // CONNECTING, not OPEN
|
|
252
|
+
|
|
253
|
+
proxy.handleMessage(ws, "test");
|
|
254
|
+
|
|
255
|
+
expect(upstream.send).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── handleClose ─────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
it("closes upstream and cleans up when browser socket closes", () => {
|
|
261
|
+
mockGetContainer.mockReturnValue({
|
|
262
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
263
|
+
});
|
|
264
|
+
const ws = makeBrowserWs();
|
|
265
|
+
|
|
266
|
+
proxy.handleOpen(ws, "s1");
|
|
267
|
+
const upstream = lastCreatedUpstream!;
|
|
268
|
+
|
|
269
|
+
proxy.handleClose(ws);
|
|
270
|
+
|
|
271
|
+
expect(upstream.close).toHaveBeenCalled();
|
|
272
|
+
// Pair should be removed — calling again should be no-op
|
|
273
|
+
proxy.handleClose(ws);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("is a no-op when pair not found on close", () => {
|
|
277
|
+
const ws = makeBrowserWs();
|
|
278
|
+
// No handleOpen called
|
|
279
|
+
proxy.handleClose(ws);
|
|
280
|
+
// Should not throw
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Edge cases ──────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
it("handles browser ws.send throwing when upstream relays message", () => {
|
|
286
|
+
mockGetContainer.mockReturnValue({
|
|
287
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
288
|
+
});
|
|
289
|
+
const ws = makeBrowserWs();
|
|
290
|
+
(ws.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
291
|
+
throw new Error("socket closed");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
proxy.handleOpen(ws, "s1");
|
|
295
|
+
const upstream = lastCreatedUpstream!;
|
|
296
|
+
|
|
297
|
+
// Should not throw — the error is caught internally
|
|
298
|
+
fireUpstreamEvent(upstream, "message", { data: "test" });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("handles upstream.send throwing when browser relays message", () => {
|
|
302
|
+
mockGetContainer.mockReturnValue({
|
|
303
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
304
|
+
});
|
|
305
|
+
const ws = makeBrowserWs();
|
|
306
|
+
|
|
307
|
+
proxy.handleOpen(ws, "s1");
|
|
308
|
+
const upstream = lastCreatedUpstream!;
|
|
309
|
+
upstream.readyState = WebSocket.OPEN;
|
|
310
|
+
upstream.send.mockImplementation(() => {
|
|
311
|
+
throw new Error("socket closed");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Should not throw — the error is caught internally
|
|
315
|
+
proxy.handleMessage(ws, "test");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("handles upstream.close throwing on browser close", () => {
|
|
319
|
+
mockGetContainer.mockReturnValue({
|
|
320
|
+
portMappings: [{ containerPort: 6080, hostPort: 49200 }],
|
|
321
|
+
});
|
|
322
|
+
const ws = makeBrowserWs();
|
|
323
|
+
|
|
324
|
+
proxy.handleOpen(ws, "s1");
|
|
325
|
+
const upstream = lastCreatedUpstream!;
|
|
326
|
+
upstream.close.mockImplementation(() => {
|
|
327
|
+
throw new Error("already closed");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Should not throw — the error is caught internally
|
|
331
|
+
proxy.handleClose(ws);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import type { SocketData } from "./ws-bridge-types.js";
|
|
3
|
+
import { containerManager } from "./container-manager.js";
|
|
4
|
+
|
|
5
|
+
const NOVNC_CONTAINER_PORT = 6080;
|
|
6
|
+
|
|
7
|
+
interface ProxyPair {
|
|
8
|
+
browserWs: ServerWebSocket<SocketData>;
|
|
9
|
+
upstreamWs: WebSocket;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Proxies noVNC WebSocket traffic between the user's browser and the
|
|
14
|
+
* container's websockify server. This allows the noVNC client to connect
|
|
15
|
+
* through the companion's single port instead of requiring direct access
|
|
16
|
+
* to the container's mapped port.
|
|
17
|
+
*/
|
|
18
|
+
export class NoVncProxy {
|
|
19
|
+
private pairs = new Map<ServerWebSocket<SocketData>, ProxyPair>();
|
|
20
|
+
|
|
21
|
+
handleOpen(ws: ServerWebSocket<SocketData>, sessionId: string): void {
|
|
22
|
+
const container = containerManager.getContainer(sessionId);
|
|
23
|
+
if (!container) {
|
|
24
|
+
console.warn(`[novnc-proxy] No container found for session ${sessionId}`);
|
|
25
|
+
ws.close(1011, "Container not found");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const portMapping = container.portMappings.find(
|
|
30
|
+
(p) => p.containerPort === NOVNC_CONTAINER_PORT,
|
|
31
|
+
);
|
|
32
|
+
if (!portMapping) {
|
|
33
|
+
console.warn(`[novnc-proxy] No noVNC port mapping for session ${sessionId}`);
|
|
34
|
+
ws.close(1011, "noVNC port not mapped");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Connect to the container's websockify server
|
|
39
|
+
const upstreamUrl = `ws://127.0.0.1:${portMapping.hostPort}`;
|
|
40
|
+
const upstream = new WebSocket(upstreamUrl, ["binary"]);
|
|
41
|
+
upstream.binaryType = "arraybuffer";
|
|
42
|
+
|
|
43
|
+
const pair: ProxyPair = { browserWs: ws, upstreamWs: upstream };
|
|
44
|
+
this.pairs.set(ws, pair);
|
|
45
|
+
|
|
46
|
+
upstream.addEventListener("open", () => {
|
|
47
|
+
console.log(`[novnc-proxy] Upstream connected for session ${sessionId}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
upstream.addEventListener("message", (event) => {
|
|
51
|
+
try {
|
|
52
|
+
if (event.data instanceof ArrayBuffer) {
|
|
53
|
+
ws.send(new Uint8Array(event.data));
|
|
54
|
+
} else {
|
|
55
|
+
ws.send(event.data);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Browser socket may have closed
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
upstream.addEventListener("close", () => {
|
|
63
|
+
this.pairs.delete(ws);
|
|
64
|
+
try { ws.close(); } catch { /* already closed */ }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
upstream.addEventListener("error", (err) => {
|
|
68
|
+
console.error(`[novnc-proxy] Upstream error for session ${sessionId}:`, err);
|
|
69
|
+
this.pairs.delete(ws);
|
|
70
|
+
try { ws.close(1011, "Upstream connection failed"); } catch { /* already closed */ }
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
handleMessage(ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
|
|
75
|
+
const pair = this.pairs.get(ws);
|
|
76
|
+
if (!pair) return;
|
|
77
|
+
|
|
78
|
+
const { upstreamWs } = pair;
|
|
79
|
+
if (upstreamWs.readyState !== WebSocket.OPEN) return;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
upstreamWs.send(msg instanceof Buffer ? new Uint8Array(msg) : msg);
|
|
83
|
+
} catch {
|
|
84
|
+
// Upstream may have closed
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleClose(ws: ServerWebSocket<SocketData>): void {
|
|
89
|
+
const pair = this.pairs.get(ws);
|
|
90
|
+
if (!pair) return;
|
|
91
|
+
|
|
92
|
+
this.pairs.delete(ws);
|
|
93
|
+
try {
|
|
94
|
+
pair.upstreamWs.close();
|
|
95
|
+
} catch {
|
|
96
|
+
// Already closed
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|