• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / vscode-brightscript-language / 35918066925

23 Sep 2026 08:45PM UTC coverage: 68.697% (+2.6%) from 66.107%
35918066925

Pull #906

github

web-flow
Merge 9efd60384 into 35d8d9931
Pull Request #906: RSG 2.0 SDK support

4616 of 7288 branches covered (63.34%)

Branch coverage included in aggregate %.

1632 of 1979 new or added lines in 27 files covered. (82.47%)

6866 of 9426 relevant lines covered (72.84%)

90.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.29
/src/managers/JsDebugProxy/JsDebugProxyManager.ts
1
import * as net from 'net';
1✔
2
import { createRokuDeploySocket, isLocalDeviceConfig, isRceDeviceConfig } from 'roku-deploy';
1✔
3
import type { DeviceConfig, LocalDeviceConfig, RokuDeploySocket } from 'roku-deploy';
4
import type { RceManager } from '../RceManager';
5
import { JsDebugCdpFilter } from './JsDebugCdpFilter';
1✔
6

7
/** The device-side Hermes debug port; relayed sessions attach to a local proxy port that tunnels here. */
8
export const JS_DEBUG_PORT = 9999;
1✔
9

10
/**
11
 * Caps repeated local connects from driving unbounded authenticated cloud tunnels. js-debug's own
12
 * steady state is ~3 sockets (2 pooled discovery + 1 CDP).
13
 */
14
const MAX_PROXY_CONNECTIONS = 8;
1✔
15

16
/**
17
 * Idle timer for connections with no established websocket session. Must NOT apply once a session
18
 * exists - CDP has no heartbeat, so a paused session sits silent indefinitely.
19
 */
20
const DEFAULT_IDLE_WITHOUT_SESSION_TIMEOUT_MS = 30_000;
1✔
21

22
/**
23
 * Bridges the node debugger to a Roku device's JS (Hermes) debug port, tunneling each local
24
 * connection through `createRokuDeploySocket()` (a plain tcp `LocalSocket` for a LAN device, an
25
 * authenticated websocket `RceSocket` for an RCE one).
26
 *
27
 * The pass-through `/json/list` discovery relay depends on shipped js-debug forcibly rewriting the
28
 * discovered `webSocketDebuggerUrl`'s host to the address it attached to - undocumented VS
29
 * Code-internal behavior. If that ever changes, the relayed response would advertise the device's
30
 * own address and js-debug would bypass this proxy entirely.
31
 */
