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

rokucommunity / roku-debug / 26120934390

19 May 2026 07:42PM UTC coverage: 70.049%. Remained the same
26120934390

push

github

web-flow
0.23.8 (#351)

Co-authored-by: rokucommunity-bot <93661887+rokucommunity-bot@users.noreply.github.com>
Co-authored-by: Bronley Plumb <bronley@gmail.com>

3328 of 5046 branches covered (65.95%)

Branch coverage included in aggregate %.

5543 of 7618 relevant lines covered (72.76%)

36.27 hits per line

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

80.37
/src/debugProtocol/server/DebugProtocolServer.ts
1
import { EventEmitter } from 'eventemitter3';
1✔
2
import * as Net from 'net';
1✔
3
import { ActionQueue } from '../../managers/ActionQueue';
1✔
4
import { Command, CommandCode } from '../Constants';
1✔
5
import type { ProtocolRequest, ProtocolResponse } from '../events/ProtocolEvent';
6
import { AddBreakpointsRequest } from '../events/requests/AddBreakpointsRequest';
1✔
7
import { AddConditionalBreakpointsRequest } from '../events/requests/AddConditionalBreakpointsRequest';
1✔
8
import { ContinueRequest } from '../events/requests/ContinueRequest';
1✔
9
import { ExecuteRequest } from '../events/requests/ExecuteRequest';
1✔
10
import { ExitChannelRequest } from '../events/requests/ExitChannelRequest';
1✔
11
import { SetExceptionBreakpointsRequest } from '../events/requests/SetExceptionBreakpointsRequest';
1✔
12
import { HandshakeRequest } from '../events/requests/HandshakeRequest';
1✔
13
import { ListBreakpointsRequest } from '../events/requests/ListBreakpointsRequest';
1✔
14
import { RemoveBreakpointsRequest } from '../events/requests/RemoveBreakpointsRequest';
1✔
15
import { StackTraceRequest } from '../events/requests/StackTraceRequest';
1✔
16
import { StepRequest } from '../events/requests/StepRequest';
1✔
17
import { StopRequest } from '../events/requests/StopRequest';
1✔
18
import { ThreadsRequest } from '../events/requests/ThreadsRequest';
1✔
19
import { VariablesRequest } from '../events/requests/VariablesRequest';
1✔
20
import { HandshakeResponse } from '../events/responses/HandshakeResponse';
1✔
21
import { HandshakeV3Response } from '../events/responses/HandshakeV3Response';
1✔
22
import PluginInterface from '../PluginInterface';
1✔
23
import type { ProtocolServerPlugin } from './DebugProtocolServerPlugin';
24
import { logger } from '../../logging';
1✔
25
import { defer, util } from '../../util';
1✔
26
import { protocolUtil } from '../ProtocolUtil';
1✔
27
import { SmartBuffer } from 'smart-buffer';
1✔
28

29
export const DEBUGGER_MAGIC = 'bsdebug';
1✔
30

31
/**
32
 * A class that emulates the way a Roku's DebugProtocol debug session/server works. This is mostly useful for unit testing,
33
 * but might eventually be helpful for an off-device emulator as well
34
 */
35
export class DebugProtocolServer {
1✔
36
    constructor(
37
        public options?: DebugProtocolServerOptions
72✔
38
    ) {
39

40
    }
41

42
    private logger = logger.createLogger(`[${DebugProtocolServer.name}]`);
72✔
43

44
    /**
45
     * Indicates whether the client has sent the magic string to kick off the debug session.
46
     */
47
    private isHandshakeComplete = false;
72✔
48

49
    private buffer = Buffer.alloc(0);
72✔
50

51
    /**
52
     * The server
53
     */
54
    private server: Net.Server;
55
    /**
56
     * Once a client connects, this is a reference to that client
57
     */
58
    private client: Net.Socket;
59

60
    /**
61
     * A collection of plugins that can interact with the server at lifecycle points
62
     */
63
    public plugins = new PluginInterface<ProtocolServerPlugin>();
72✔
64

65
    /**
66
     * A queue for processing the incoming buffer, every transmission at a time
67
     */
68
    private bufferQueue = new ActionQueue();
72✔
69

70
    public get controlPort() {
71
        return this.options.controlPort ?? this._port;
72✔
72
    }
73
    private _port: number;
74

75
    /**
76
     * Run the server. This opens a socket and listens for a connection.
77
     * The promise resolves when the server has started listening. It does NOT wait for a client to connect
78
     */
79
    public async start() {
80
        const deferred = defer();
72✔
81
        try {
72✔
82
            this.server = new Net.Server({});
72✔
83
            //Roku only allows 1 connection, so we should too.
84
            this.server.maxConnections = 1;
72✔
85

86
            //whenever a client makes a connection
87
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
88
            this.server.on('connection', async (socket: Net.Socket) => {
72✔
89
                const event = await this.plugins.emit('onClientConnected', {
61✔
90
                    server: this,
91
                    client: socket
92
                });
93
                this.client = event.client;
61✔
94

95
                //anytime we receive incoming data from the client
96
                this.client.on('data', (data) => {
61✔
97
                    //queue up processing the new data, chunk by chunk
98
                    void this.bufferQueue.run(async () => {
142✔
99
                        this.buffer = Buffer.concat([this.buffer, data]);
142✔
100
                        while (this.buffer.length > 0 && await this.process()) {
142✔
101
                            //the loop condition is the actual work
102
                        }
103
                        return true;
142✔
104
                    });
105
                });
106
                //handle connection errors
107
                this.client.on('error', (e) => {
61✔
108
                    this.logger.error(e);
×
109
                });
110
            });
111
            this._port = this.controlPort ?? await util.getPort();
72✔
112

113
            //handle connection errors
114
            this.server.on('error', (e) => {
72✔
115
                this.logger.error(e);
×
116
            });
117

118
            this.server.listen({
72✔
119
                port: this.options.controlPort ?? 8081,
216✔
120
                hostName: this.options.host ?? '0.0.0.0'
216✔
121
            }, () => {
122
                void this.plugins.emit('onServerStart', { server: this });
72✔
123
                deferred.resolve();
72✔
124
            });
125
        } catch (e) {
126
            deferred.reject(e);
×
127
        }
128
        return deferred.promise;
72✔
129
    }
130

131
    public async stop() {
132
        //close the client socket
133
        this.client?.destroy();
71✔
134

135
        //now close the server
136
        try {
71✔
137
            await new Promise<void>((resolve, reject) => {
71✔
138
                this.server.close((err) => {
71✔
139
                    if (err) {
71!
140
                        reject(err);
×
141
                    } else {
142
                        resolve();
71✔
143
                    }
144
                });
145
            });
146
        } finally {
147
            this.client?.removeAllListeners();
71✔
148
            delete this.client;
71✔
149
            this.server?.removeAllListeners();
71!
150
            delete this.server;
71✔
151
        }
152
    }
153

154
    public async destroy() {
155
        await this.stop();
54✔
156
    }
157

158
    /**
159
     * Given a buffer, find the request that matches it
160
     */
161
    public static getRequest(buffer: Buffer, allowHandshake: boolean): ProtocolRequest {
162
        //when enabled, look at the start of the buffer for the exact DEBUGGER_MAGIC text. This is a boolean because
163
        //there could be cases where binary data looks similar to this structure, so the caller must opt-in to this logic
164
        if (allowHandshake && buffer.length >= 8 && protocolUtil.readStringNT(SmartBuffer.fromBuffer(buffer)) === DEBUGGER_MAGIC) {
81!
165
            return HandshakeRequest.fromBuffer(buffer);
×
166
        }
167
        // if we don't have enough buffer data, skip this
168
        if (buffer.length < 12) {
81!
169
            return;
×
170
        }
171
        //the client may only send commands to the server, so extract the command type from the known byte position
172
        const command = CommandCode[buffer.readUInt32LE(8)] as Command; // command_code
81✔
173
        switch (command) {
81!
174
            case Command.AddBreakpoints:
175
                return AddBreakpointsRequest.fromBuffer(buffer);
9✔
176
            case Command.Stop:
177
                return StopRequest.fromBuffer(buffer);
1✔
178
            case Command.Continue:
179
                return ContinueRequest.fromBuffer(buffer);
1✔
180
            case Command.Threads:
181
                return ThreadsRequest.fromBuffer(buffer);
15✔
182
            case Command.StackTrace:
183
                return StackTraceRequest.fromBuffer(buffer);
15✔
184
            case Command.Variables:
185
                return VariablesRequest.fromBuffer(buffer);
22✔
186
            case Command.Step:
187
                return StepRequest.fromBuffer(buffer);
7✔
188
            case Command.ListBreakpoints:
189
                return ListBreakpointsRequest.fromBuffer(buffer);
3✔
190
            case Command.RemoveBreakpoints:
191
                return RemoveBreakpointsRequest.fromBuffer(buffer);
4✔
192
            case Command.Execute:
193
                return ExecuteRequest.fromBuffer(buffer);
1✔
194
            case Command.AddConditionalBreakpoints:
195
                return AddConditionalBreakpointsRequest.fromBuffer(buffer);
2✔
196
            case Command.ExitChannel:
197
                return ExitChannelRequest.fromBuffer(buffer);
1✔
198
            case Command.SetExceptionBreakpoints:
199
                return SetExceptionBreakpointsRequest.fromBuffer(buffer);
×
200
        }
201
    }
202

203
    private getResponse(request: ProtocolRequest) {
204
        if (request instanceof HandshakeRequest) {
60!
205
            return HandshakeV3Response.fromJson({
60✔
206
                magic: this.magic,
207
                protocolVersion: '3.1.0',
208
                //TODO update this to an actual date from the device
209
                revisionTimestamp: new Date(2022, 1, 1)
210
            });
211
        }
212
    }
213

214
    /**
215
     * Process a single request.
216
     * @returns true if successfully processed a request, and false if not
217
     */
218
    private async process() {
219
        try {
142✔
220
            this.logger.log('process() start', { buffer: this.buffer.toJSON() });
142✔
221

222
            //at this point, there is an active debug session. The plugin must provide us all the real-world data
223
            let { buffer, request } = await this.plugins.emit('provideRequest', {
142✔
224
                server: this,
225
                buffer: this.buffer,
226
                request: undefined
227
            });
228

229
            //we must build the request if the plugin didn't supply one (most plugins won't provide a request...)
230
            if (!request) {
142!
231
                //if we haven't seen the handshake yet, look for the handshake first
232
                if (!this.isHandshakeComplete) {
142✔
233
                    request = HandshakeRequest.fromBuffer(buffer);
61✔
234
                } else {
235
                    request = DebugProtocolServer.getRequest(buffer, false);
81✔
236
                }
237
            }
238

239
            //if we couldn't construct a request this request, hard-fail
240
            if (!request || !request.success) {
142!
241
                this.logger.error('process() invalid request', { request });
×
242
                throw new Error(`Unable to parse request: ${JSON.stringify(this.buffer.toJSON().data)}`);
×
243
            }
244

245
            this.logger.log('process() constructed request', { request });
142✔
246

247
            //trim the buffer now that the request has been processed
248
            this.buffer = buffer.slice(request.readOffset);
142✔
249

250
            this.logger.log('process() buffer sliced', { buffer: this.buffer.toJSON() });
142✔
251

252
            //now ask the plugin to provide a response for the given request
253
            let { response } = await this.plugins.emit('provideResponse', {
142✔
254
                server: this,
255
                request: request,
256
                response: undefined
257
            });
258

259

260
            //if the plugin didn't provide a response, we need to try our best to make one (we only support a few...plugins should provide most of them)
261
            if (!response) {
142✔
262
                response = this.getResponse(request);
60✔
263
            }
264

265
            if (!response) {
142!
266
                this.logger.error('process() invalid response', { request, response });
×
267
                throw new Error(`Server was unable to provide a response for ${JSON.stringify(request.data)}`);
×
268
            }
269

270

271
            //if this is part of the handshake flow, the client should have sent a magic string to kick off the debugger. If it matches, set `isHandshakeComplete = true`
272
            if ((response instanceof HandshakeResponse || response instanceof HandshakeV3Response) && response.data.magic === this.magic) {
142✔
273
                this.isHandshakeComplete = true;
61✔
274
            }
275

276
            //send the response to the client. (TODO handle when the response is missing)
277
            await this.sendResponse(response);
142✔
278
            return true;
142✔
279
        } catch (e) {
280
            this.logger.error('process() error', e);
×
281
        }
282
        return false;
×
283
    }
284

285
    /**
286
     * Send a response from the server to the client. This involves writing the response buffer to the client socket
287
     */
288
    private async sendResponse(response: ProtocolResponse) {
289
        const event = await this.plugins.emit('beforeSendResponse', {
206✔
290
            server: this,
291
            response: response
292
        });
293

294
        this.logger.log('sendResponse()', { response });
206✔
295
        this.client.write(event.response.toBuffer());
206✔
296

297
        await this.plugins.emit('afterSendResponse', {
206✔
298
            server: this,
299
            response: event.response
300
        });
301
        return event.response;
206✔
302
    }
303

304
    /**
305
     * Send an update from the server to the client. This can be things like ALL_THREADS_STOPPED
306
     */
307
    public sendUpdate(update: ProtocolResponse) {
308
        return this.sendResponse(update);
64✔
309
    }
310

311
    /**
312
     * An event emitter used for all of the events this server emitts
313
     */
314
    private emitter = new EventEmitter();
72✔
315

316
    public on<T = { response: Response }>(eventName: 'before-send-response', callback: (event: T) => void);
317
    public on<T = { response: Response }>(eventName: 'after-send-response', callback: (event: T) => void);
318
    public on<T = { client: Net.Socket }>(eventName: 'client-connected', callback: (event: T) => void);
319
    public on<T = any>(eventName: string, callback: (data: T) => void);
320
    public on<T = any>(eventName: string, callback: (data: T) => void) {
321
        this.emitter.on(eventName, callback);
×
322
        return () => {
×
323
            this.emitter?.removeListener(eventName, callback);
×
324
        };
325
    }
326

327
    /**
328
     * Subscribe to an event exactly one time. This will fire the very next time an event happens,
329
     * and then immediately unsubscribe
330
     */
331
    public once<T>(eventName: string): Promise<T> {
332
        return new Promise<T>((resolve) => {
×
333
            const off = this.on<T>(eventName, (event) => {
×
334
                off();
×
335
                resolve(event);
×
336
            });
337
        });
338
    }
339

340
    public emit<T = { response: Response }>(eventName: 'before-send-response', event: T): T;
341
    public emit<T = { response: Response }>(eventName: 'after-send-response', event: T): T;
342
    public emit<T = { client: Net.Socket }>(eventName: 'client-connected', event: T): T;
343
    public emit<T>(eventName: string, event: any): T {
344
        this.emitter?.emit(eventName, event);
×
345
        return event;
×
346
    }
347

348
    /**
349
     * The magic string used to kick off the debug session.
350
     * @default "bsdebug"
351
     */
352
    private get magic() {
353
        return this.options.magic ?? DEBUGGER_MAGIC;
121!
354
    }
355
}
356

357
export interface DebugProtocolServerOptions {
358
    /**
359
     * The magic that is sent as part of the handshake
360
     */
361
    magic?: string;
362
    /**
363
     * The port to use for the primary communication between this server and a client
364
     */
365
    controlPort?: number;
366
    /**
367
     * A specific host to listen on. If not specified, all hosts are used
368
     */
369
    host?: string;
370
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc