This guide explains how to use the brs-node library in your Node.js applications and testing environments. The library provides programmatic access to the BrightScript Simulation Engine, allowing you to execute BrightScript code, run Roku applications, and test your BrightScript implementations.
- Using the brs-node Library
- Table of Contents
- Installation
- Package Libraries
- Using in Node.js Applications
- Using in Tests
- API Reference
- Core Functions
registerCallback(callback, sharedBuffer?)createPayloadFromFiles(files, device, deepLink?, root?, ext?)createPayloadFromFileMap(fileMap, device, deepLink?)executeFile(payload, options?)executeApp(payload, options?)subscribeHost(observerId, callback)/unsubscribeHost(observerId)terminateApp(reason?, timeoutMs?)getReplInterpreter(options)executeLine(line, interpreter)
- File System API
- Core Functions
- Additional Resources
- Support
Install the package via npm:
npm install brs-nodeOr using yarn:
yarn add brs-nodeThe package ships four bundles under bin/ (Node.js v22+ required):
| Library File | Role |
|---|---|
brs.node.js |
The engine library — the package main (require("brs-node")). Also serves as the worker entry when apps run on worker threads. |
brs-sg.node.js |
The SceneGraph extension bundle, loaded dynamically into the engine when SceneGraph support is needed. |
brs.cli.js |
The brs-cli executable — a consumer of the two libraries above. |
brs.ecp.js |
Worker-thread library for the ECP/SSDP servers, used by the CLI with --ecp. |
brs-node offers two ways to execute an app — pick per use case:
executeFile (synchronous) |
executeApp (worker threads) |
|
|---|---|---|
| Runs on | the calling thread | a dedicated worker thread |
SceneGraph UI (roSGScreen, nodes) |
✅ supported | ✅ supported |
SceneGraph Task nodes |
❌ never spawn (no free host thread to broker them) | ✅ one worker thread per running Task |
| Output delivery | registerCallback (synchronous calls) |
subscribeHost events (asynchronous) |
| Control input | shared buffer via registerCallback |
shared buffer via options.sharedBuffer |
Packaging (.bpk generation) |
✅ returns cipherText |
❌ not supported |
| Best for | tests, CI, scripting, packaging | running full apps, anything using Tasks |
Both models accept the same payloads (createPayloadFromFiles / createPayloadFromFileMap) and simulate the same device. The browser package (brs-engine) only has the worker model; executeFile is unique to Node, kept for its simplicity and synchronous determinism in test suites.
The interpreter core is SceneGraph-agnostic: roSGScreen, roSGNode and the node types are provided by the separate brs-sg.node.js bundle, loaded through the public extension API (the same contract available to third-party extensions — see extensions.md). What you must know as a consumer:
-
It binds to the running engine at load time. Its internal
brs-engineimport resolves to the already-loadedbrs.node.jsmodule instance, so the two bundles must come from the same installed package version — never mix abrs-sg.node.jsfrom one version with abrs.node.jsfrom another. -
How to enable it depends on the execution model:
-
executeFile: register it in-process before executing:const brs = require("brs-node"); const sg = require("brs-node/bin/brs-sg.node.js"); brs.registerExtension(() => new sg.BrightScriptExtension());
-
executeApp: declare it on the payload; each worker thread is a fresh isolate and loads its own instance from the path indevice.extensions(relative paths resolve against thebin/folder):payload.extensions = [brs.SupportedExtension.SceneGraph]; payload.device.extensions = new Map([[brs.SupportedExtension.SceneGraph, "brs-sg.node.js"]]);
-
-
If you bundle your application (webpack/esbuild), keep
brs-nodeexternal (on disk): the worker entry and the extension are resolved by file path at runtime and cannot live inside your bundle. TheworkerEntryoption ofexecuteAppexists for custom layouts.
Import the library in your Node.js application:
const brs = require("brs-node");Or using ES6 imports (TypeScript):
import * as brs from "brs-node";The library provides several functions to execute BrightScript code. The main workflow involves:
- Creating a payload from your BrightScript files
- Registering a callback to handle output and events
- Executing the payload
const brs = require("brs-node");
const fs = require("fs");
const path = require("path");
// Register a callback to handle interpreter messages
brs.registerCallback((message, data) => {
if (typeof message === "string") {
const [messageType, content] = message.split(",", 2);
switch (messageType) {
case "print":
console.log(content);
break;
case "warning":
console.warn(content);
break;
case "error":
console.error(content);
break;
case "end":
console.log(`Execution finished: ${content}`);
break;
}
} else if (message instanceof Map) {
// Registry updates
console.log("Registry updated:", message);
}
});
// Define device configuration
const deviceData = {
developerId: "34c6fceca75e456f25e7e99531e2425c6c1de443",
friendlyName: "BrightScript Test Device",
deviceModel: "8000X",
clientId: "6c5bf3a5-b2a5-4918-824d-7691d5c85364",
RIDA: "f51ac698-bc60-4409-aae3-8fc3abc025c4",
countryCode: "US",
timeZone: "US/Eastern",
locale: "en_US",
clockFormat: "12h",
displayMode: "1080p",
customFeatures: [],
localIps: ["192.168.1.100"],
};
// Create payload from BrightScript files
const files = [
path.join(__dirname, "main.brs"),
path.join(__dirname, "lib", "utils.brs")
];
const payload = await brs.createPayloadFromFiles(
files,
deviceData,
new Map(), // deepLink parameters (optional)
"/path/to/pkg/root", // root directory for pkg:/ (optional)
"/path/to/ext/root" // root directory for ext1:/ (optional)
);
// Execute the payload
(async () => {
try {
const result = await brs.executeFile(payload);
console.log(`Exit reason: ${result.exitReason}`);
// Handle encrypted package generation if password was provided
if (result.exitReason === "PACKAGED") {
console.log("Package encrypted successfully");
// Save the encrypted package
const encryptedData = new Uint8Array(result.cipherText);
fs.writeFileSync("app.bpk", encryptedData);
}
} catch (error) {
console.error("Execution failed:", error);
}
})();You can also create a payload from in-memory file content using createPayloadFromFileMap. This is useful when working with uploaded files, network resources, or dynamically generated content:
const brs = require("brs-node");
// Register callback (same as above)
brs.registerCallback((message) => {
// Handle messages...
});
// Create file map with Blob content
const fileMap = new Map();
// Add BrightScript source files
const mainBrsCode = `
sub Main()
print "Hello from in-memory BrightScript!"
print "Device model: "; CreateObject("roDeviceInfo").GetModel()
end sub
`;
fileMap.set("main.brs", new Blob([mainBrsCode], { type: "text/plain" }));
// Add manifest file
const manifestContent = `
title=My In-Memory App
major_version=1
minor_version=0
build_version=1
`;
fileMap.set("manifest", new Blob([manifestContent], { type: "text/plain" }));
// Add additional library file
const libCode = `
function GetAppVersion() as string
return "1.0.1"
end function
`;
fileMap.set("lib/utils.brs", new Blob([libCode], { type: "text/plain" }));
// Execute the in-memory files
(async () => {
try {
// Create payload from file map
const payload = await brs.createPayloadFromFileMap(fileMap, deviceData);
// Execute the payload
const result = await brs.executeFile(payload);
console.log(`Exit reason: ${result.exitReason}`);
} catch (error) {
console.error("Execution failed:", error);
}
})();For SceneGraph applications, organize files in the proper folder structure:
const fileMap = new Map();
// Manifest for SceneGraph app
const manifest = `
title=My SceneGraph App
major_version=1
minor_version=0
ui_resolutions=hd
splash_min_time=0
`;
fileMap.set("manifest", new Blob([manifest], { type: "text/plain" }));
// Main application source (executed)
const mainCode = `
sub Main()
print "SceneGraph app starting..."
screen = CreateObject("roSGScreen")
m.port = CreateObject("roMessagePort")
screen.setMessagePort(m.port)
scene = screen.CreateScene("MainScene")
screen.show()
print "Scene created and displayed"
' Event loop
while true
msg = wait(1000, m.port)
if msg <> invalid
if msg.isScreenClosed()
exit while
end if
else
exit while ' Timeout for demo
end if
end while
end sub
`;
fileMap.set("source/main.brs", new Blob([mainCode], { type: "text/plain" }));
// Scene component XML (packaged, not executed)
const sceneXml = `<?xml version="1.0" encoding="utf-8" ?>
<component name="MainScene" extends="Scene">
<children>
<Label id="titleLabel"
text="Hello SceneGraph!"
translation="[960, 540]"
horizAlign="center"
font="font:LargeSystemFont" />
</children>
<script type="text/brightscript" uri="MainScene.brs" />
</component>`;
fileMap.set("components/MainScene.xml", new Blob([sceneXml], { type: "text/xml" }));
// Scene component script (packaged, not executed)
const sceneBrs = `
function init()
print "MainScene component initialized"
m.titleLabel = m.top.findNode("titleLabel")
if m.titleLabel <> invalid
m.titleLabel.text = "Hello from Component!"
end if
end function
`;
fileMap.set("components/MainScene.brs", new Blob([sceneBrs], { type: "text/plain" }));
// Register the SceneGraph extension (required for roSGScreen/roSGNode — see
// "The SceneGraph Extension Bundle" above)
const sg = require("brs-node/bin/brs-sg.node.js");
brs.registerExtension(() => new sg.BrightScriptExtension());
// Execute SceneGraph app
(async () => {
const payload = await brs.createPayloadFromFileMap(fileMap, deviceData);
const result = await brs.executeFile(payload);
// payload.pkgZip contains the complete app package
})();Note: with the synchronous
executeFile, SceneGraphTasknodes never run (settingcontrol = "run"is a no-op). If the app depends on Tasks, run it withexecuteApp(next section) instead.
The synchronous executeFile runs the interpreter on the calling thread — ideal for tests and
scripted execution, but SceneGraph Task nodes never spawn (they need a free host thread to
broker cross-thread rendezvous). Use executeApp to run an app on a dedicated
worker_threads thread (the render thread) with full Task support: each running Task node
gets its own worker thread, mirroring the browser engine and a real Roku device.
const brs = require("brs-node");
// Subscribe to host events (replaces registerCallback for the worker path)
brs.subscribeHost("my-app", (event, data) => {
if (event === "message" && typeof data === "string") {
// Same "type,content" strings the sync callback receives: print, error, end, ...
const [type] = data.split(",", 1);
if (type === "print") process.stdout.write(data.slice(6));
} else if (event === "frame") {
// A node-canvas ImageData with the latest rendered frame
} else if (event === "registry") {
// RegistryData to persist ({ current: Map, ... })
}
});
(async () => {
const payload = await brs.createPayloadFromFiles([], deviceData, undefined, "/path/to/app-root");
// Enable the SceneGraph extension in the worker threads:
payload.extensions = [brs.SupportedExtension.SceneGraph];
payload.device.extensions = new Map([[brs.SupportedExtension.SceneGraph, "brs-sg.node.js"]]);
const result = await brs.executeApp(payload);
console.log(`Exit reason: ${result.exitReason}`);
})();executeApp(payload, options?) accepts:
options.sharedBuffer— a controlSharedArrayBufferyou own (keys, sounds, debug commands). Write remote-control keys into it withAtomics(see remote-control.md) to control the running app; when omitted, the host creates one internally.options.workerEntry— absolute path to the engine bundle used as the worker entry. Defaults to the installedbrs.node.js; set it if you relocate or bundle the library.
Use terminateApp(reason?, timeoutMs?) to request a graceful exit of the running app. Only one
app can run on the host at a time. Packaging (.bpk generation) is not supported through
executeApp — use the synchronous executeFile for that.
Host events: message (engine strings), frame (ImageData), registry (RegistryData),
graphics (texture-memory data), launch (roAppManager launch requests), ndkStart,
component (display/caption state objects), stdout/stderr (console output written directly
inside a worker thread — the workers' streams are piped to the host, never to the terminal),
error/warning/debug (host diagnostics).
For interactive BrightScript execution or building a custom REPL:
const brs = require("brs-node");
(async () => {
// Get REPL interpreter instance
const replInterpreter = brs.getReplInterpreter({
device: deviceData,
root: "/path/to/pkg/root", // optional
ext: "/path/to/ext/root", // optional
extZip: undefined // optional ArrayBuffer with zip data
});
if (!replInterpreter) {
console.error("Could not create the REPL interpreter");
return;
}
// Execute single line
brs.executeLine("print \"Hello, World!\"", replInterpreter);
// Execute expression
brs.executeLine("? 2 + 2", replInterpreter);
// Get variable information
const globalVars = replInterpreter.formatVariables(0); // 0=global, 1=module, 2=function
console.log("Global variables:", globalVars);
// Access interpreter options
console.log("Root path:", replInterpreter.options.root);
})();Applies to the synchronous model (
executeFile/REPL). WithexecuteApp, output is delivered throughsubscribeHostevents instead — see the previous section.
The callback function receives all output and events from the interpreter:
brs.registerCallback((message, data) => {
if (typeof message === "string") {
// Parse message type and content
const parts = message.split(",");
const messageType = parts[0];
const content = parts.slice(1).join(",");
switch (messageType) {
case "print":
// Standard output
process.stdout.write(content);
break;
case "warning":
// Warning messages
console.warn(content);
break;
case "error":
// Error messages
console.error(content);
break;
case "start":
// Execution started
console.log("Execution started");
break;
case "end":
// Execution finished with reason
console.log(`Finished: ${content}`);
break;
case "debug":
// Debug events
console.log("Debug:", content);
break;
case "syslog":
// System log messages
console.log("SysLog:", content);
break;
}
} else if (message instanceof ImageData) {
// Screen buffer update (for ASCII rendering or canvas)
console.log(`Screen updated: ${message.width}x${message.height}`);
} else if (message instanceof Map) {
// Registry updates
console.log("Registry updated with", message.size, "entries");
}
}, sharedBuffer); // Optional SharedArrayBuffer for inter-thread communicationFor advanced use cases with worker threads (like the ECP server):
const { Worker } = require("worker_threads");
// Create shared buffer for communication
// Always derive the size from the exported constants, never hardcode it
const length = brs.DataBufferIndex + brs.DataBufferSize;
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * length);
const sharedArray = new Int32Array(sharedBuffer);
sharedArray.fill(-1);
// Register callback with shared buffer
brs.registerCallback(messageCallback, sharedBuffer);
// Use with worker threads
const worker = new Worker("./ecp-worker.js");
worker.postMessage(sharedBuffer);The brs-node library is excellent for testing BrightScript code. Here are common patterns used in the project's test suite.
Install Vitest and set up your test environment:
npm install --save-dev vitestCreate a helper module for end-to-end tests:
// test/helpers/E2EHelpers.js
const path = require("path");
const stream = require("stream");
const brs = require("brs-node");
brs.registerCallback(() => {}); // Suppress output
const deviceData = {
developerId: "34c6fceca75e456f25e7e99531e2425c6c1de443",
friendlyName: "Test Device",
deviceModel: "8000X",
firmwareVersion: "48G.04E05531A",
clientId: "test-client-id",
RIDA: "test-rida",
countryCode: "US",
timeZone: "US/Eastern",
locale: "en_US",
clockFormat: "12h",
displayMode: "1080p",
audioCodecs: ["mp3", "wav", "aac"],
videoFormats: new Map([
["codecs", ["mpeg4 avc", "vp9"]],
["containers", ["mp4", "mkv"]]
]),
customFeatures: [],
localIps: ["192.168.1.100"]
};
function resourceFile(...filenameParts) {
return path.join("test", "resources", ...filenameParts);
}
function createMockStreams() {
const stdout = Object.assign(new stream.PassThrough(), process.stdout);
const stderr = Object.assign(new stream.PassThrough(), process.stderr);
return {
stdout,
stderr,
stdoutSpy: vi.spyOn(stdout, "write").mockImplementation(() => {}),
stderrSpy: vi.spyOn(stderr, "write").mockImplementation(() => {})
};
}
async function execute(filenames, options = {}, deepLink) {
// Reset file system for clean test
brs.BrsDevice.fileSystem.resetMemoryFS();
const payload = await brs.createPayloadFromFiles(filenames, deviceData);
if (deepLink) {
payload.deepLink = deepLink;
}
await brs.executeFile(payload, options);
}
function allArgs(mockFn) {
return mockFn.mock.calls
.reduce((allArgs, thisCall) => allArgs.concat(thisCall), []);
}
module.exports = {
deviceData,
resourceFile,
createMockStreams,
execute,
allArgs
};Use the helpers in your tests:
const { execute, createMockStreams, resourceFile, allArgs } = require("./helpers/E2EHelpers");
describe("BrightScript Components", () => {
let outputStreams;
beforeAll(() => {
outputStreams = createMockStreams();
});
afterEach(() => {
// clearAllMocks(), not resetAllMocks(): a reset restores the spy's original
// implementation, which reopens writes onto the shared (already-closed) PassThrough.
vi.clearAllMocks();
});
test("roArray operations", async () => {
await execute([resourceFile("components", "roArray.brs")], outputStreams);
expect(allArgs(outputStreams.stdout.write).map(arg => arg.trimEnd())).toEqual([
"array length: 4",
"last element: sit",
"first element: lorem",
"can delete elements: true"
]);
});
test("roAssociativeArray operations", async () => {
await execute([resourceFile("components", "roAssociativeArray.brs")], outputStreams);
const output = allArgs(outputStreams.stdout.write);
expect(output).toContain("AA size: 3");
expect(output).toContain("can delete elements: true");
});
});beforeEach(() => {
brs.BrsDevice.fileSystem.resetMemoryFS();
});const fakeTimer = require("@sinonjs/fake-timers");
let clock;
beforeEach(() => {
clock = fakeTimer.install({
now: 1547072370937,
toFake: ["Date", "performance"]
});
});
afterEach(() => {
clock.uninstall();
});test("handles deep link parameters", async () => {
const deepLink = new Map([
["contentId", "12345"],
["mediaType", "movie"]
]);
await execute([resourceFile("deeplink.brs")], {}, deepLink);
// Assert expected behavior
});Registers a callback function to receive interpreter messages and events in the
synchronous model (executeFile/REPL). Not used by executeApp — subscribe with
subscribeHost instead.
Parameters:
callback: (message: any, data?: any) => void- Function to handle messagessharedBuffer?: SharedArrayBuffer- Optional shared buffer for control input (keys, debug commands)
Example:
brs.registerCallback((message) => {
console.log("Received:", message);
});Creates an execution payload from BrightScript files.
Parameters:
files: string[]- Array of file paths to executedevice: DeviceInfo- Device configuration objectdeepLink?: Map<string, string>- Deep link parametersroot?: string- Root directory forpkg:/volumeext?: string- Root directory forext1:/volume
Returns: Promise<AppPayload>
Creates an execution payload from a map of file paths and Blob content. This function automatically creates a ZIP package (in memory) containing all files and properly handles SceneGraph app structures.
Parameters:
fileMap: Map<string, Blob>- Map with file paths as keys and Blob content as valuesdevice: DeviceInfo- Device configuration objectdeepLink?: Map<string, string>- Deep link parameters
Returns: Promise<AppPayload> - Payload with pkgZip containing all files
File Handling Rules:
- Files without folder: Placed in
source/folder and executed as main source code - Files in
source/folder: Executed as main source code (including subfolders) - Files in other folders (e.g.,
components/,images/): Packaged in ZIP but not executed as source
SceneGraph Support:
- Component XML and BrightScript files in
components/folder are packaged for SceneGraph runtime - Only
source/folder BrightScript files are executed as main application code - Maintains proper separation between main source and component files
Examples:
Basic usage:
const fileMap = new Map();
fileMap.set("main.brs", new Blob([brightScriptCode], { type: "text/plain" }));
fileMap.set("manifest", new Blob([manifestContent], { type: "text/plain" }));
const payload = await brs.createPayloadFromFileMap(fileMap, deviceData);
const result = await brs.executeFile(payload);SceneGraph app structure:
const fileMap = new Map();
// Main application (executed)
fileMap.set("source/main.brs", new Blob([mainAppCode], { type: "text/plain" }));
// SceneGraph components (packaged, not executed directly)
fileMap.set("components/MyScene.xml", new Blob([sceneXmlCode], { type: "text/xml" }));
fileMap.set("components/MyScene.brs", new Blob([sceneBrsCode], { type: "text/plain" }));
// Assets (packaged as-is)
fileMap.set("images/icon.png", new Blob([iconData], { type: "image/png" }));
fileMap.set("manifest", new Blob([manifestContent], { type: "text/plain" }));
const payload = await brs.createPayloadFromFileMap(fileMap, deviceData);
// payload.pkgZip contains the complete app packageExecutes a BrightScript application payload synchronously on the calling thread.
SceneGraph Task nodes do not spawn in this mode (use executeApp for Task support).
This is also the only mode that supports packaging (cipherText in the result).
Parameters:
payload: AppPayload- Application payload to executeoptions?: object- Execution optionsuseMainEnv?: boolean- Run in the main environment instead of a fresh one; used by the CLI packaging path (--pack)
Returns: Promise<{ exitReason: string, cipherText?: ArrayBuffer, iv?: Uint8Array }>
Executes a BrightScript application on a dedicated worker thread, with SceneGraph Task
support (one worker thread per running Task). The promise resolves when the app finishes.
Output and events are delivered to subscribeHost observers. Only one app can run at a time;
packaging is not supported in this mode (use executeFile).
Parameters:
payload: AppPayload- Application payload to execute (setpayload.extensionsandpayload.device.extensionsto enable SceneGraph in the workers)options?: { sharedBuffer?: SharedArrayBuffer, workerEntry?: string }- Optional control buffer (for remote-control/debug input viaAtomics) and worker entry path override
Returns: Promise<{ exitReason: string }>
Subscribes to (or removes a subscription from) events emitted by the worker host while an app
runs through executeApp.
Callback: (event: string, data?: any) => void, with events:
message- engine strings in"type,content"format (print,error,end, ...)frame- a node-canvasImageDatawith the latest rendered frameregistry-RegistryDatato persist ({ current: Map, ... })graphics- texture-memory debug datalaunch/ndkStart- app-launch requestscomponent- display/caption state objectsstdout/stderr- console output written directly inside a worker thread (piped to the host)error/warning/debug- host diagnostics
Requests a graceful exit of the app running through executeApp (same as the ECP exit-app
command), force-terminating the app worker and all Task workers if it does not finish within
the timeout (default 3000 ms). The optional reason (an AppExitReason, default
EXIT_USER_NAV) is reported on the end event, mirroring the browser API's
terminate(reason) — e.g. the CLI's Home key uses the default to report a user-initiated exit.
Creates a REPL interpreter instance for interactive execution.
Parameters:
options: { device: DeviceInfo, root?: string, ext?: string, extZip?: ArrayBuffer }
Returns: Interpreter | null - this function is synchronous; it returns null when the configuration is invalid (e.g. missing device.assets), so always null-check the result.
Executes a single line of BrightScript code in the REPL interpreter.
Parameters:
line: string- BrightScript code to executeinterpreter: ReplInterpreter- REPL interpreter instance
Resets the in-memory file system to a clean state.
- GitHub Issues: https://github.com/lvcabral/brs-engine/issues
- Slack: RokuCommunity