32
export class JsDebugProxyManager {
1✔
33
    constructor(
34
        private rceManager: RceManager,
47✔
35
        private log: (message: string) => void,
47✔
36
        private socketFactory: (options: { device: DeviceConfig; port: number }) => RokuDeploySocket = createRokuDeploySocket,
47✔
37
        private idleWithoutSessionTimeoutMs: number = DEFAULT_IDLE_WITHOUT_SESSION_TIMEOUT_MS
47✔
38
    ) {
39
    }
40

41
    private proxiesBySessionId = new Map<string, JsDebugProxy>();
47✔
42

43
    /**
44
     * Starts (or reuses) the proxy for `sessionId` and returns the local port the node debugger
45
     * should attach to. Idempotent: a second call for the same session (even one made before the
46
     * first has finished binding) returns the same port instead of standing up a second server.
47
     */
48
    public async start(sessionId: string, device: DeviceConfig): Promise<number> {
49
        const existingProxy = this.proxiesBySessionId.get(sessionId);
18✔
50
        if (existingProxy) {
18✔
51
            return existingProxy.portPromise;
2✔
52
        }
53

54
        const proxy = {
16✔
55
            server: undefined,
56
            sockets: new Set<DestroyableSocket>(),
57
            stopped: false
58
        } as JsDebugProxy;
59
        this.proxiesBySessionId.set(sessionId, proxy);
16✔
60

61
        proxy.portPromise = this.bindServer(sessionId, device, proxy);
16✔
62
        return proxy.portPromise;
16✔
63
    }
64

65
    private async bindServer(sessionId: string, device: DeviceConfig, proxy: JsDebugProxy): Promise<number> {
66
        const server = net.createServer((client) => {
16✔
67
            this.handleConnection(sessionId, device, client).catch((error: Error) => {
16✔
68
                this.log(`[js-debug-proxy] connection handler failed: ${error?.message}`);
1!
69
                if (!client.destroyed) {
1!
70
                    client.destroy();
1✔
71
                }
72
            });
73
        });
74
        server.maxConnections = MAX_PROXY_CONNECTIONS;
16✔
75
        //Node never runs the connection listener for dropped connections; log or the client just sees ECONNRESET
76
        server.on('drop', () => {
16✔
NEW
77
            this.log(`[js-debug-proxy] dropped a connection for session '${sessionId}': already at the ${MAX_PROXY_CONNECTIONS}-connection cap`);
×
78
        });
79
        proxy.server = server;
16✔
80

81
        //atomic ephemeral-port bind; avoids get-port's check-then-bind race
82
        const port = await new Promise<number>((resolve, reject) => {
16✔
83
            const onListening = () => {
16✔
84
                server.removeListener('error', onError);
16✔
85
                const address = server.address();
16✔
86
                resolve(typeof address === 'object' && address !== null ? address.port : 0);
16!
87
            };
88
            const onError = (error: Error) => {
16✔
NEW
89
                server.removeListener('listening', onListening);
×
NEW
90
                reject(error);
×
91
            };
92
            server.once('listening', onListening);
16✔
93
            server.once('error', onError);
16✔
94
            server.listen(0, '127.0.0.1');
16✔
95
        });
96

97
        //persistent listener, distinct from the one-shot bind guard above
98
        server.on('error', (error: Error) => {
16✔
NEW
99
            this.log(`[js-debug-proxy] server error for session '${sessionId}': ${error?.message}`);
×
NEW
100
            this.stop(sessionId);
×
101
        });
102

103
        if (proxy.stopped) {
16!
104
            //`stop()` ran while we were still binding; nothing is listening on `sessionId` anymore
105
            //so close the server we just stood up rather than leaving it orphaned
NEW
106
            server.close();
×
NEW
107
            return port;
×
108
        }
109

110
        return port;
16✔
111
    }
112

113
    /**
114
     * Closes `sessionId`'s server (if any) and destroys every socket it is still tracking. No-op
115
     * for an unknown sessionId. Safe to call while the server is still binding (i.e. before
116
     * `start()`'s returned promise has resolved).
117
     */
118
    public stop(sessionId: string): void {
119
        const proxy = this.proxiesBySessionId.get(sessionId);
18✔
120
        if (!proxy) {
18✔
121
            return;
2✔
122
        }
123
        this.proxiesBySessionId.delete(sessionId);
16✔
124
        proxy.stopped = true;
16✔
125
        if (proxy.server) {
16!
126
            proxy.server.close();
16✔
127
        }
128
        for (const socket of [...proxy.sockets]) {
16✔
129
            if (!socket.destroyed) {
15✔
130
                socket.destroy();
10✔
131
            }
132
        }
133
    }
134

135
    /**
136
     * Test-only accessor for the number of sockets `sessionId`'s proxy is currently tracking, to
137
     * verify that closed connections get pruned from `sockets` rather than accumulating there.
138
     */
139
    public getSocketCountForTest(sessionId: string): number {
140
        return this.proxiesBySessionId.get(sessionId)?.sockets.size ?? 0;
2!
141
    }
142

143
    public dispose(): void {
144
        for (const sessionId of [...this.proxiesBySessionId.keys()]) {
47✔
145
            this.stop(sessionId);
13✔
146
        }
147
    }
148

149
    /**
150
     * Pure/vscode-free: decides whether (and how) to route the node debugger through this proxy.
151
     * RCE always relays (no LAN address to attach to directly). LAN relays only on win32
152
     * (js-debug's four-slash file-url bug is client-side) or when forced via the setting.
153
     * `device.host` wins over `fallbackHost` because the raw configured host may be an unresolved
154
     * placeholder.
155
     */
156
    public resolveRoute(input: {
157
        device: DeviceConfig | undefined;
158
        fallbackHost: string | undefined;
159
        platform: NodeJS.Platform;
160
        forceLanRelay: boolean;
161
    }): JsDebugRoute {
162
        if (input.device && isRceDeviceConfig(input.device)) {
12✔
163
            return {
1✔
164
                useRelay: true,
165
                device: input.device,
166
                summary: 'relay mode: rce'
167
            };
168
        }
169

170
        //isLocalDeviceConfig dereferences config.host - guard undefined
171
        const lanHost = (input.device !== undefined && isLocalDeviceConfig(input.device)) ? input.device.host : input.fallbackHost;
11✔
172
        if (lanHost) {
11✔
173
            if (input.platform === 'win32') {
10✔
174
                return {
2✔
175
                    useRelay: true,
176
                    device: { host: lanHost } as LocalDeviceConfig,
177
                    summary: 'relay mode: lan (win32)'
178
                };
179
            }
180
            if (input.forceLanRelay) {
8✔
181
                return {
1✔
182
                    useRelay: true,
183
                    device: { host: lanHost } as LocalDeviceConfig,
184
                    summary: 'relay mode: lan (forced by brightscript.debug.forceJsDebugRelay)'
185
                };
186
            }
187
            return {
7✔
188
                useRelay: false,
189
                host: lanHost,
190
                summary: 'direct attach: lan'
191
            };
192
        }
193

194
        return {
1✔
195
            useRelay: false,
196
            summary: 'direct attach: no device and no fallback host - attach will fail downstream'
197
        };
198
    }
199

200
    private async handleConnection(sessionId: string, device: DeviceConfig, client: net.Socket) {
201
        const proxy = this.proxiesBySessionId.get(sessionId);
16✔
202
        //`stop()` may have already torn this session down between the server accepting the
203
        //connection and this handler running
204
        if (!proxy) {
16!
NEW
205
            client.destroy();
×
NEW
206
            return;
×
207
        }
208
        proxy.sockets.add(client);
16✔
209

210
        let tunnel: RokuDeploySocket | undefined;
211
        let tornDown = false;
16✔
212
        const teardown = () => {
16✔
213
            if (tornDown) {
32✔
214
                return;
16✔
215
            }
216
            tornDown = true;
16✔
217
            proxy.sockets.delete(client);
16✔
218
            if (!client.destroyed) {
16✔
219
                client.destroy();
3✔
220
            }
221
            if (tunnel) {
16✔
222
                proxy.sockets.delete(tunnel);
13✔
223
                if (!tunnel.destroyed) {
13✔
224
                    tunnel.destroy();
6✔
225
                }
226
            }
227
        };
228
        //attach before the await so an early client error can't leak the connection
229
        client.on('error', teardown);
16✔
230
        client.on('close', teardown);
16✔
231

232
        //disarmed below once the filter confirms a session established
233
        client.setTimeout(this.idleWithoutSessionTimeoutMs, () => {
16✔
234
            this.log(`[js-debug-proxy] closed an idle connection for session '${sessionId}' with no active websocket session (benign for js-debug's pooled discovery sockets)`);
1✔
235
            teardown();
1✔
236
        });
237

238
        //a LAN device needs no token; fetching one unconditionally would hard-fail LAN relays while signed out of RCE
239
        let rceToken: string | undefined;
240
        if (isRceDeviceConfig(device)) {
16✔
241
            //fetched fresh per connection so a token change is picked up by the next attach attempt
242
            rceToken = await this.rceManager.getToken();
14✔
243

244
            //stop() may have run, or the client disconnected, while we were waiting on the token
245
            if (client.destroyed || this.proxiesBySessionId.get(sessionId) !== proxy) {
13✔
246
                if (!client.destroyed) {
1!
NEW
247
                    client.destroy();
×
248
                }
249
                return;
1✔
250
            }
251

252
            if (!rceToken) {
12✔
253
                this.log('[js-debug-proxy] no active Cloud Emulator account token; JS debugger cannot reach the device');
1✔
254
                client.destroy();
1✔
255
                return;
1✔
256
            }
257
        }
258

259
        const tunnelDevice: DeviceConfig = rceToken === undefined ? device : { ...device, rceToken: rceToken };
13✔
260
        tunnel = this.socketFactory({ device: tunnelDevice, port: JS_DEBUG_PORT });
13✔
261
        proxy.sockets.add(tunnel);
13✔
262
        tunnel.setTimeout(this.idleWithoutSessionTimeoutMs, () => {
13✔
NEW
263
            this.log(`[js-debug-proxy] closed an idle tunnel connection for session '${sessionId}' with no active websocket session (benign for js-debug's pooled discovery sockets)`);
×
NEW
264
            teardown();
×
265
        });
266

267
        tunnel.on('error', (error: Error) => {
13✔
268
            this.log(`[js-debug-proxy] tunnel error: ${error?.message}`);
1!
269
            teardown();
1✔
270
        });
271
        tunnel.on('close', teardown);
13✔
272

273
        //Hermes on RCE crashes the whole app on a second `Runtime.enable` on this session; the
274
        //filter drops the duplicate and answers it locally instead of forwarding it to the device
275
        const filter = new JsDebugCdpFilter(this.log, () => {
13✔
276
            //disarm the idle-without-session timer now that a session has formed
277
            client.setTimeout(0);
2✔
278
            tunnel?.setTimeout(0);
2!
279
        });
280
        filter.upstream.on('error', teardown);
13✔
281
        filter.downstream.on('error', teardown);
13✔
282

283
        client.pipe(filter.upstream).pipe(tunnel);
13✔
284
        tunnel.pipe(filter.downstream).pipe(client);
13✔
285

286
        tunnel.connect();
13✔
287
    }
288
}
289

290
// ---- types ----
291

292
interface JsDebugProxy {
293
    server: net.Server | undefined;
294
    /** Resolves to the bound port; lets a concurrent `start()` call await instead of racing a second bind. */
295
    portPromise: Promise<number>;
296
    sockets: Set<DestroyableSocket>;
297
    /** Set by `stop()`; lets a still-in-flight bind detect it should tear itself down. */
298
    stopped: boolean;
299
}
300

301
/** Minimal `net.Socket`/`RokuDeploySocket` surface `stop()` needs to tear down a connection. */
302
interface DestroyableSocket {
303
    readonly destroyed: boolean;
304
    destroy: (error?: Error) => unknown;
305
}
306

307
/** The result of `JsDebugProxyManager.resolveRoute`. */
308
interface JsDebugRoute {
309
    useRelay: boolean;
310
    device?: DeviceConfig;
311
    host?: string;
312
    summary: string;
313
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc