Created
May 12, 2026 13:44
-
-
Save TwitchBronBron/0f16aa1e1a8033c18dd87be45c84802e to your computer and use it in GitHub Desktop.
Benchmarking compile times for CDATA vs codbehind for Roku Projects
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import net from 'node:net'; | |
| import { rokuDeploy } from 'roku-deploy'; | |
| import { undent } from 'undent'; | |
| const SCENARIOS = [ | |
| { folder: 'cdata', title: 'CDATA Bench', inline: true }, | |
| { folder: 'codebehind', title: 'Codebehind Bench', inline: false }, | |
| ]; | |
| const DEFAULT_COMPONENT_COUNT = 100; | |
| const TELNET_PORT = 8085; | |
| const SETTLE_MS = 500; | |
| const SETTLE_MAX_MS = 30_000; | |
| const RUN_TIMEOUT_MS = 120_000; | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| const opts = { | |
| host: null, | |
| password: null, | |
| runs: 5, | |
| components: DEFAULT_COMPONENT_COUNT, | |
| }; | |
| for (let i = 0; i < args.length; i++) { | |
| const a = args[i]; | |
| if (a === '--host') opts.host = args[++i]; | |
| else if (a === '--password') opts.password = args[++i]; | |
| else if (a === '--runs') opts.runs = parseInt(args[++i], 10); | |
| else if (a === '--components') opts.components = parseInt(args[++i], 10); | |
| else if (a === '-h' || a === '--help') { | |
| printHelp(); | |
| process.exit(0); | |
| } else { | |
| console.error(`Unknown arg: ${a}`); | |
| printHelp(); | |
| process.exit(1); | |
| } | |
| } | |
| if (!opts.host) { | |
| console.error('Missing required --host'); | |
| printHelp(); | |
| process.exit(1); | |
| } | |
| if (!opts.password) { | |
| console.error('Missing required --password'); | |
| printHelp(); | |
| process.exit(1); | |
| } | |
| if (!Number.isFinite(opts.runs) || opts.runs < 1) { | |
| console.error('--runs must be a positive integer'); | |
| process.exit(1); | |
| } | |
| if (!Number.isFinite(opts.components) || opts.components < 1) { | |
| console.error('--components must be a positive integer'); | |
| process.exit(1); | |
| } | |
| return opts; | |
| } | |
| function printHelp() { | |
| console.log(undent` | |
| Usage: node bench.js --host <ip> --password <pwd> [--runs <n>] [--components <n>] | |
| --host <ip> Roku device IP (required) | |
| --password <pwd> Developer password (required) | |
| --runs <n> Runs per scenario (default: 5) | |
| --components <n> Components to generate per scenario (default: ${DEFAULT_COMPONENT_COUNT}) | |
| `); | |
| } | |
| function regenerateScenarios(componentCount) { | |
| for (const s of SCENARIOS) { | |
| fs.rmSync(path.resolve(s.folder), { recursive: true, force: true }); | |
| generateScenario(s, componentCount); | |
| } | |
| } | |
| function generateScenario({ folder, title, inline }, componentCount) { | |
| const root = path.resolve(folder); | |
| fs.mkdirSync(path.join(root, 'source'), { recursive: true }); | |
| fs.mkdirSync(path.join(root, 'components', 'scene'), { recursive: true }); | |
| fs.mkdirSync(path.join(root, 'components', 'generated'), { recursive: true }); | |
| fs.writeFileSync(path.join(root, 'manifest'), undent` | |
| title=${title} | |
| major_version=1 | |
| minor_version=0 | |
| build_version=0 | |
| splash_color=#808080 | |
| splash_min_time=0 | |
| ui_resolutions=fhd | |
| `); | |
| fs.writeFileSync(path.join(root, 'source', 'main.brs'), undent` | |
| sub Main(_inputArguments as object) | |
| dt = createObject("roDateTime") | |
| print "BENCH_MAIN_START_MS="; (dt.asSecondsLong() * 1000) + dt.getMilliseconds() | |
| screen = createObject("roSGScreen") | |
| m.port = createObject("roMessagePort") | |
| screen.setMessagePort(m.port) | |
| scene = screen.CreateScene("MainScene") | |
| screen.show() | |
| scene.observeField("appExit", m.port) | |
| scene.setFocus(true) | |
| while true | |
| msg = wait(0, m.port) | |
| msgType = type(msg) | |
| if msgType = "roSGScreenEvent" then | |
| if msg.isScreenClosed() then return | |
| else if msgType = "roSGNodeEvent" then | |
| if msg.getField() = "appExit" then return | |
| end if | |
| end while | |
| end sub | |
| `); | |
| fs.writeFileSync(path.join(root, 'components', 'scene', 'MainScene.brs'), undent` | |
| sub init() | |
| end sub | |
| `); | |
| const childTags = []; | |
| for (let i = 1; i <= componentCount; i++) { | |
| childTags.push(`<Component${String(i).padStart(3, '0')}/>`); | |
| } | |
| childTags.push('<FinalLogger/>'); | |
| const childrenBlock = childTags.map((t) => ` ${t}`).join('\n'); | |
| fs.writeFileSync(path.join(root, 'components', 'scene', 'MainScene.xml'), undent` | |
| <?xml version="1.0" encoding="utf-8" ?> | |
| <component name="MainScene" extends="Scene"> | |
| <script type="text/brightscript" uri="MainScene.brs" /> | |
| <interface> | |
| <field id="appExit" type="bool" alwaysnotify="true" value="false"/> | |
| </interface> | |
| <children> | |
| ${childrenBlock} | |
| </children> | |
| </component> | |
| `); | |
| for (let i = 1; i <= componentCount; i++) { | |
| const name = `Component${String(i).padStart(3, '0')}`; | |
| writeComponent(root, name, dummyBrs(name), inline); | |
| } | |
| writeComponent(root, 'FinalLogger', finalLoggerBrs(), inline); | |
| } | |
| function dummyBrs(name) { | |
| return undent` | |
| sub init() | |
| end sub | |
| function ${name}_funcA() as integer | |
| return 0 | |
| end function | |
| function ${name}_funcB() as integer | |
| return 0 | |
| end function | |
| function ${name}_funcC() as integer | |
| return 0 | |
| end function | |
| function ${name}_funcD() as integer | |
| return 0 | |
| end function | |
| `; | |
| } | |
| function finalLoggerBrs() { | |
| return undent` | |
| sub init() | |
| dt = createObject("roDateTime") | |
| print "BENCH_FINAL_LOGGER_INIT_MS="; (dt.asSecondsLong() * 1000) + dt.getMilliseconds() | |
| end sub | |
| `; | |
| } | |
| function writeComponent(root, name, brs, inline) { | |
| const dir = path.join(root, 'components', 'generated'); | |
| if (inline) { | |
| fs.writeFileSync(path.join(dir, `${name}.xml`), undent` | |
| <?xml version="1.0" encoding="utf-8" ?> | |
| <component name="${name}" extends="Group"> | |
| <script type="text/brightscript"><![CDATA[ | |
| ${brs} | |
| ]]></script> | |
| </component> | |
| `); | |
| } else { | |
| fs.writeFileSync(path.join(dir, `${name}.brs`), brs); | |
| fs.writeFileSync(path.join(dir, `${name}.xml`), undent` | |
| <?xml version="1.0" encoding="utf-8" ?> | |
| <component name="${name}" extends="Group"> | |
| <script type="text/brightscript" uri="${name}.brs" /> | |
| </component> | |
| `); | |
| } | |
| } | |
| class Telnet { | |
| constructor(host) { | |
| this.host = host; | |
| this.socket = null; | |
| this.buffer = ''; | |
| this.lastDataAt = Date.now(); | |
| this.listeners = new Set(); | |
| } | |
| connect() { | |
| return new Promise((resolve, reject) => { | |
| const sock = net.createConnection({ host: this.host, port: TELNET_PORT }); | |
| sock.once('error', reject); | |
| sock.once('connect', () => { | |
| sock.off('error', reject); | |
| sock.on('error', (err) => console.error('\n[telnet error]', err.message)); | |
| sock.on('close', () => console.error('\n[telnet closed]')); | |
| sock.on('data', (chunk) => { | |
| const s = chunk.toString('utf8'); | |
| this.buffer += s; | |
| this.lastDataAt = Date.now(); | |
| process.stdout.write(s); | |
| if (/Console connection is already in use/i.test(s)) { | |
| console.error( | |
| '\n[fatal] Roku reports port 8085 is already in use by another telnet client.\n' + | |
| ' Close any active VS Code BrightScript debug session, other telnet clients, or stale terminals connected to this device, then re-run.', | |
| ); | |
| process.exit(1); | |
| } | |
| for (const cb of this.listeners) cb(); | |
| }); | |
| this.socket = sock; | |
| resolve(); | |
| }); | |
| }); | |
| } | |
| resetBuffer() { | |
| this.buffer = ''; | |
| } | |
| async waitForLine(regex, timeoutMs = RUN_TIMEOUT_MS) { | |
| if (regex.test(this.buffer)) return; | |
| return new Promise((resolve, reject) => { | |
| const timer = setTimeout(() => { | |
| this.listeners.delete(cb); | |
| reject(new Error(`Timeout waiting for ${regex}`)); | |
| }, timeoutMs); | |
| const cb = () => { | |
| if (regex.test(this.buffer)) { | |
| clearTimeout(timer); | |
| this.listeners.delete(cb); | |
| resolve(); | |
| } | |
| }; | |
| this.listeners.add(cb); | |
| }); | |
| } | |
| async waitForSettle(idleMs = SETTLE_MS, maxMs = SETTLE_MAX_MS) { | |
| const deadline = Date.now() + maxMs; | |
| while (true) { | |
| const idle = Date.now() - this.lastDataAt; | |
| if (idle >= idleMs) return; | |
| if (Date.now() >= deadline) return; | |
| await sleep(Math.min(idleMs - idle + 10, 200)); | |
| } | |
| } | |
| close() { | |
| if (this.socket) this.socket.destroy(); | |
| } | |
| } | |
| function sleep(ms) { | |
| return new Promise((r) => setTimeout(r, ms)); | |
| } | |
| function extractMetrics(buffer) { | |
| const compiles = [...buffer.matchAll(/AppCompileComplete[^)]*\((\d+)\s*ms\)/g)] | |
| .map((m) => parseInt(m[1], 10)); | |
| const launch = buffer.match(/AppLaunchChainComplete[^)]*\((\d+)\s*ms\)/); | |
| const mainMs = buffer.match(/BENCH_MAIN_START_MS=\s*(\d+)/); | |
| const finalMs = buffer.match(/BENCH_FINAL_LOGGER_INIT_MS=\s*(\d+)/); | |
| const runMs = | |
| mainMs && finalMs ? parseInt(finalMs[1], 10) - parseInt(mainMs[1], 10) : null; | |
| return { | |
| compile1: compiles[0] ?? null, | |
| compile2: compiles[1] ?? null, | |
| launchChain: launch ? parseInt(launch[1], 10) : null, | |
| runMs, | |
| }; | |
| } | |
| async function runScenario(scenario, opts, telnet) { | |
| console.log(`\n========== Scenario: ${scenario.title} (${opts.runs} runs) ==========`); | |
| const results = []; | |
| for (let i = 1; i <= opts.runs; i++) { | |
| console.log(`\n----- ${scenario.folder} run ${i}/${opts.runs} -----`); | |
| telnet.resetBuffer(); | |
| try { | |
| const deployOpts = { | |
| host: opts.host, | |
| password: opts.password, | |
| rootDir: path.resolve(scenario.folder), | |
| stagingDir: path.resolve('out', `${scenario.folder}-staging`), | |
| outDir: path.resolve('out'), | |
| outFile: `${scenario.folder}.zip`, | |
| }; | |
| await rokuDeploy.stage(deployOpts); | |
| await rokuDeploy.zip(deployOpts); | |
| await rokuDeploy.sideload(deployOpts); | |
| await telnet.waitForLine(/BENCH_FINAL_LOGGER_INIT_MS=\s*\d+/); | |
| await telnet.waitForSettle(); | |
| const metrics = extractMetrics(telnet.buffer); | |
| results.push({ run: i, ...metrics }); | |
| } catch (err) { | |
| console.error(`\n[run ${i} failed] ${err.message}`); | |
| results.push({ | |
| run: i, | |
| compile1: null, | |
| compile2: null, | |
| launchChain: null, | |
| runMs: null, | |
| error: err.message, | |
| }); | |
| } | |
| try { | |
| await rokuDeploy.keyPress({ host: opts.host, key: 'Home' }); | |
| } catch (err) { | |
| console.error(`\n[home press failed] ${err.message}`); | |
| } | |
| await telnet.waitForSettle(); | |
| } | |
| return results; | |
| } | |
| function avg(results, key) { | |
| const vals = results.map((r) => r[key]).filter((v) => typeof v === 'number'); | |
| if (!vals.length) return null; | |
| return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length); | |
| } | |
| function printTable(title, results) { | |
| console.log(`\n=== ${title} ===`); | |
| console.log('| Run | Compile 1 | Compile 2 | Run (Main→FinalLogger) | LaunchChain |'); | |
| console.log('|-----|-----------|-----------|------------------------|-------------|'); | |
| for (const r of results) { | |
| const cell = (v) => (v == null ? ' ? ' : `${v} ms`); | |
| console.log( | |
| `| ${String(r.run).padStart(3)} | ${cell(r.compile1).padStart(9)} | ${cell(r.compile2).padStart(9)} | ${cell(r.runMs).padStart(22)} | ${cell(r.launchChain).padStart(11)} |`, | |
| ); | |
| } | |
| const avgCell = (v) => (v == null ? ' ? ' : `${v} ms`); | |
| console.log( | |
| `| avg | ${avgCell(avg(results, 'compile1')).padStart(9)} | ${avgCell(avg(results, 'compile2')).padStart(9)} | ${avgCell(avg(results, 'runMs')).padStart(22)} | ${avgCell(avg(results, 'launchChain')).padStart(11)} |`, | |
| ); | |
| } | |
| async function main() { | |
| const opts = parseArgs(); | |
| console.log(`Host: ${opts.host} Runs per scenario: ${opts.runs} Components: ${opts.components}`); | |
| console.log('Regenerating cdata/ and codebehind/...'); | |
| regenerateScenarios(opts.components); | |
| console.log(`Connecting to telnet at ${opts.host}:${TELNET_PORT}...`); | |
| const telnet = new Telnet(opts.host); | |
| await telnet.connect(); | |
| console.log('Connected. Pressing Home for clean baseline...'); | |
| try { | |
| await rokuDeploy.keyPress({ host: opts.host, key: 'Home' }); | |
| } catch (err) { | |
| console.error(`[initial home press failed] ${err.message}`); | |
| } | |
| await telnet.waitForSettle(); | |
| const allResults = {}; | |
| for (const scenario of SCENARIOS) { | |
| allResults[scenario.title] = await runScenario(scenario, opts, telnet); | |
| } | |
| console.log('\n\n############ SUMMARY ############'); | |
| for (const [title, results] of Object.entries(allResults)) { | |
| printTable(title, results); | |
| } | |
| telnet.close(); | |
| } | |
| try { | |
| await main(); | |
| } catch (err) { | |
| console.error('\nFatal:', err); | |
| process.exit(1); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "roku-compile-bench", | |
| "private": true, | |
| "version": "0.0.0", | |
| "type": "module", | |
| "description": "Benchmarks Roku compile/launch times for inline CDATA vs codebehind components.", | |
| "scripts": { | |
| "bench": "node bench.js" | |
| }, | |
| "dependencies": { | |
| "roku-deploy": "^4.0.0-alpha.2", | |
| "undent": "^1.0.0" | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Roku Compile-Time Benchmark: Inline CDATA vs. Codebehind
Setup
Two synthetic Roku channels generated by the same script, identical in every way except how each child component's BrightScript is attached to its XML:
<script type="text/brightscript"><![CDATA[ ... ]]></script>.brsfile, referenced via<script type="text/brightscript" uri="ComponentNNN.brs" />Each project contains:
Mainthat printsBENCH_MAIN_START_MS=<epoch_ms>on its first lineMainScenewith 100 child components (Component001–Component100) plus aFinalLoggerinit()plus 4 dummy functionsFinalLogger.init()printsBENCH_FINAL_LOGGER_INIT_MS=<epoch_ms>Metrics captured per run from the Roku device's beacon log on telnet port 8085:
AppCompileCompletedurationAppCompileCompleteduration (Roku double-compiles every sideload)BENCH_FINAL_LOGGER_INIT_MS−BENCH_MAIN_START_MSAppLaunchChainCompletedurationEach scenario was run 10 times: sideload, capture beacons, press ECP
Home, wait for telnet to go quiet, repeat.Raw Results (10 runs each)
CDATA
Codebehind
Comparison
Medians (resistant to outliers)
Averages
Variance
Takeaways
.brsfiles vs. parsing the BrightScript inline as part of the XML the device is already reading.AppLaunchChainCompleteis within noise (~2% slower for codebehind), so end-user-perceived launch time is dominated by the compile delta, not anything that happens afterMainstarts.Reproduce