Guide for AI agents contributing to this repo.
A JavaScript runtime for Nintendo Switch homebrew, built on QuickJS + libnx + cairo. It implements Web APIs (Canvas 2D, Fetch, Crypto, URL, EventTarget, etc.) so you can write Switch apps in JS/TS.
source/ — C code (native modules compiled for aarch64 Switch)
packages/
runtime/ — TypeScript runtime (bundled via QuickJS bytecode compiler)
src/$.ts — Native binding types (the `$` object bridges JS↔C)
src/index.ts — Entry point, registers all globals
test/ — Host-platform test binary & TAP conformance tests
nro/ — .nro builder (Switch homebrew executable format)
nsp/ — .nsp builder
create-nxjs-app/ — Scaffolding tool
apps/ — Example apps (each is a standalone pnpm workspace package)
Makefile — Cross-compiles C code via devkitPro toolchain
Every feature follows the same pattern:
#include "types.h"
static JSValue nx_foo_do_thing(JSContext *ctx, JSValueConst this_val,
int argc, JSValueConst *argv) {
// Implementation using libnx or other C libraries
return JS_UNDEFINED;
}
static const JSCFunctionListEntry function_list[] = {
JS_CFUNC_DEF("fooDoThing", 1, nx_foo_do_thing),
};
void nx_init_foo(JSContext *ctx, JSValueConst init_obj) {
JS_SetPropertyFunctionList(ctx, init_obj, function_list,
countof(function_list));
}Header (source/foo.h):
#pragma once
#include <quickjs.h>
void nx_init_foo(JSContext *ctx, JSValueConst init_obj);Then in source/main.c:
#include "foo.h"at the topnx_init_foo(ctx, nx_ctx->init_obj);in the init block (~line 680, alphabetical)
import { $ } from './$';
import { def } from './utils';
import { EventTarget } from './polyfills/event-target';
export class Foo extends EventTarget {
constructor() {
super();
$.fooInit();
addEventListener('unload', $.fooExit);
}
}
def(Foo); // registers class name for toString/instanceof- The
$object contains ALL native function bindings (defined in Cfunction_lists) - Add types for new
$functions inpackages/runtime/src/$.ts - Register in
packages/runtime/src/index.ts - Use
assertInternalConstructorfor classes that shouldn't be user-constructible - Use
proto()for classes that return a native object from C (like Image)
For expensive operations (decoding, crypto, etc.), use the thread pool:
// In work_cb (runs on thread pool — NO JS API calls allowed):
static void my_work_cb(nx_work_t *req) {
my_data_t *data = (my_data_t *)req->data;
// Do heavy work here
}
// In after_work_cb (runs on main thread — JS API calls OK):
static JSValue my_after_work_cb(JSContext *ctx, nx_work_t *req) {
my_data_t *data = (my_data_t *)req->data;
// Return result to JS
return JS_NewArrayBuffer(...);
}
// Queue it:
return nx_queue_async(ctx, req, my_work_cb, my_after_work_cb);See source/async.c for the implementation, source/image.c and source/crypto.c for examples.
nx_context_t— per-runtime context (thread pool, work queue, rendering mode, etc.)NX_DEF_GET(obj, "name", getter_fn)— define a getterNX_DEF_GETSET(obj, "name", get_fn, set_fn)— define getter + setterNX_DEF_FUNC(obj, "name", fn, arg_count)— define a methodcountof(x)— array length macro
Files are loaded via fetch() with romfs:/ URLs (Switch ROM filesystem) or regular paths:
// In Image class (good reference for loading resources):
fetch(url)
.then(res => res.arrayBuffer())
.then(buf => $.imageDecode(this, buf))The $.entrypoint gives the base URL for resolving relative paths.
main.cc mounts two RomFS devices:
nxjs:— the nx.js NRO's OWN embedded RomFS (holds the runtime's source map,nxjs:/runtime.js.map). Always mounted. The embedded runtime is run under the namenxjs:/runtime.jsso its stack frames symbolicate.romfs:— "the app". For a standalone app this is the same NRO's RomFS (so existingromfs:/assetreferences work). For a bootstrap launch (argv[1]is an app.nro), it is the launched app's RomFS instead.
main.cc resolves the user entrypoint as:
- If
argv[1]is set (bootstrap launch — a thin launcher hands nx.js the app):*.nro→ mount its embedded RomFS asromfs:and runromfs:/main.js. The RomFS is not at file offset 0, somount_nro_romfs()parses the NRO header + asset header to find the RomFS offset, then callsromfsMountFromFsdev(argv[1], romfs_offset, "romfs").- otherwise (typically
*.js) → runargv[1]directly.
- Else (standalone): mount self as
romfs:, runromfs:/main.js; fall back to<argv0>.jsnext to the.nroon the SD card.
The entrypoint resolution (resolve_entrypoint()) runs early in main(), before
V8 init — not because the app code runs early, but because nxjs.ini (below)
lives next to the entrypoint and its V8/heap settings must apply before
V8::Initialize()/Isolate::New.
An optional INI file located next to the entrypoint (romfs:/nxjs.ini for a
standalone/bootstrap app, <dir>/nxjs.ini for a loose .js). Parsed very early
in main() with the bundled source/vendor/ini.h (inih), using plain fopen —
the JS fetch/readFileSync layer doesn't exist yet, and the [v8]/[memory]
settings must be applied before the isolate is created. Lives in
source/config.cc/config.h (nx_config_t on nx_context_t).
[v8]
jit = auto ; auto (regime-based) | on | off
flags = --max-old-space-size=256 ; appended AFTER the runtime's default V8 flags
[memory]
heap_limit = 256MiB ; KiB/MiB/GiB or raw bytes; clamped to fit
[renderer]
mode = auto ; auto | cpu | gpu (gpu falls back to raster on init fail)
[console] ; on-screen console / terminal styling
font_size = 22
cursor_style = bar ; block | underline | bar
background = #002b36
foreground = #839496
black = #073642 red = #dc322f ... bright_white = #fdf6e3
[threadpool] ; libuv worker pool (services ALL async native ops)
size = 4 ; worker count; default 2 in applet, 4 in application
stack_size = 1MiB ; per-worker stack; default 1MiB (256KiB floor, 32MiB cap)
[socket] ; field-level overrides on the regime base (lean/full)
tcp_tx_buf_size = 256KiB
tcp_rx_buf_size = 256KiB
tcp_tx_buf_max_size = 1MiB
tcp_rx_buf_max_size = 1MiB
udp_tx_buf_size = 9KiB
udp_rx_buf_size = 42KiB
sb_efficiency = 6
num_bsd_sessions = 3
service_type = auto ; auto | user | system
[runtime] ; consumed by the SLIM bootstrap launcher (not the runtime)
version = ^1 ; semver requirement for the shared runtime NRO- Effective (post-clamp) values are exposed to JS as
$.config({ jit, heapLimit, renderer, v8Flags, socket:{…}, threadpool:{…}, loaded }). [threadpool]exists because libuv's upstream defaults (4 workers × 8 MiB stacks = 32 MiB, committed lazily on the FIRST async op) cannot be satisfied in applet mode next to the JIT code arena — and a failed worker create is a hard libuvabort()that skips the applet exit handshake and destabilizes the system until reboot.main.ccexports the effective values viaUV_THREADPOOL_SIZE/UV_THREADPOOL_STACK_SIZEenv vars before the pool spins up; the stack-size var is a switch-libuv port extension (pacman-packagesswitch-libuv≥ 1.52.1-3 required).- Every value that can't be honored is logged to
nxjs-debug.logas a[config] … not honored: <reason>line (clamped heap, invalid value, GPU init fallback, socket reservation too big, etc.). A missing file is silent. - JIT default is regime-gated (
jit = auto→ full JIT in application mode, jitless/Ignition in applet mode). JIT in applet runs, but its 64 MiB code-range minimum is dual-mapped by libnx jitCreate to ~128 MiB REAL — a third of the ~380 MiB applet grant — leaving only ~15-19 MiB of slack, so every multi-MiB allocation (raster framebuffers, console font + terminal canvas) sits on a knife edge where kilobytes of runtime growth degrade the console to the PrintConsole fallback (or worse without the display-funding hardening). Jitless frees that ~128 MiB so the full canvas-terminal experience reliably fits.jit = onopts in to applet JIT (accepting those margins);jit = offforces the interpreter everywhere. Applet mode also uses CPU raster rendering (chosen independently of JIT). Only applet + GPU + JIT is known to crash (jitCreate starves Mesa) — that combo (an explicit[renderer] gpuin applet) is warned about but attempted. - WASM needs JIT code-arena headroom beyond V8's 64 MiB code-range floor.
The libnx jit_* arena is dual-mapped (rx+rw → ~2× real), so the headroom is
regime-gated: application mode reserves 64 MiB (WASM works out of the box),
applet mode reserves 0 (WASM opt-in, since the dual-mapped headroom won't
fit applet's ~137 MiB free). Set
[v8] code_headroom_mb = N(orwasm = on, sugar for 64). When WASM is unavailable (jitless, or applet w/ 0 headroom),index.tswrapsWebAssembly.Module/compile/instantiate(Streaming) to throw a clear nx.jsCompileErrorpointing at thenxjs.inifix, gated on$.config.jit/$.config.codeHeadroomMb— keep the host$.configmirror'scodeHeadroomMbnon-zero (64) so the host harness keeps WASM enabled. - The historic applet-mode-JIT crashes were NOT the regime per se: nx.js used to reserve the 64 MiB WASM headroom unconditionally → a ~256 MiB-real arena that left ~10 MiB for everything in applet → bsdsocket/heap/Skia failures. Fixed by regime-gating the headroom (commit 71eecc2).
- Applet-mode memory ground truth (measured via the
nativeHeap*fields ofSwitch.memoryUsage(), which wrap newlibmallinfo+ thefake_heapbounds): the native malloc heap is ~168 MiB TOTAL, with >130 MiB consumed at startup under JIT (64 MiB jit arena + V8 slabs + Skia raster + console). The V8 heap, JIT arena, Skia surfaces, libuv stacks, zstd windows, and ArrayBuffer backings ALL compete in this one budget — V8 commits its heap from it in 16 MiB slabs. That is why the applet+JITreserveis 96 MiB (V8 max heap ~41 MiB): a 64 MiB reserve left streaming-decompression workloads (8 MiB zstd window + one V8 slab commit) ~5 MiB short → native ENOMEM, then a FATAL V8 Zone OOM on the next JIT compile. - V8 fatal/OOM no longer takes down the system:
main.ccinstallsfatal_error_callback/oom_error_callback(seenx_v8_fatal_exit) that log the reason +mallinfostats tonxjs-debug.logandexit(1)back through hbloader, instead of V8's defaultOS::Abort()undefined-instruction trap — which, under the Album applet, skipped the applet exit handshake and made any subsequent applet launch crash the OS until reboot. - The host
nxjs-testreads no INI; itsbuild_init_objectexposes a default$.config. Keep that mirror in sync if you change$.config's shape. [runtime]is read by the slim bootstrap launcher, not the runtime. The runtime's parser recognizes + silently ignores it (the samenxjs.iniships inside a slim app's RomFS and is read by both).
An app can be packaged slim (default — shares one on-SD runtime) or fat
(self-contained). This applies to BOTH .nro (@nx.js/nro) and .nsp
(@nx.js/nsp) outputs.
bootstrap/ holds the shared launcher logic (pure-C, libnx only, NO
V8/Skia), with two flavor subdirs that differ only in the final "launch" step:
-
bootstrap/source/— shared:resolve.c(read[runtime] versionfrom the app's ownromfs:/nxjs.ini, scansdmc:/nx.js/nxjs-v<full-version>.nro, semver-match the highest),match.c(specifier parse + compare),ui.c(on-screen error + wait-for-+),vendor/{ini.h,semver.c}. A future "download the runtime if missing" step slots in here. -
bootstrap/launcher-nro/→bootstrap.nro. Resolves the runtime, thenenvSetNextLoad(<runtime>, "\"<runtime>\" \"<self.nro>\"")and exits; hbloader loads the runtime withargv[1]= the slim NRO, whose RomFS the runtime mounts viamount_nro_romfs(argv[1]). -
bootstrap/launcher-nsp/→hbl.nso+hbl.npdm. A patched nx-hbloader forwarder that runs as the slim NSP's exefsmain.envSetNextLoadis hbloader-only and unavailable to an installed title, so the forwarder instead is an hbloader: it resolves the runtime, then loads that NRO directly (svcMapProcessCodeMemory+ the homebrewConfigEntry[]ABI) withargv="<runtime>" "nsp:". The loaded runtime seesenvIsNso()==false(forwarded homebrew, not the title's NSO) and the"nsp:"marker tells it to mount the installed title's own RomFS (the app's files) viaromfsMountFromCurrentProcess(fs cmd 200), then runromfs:/main.js. Vendored from switchbrew/nx-hbloader (MIT) + Skywalker25/Forwarder-Mod; only needs rebuilding on a major libnx/firmware ABI change.- No-runtime error path: when
nx_resolve_runtimefails, the forwarder can't just abort (diagAbortWithResult) — it renders the shared on-screen error UI (bootstrap/source/ui.cnx_fail_no_runtime) so the user sees a real message (+ the future runtime-download UI lives here). Two gotchas this requires: (1) the forwarder's__libnx_initheapmalloc arena must be 16 MiB (upstream nx-hbloader uses 16 KiB) —consoleInit'sframebufferCreateallocates from malloc and hangs on a tiny heap; and (2) the forwarder's minimal__appInitdoesn't bring up the display/input stack, sonx_show_error_and_exitruns the libnx__appInitorder (sm/applet/hid/time+__libnx_init_time/__nx_win_init) before delegating to the shared UI. - Clean exit from the error screen: an installed title that just
svcExitProcess()s makes the OS show "software was closed". The proper applet self-exit handshake in libnx's_appletCleanupis gated on(envIsNso() && __nx_applet_exit_mode==0) || __nx_applet_exit_mode==1— and forwarded homebrew hasenvIsNso()==false, so the forwarder setsu32 __nx_applet_exit_mode = 1. The sharedui.ccalls a weaknx_ui_exit()after+(default =consoleExit+ return, correct for the hbloader-launched NRO launcher); the forwarder provides a strongnx_ui_exit()that replicates__libnx_exitby hand (it links-Wl,-wrap,exit, whose__wrap_exitaborts, soexit()is unusable): teardown →appletExit()(registers_appletExitProcessas the exit func) →__nx_exit(0, envGetExitFuncPtr())jumps to it. All four matrix cases (NRO/NSP × runtime present/missing) verified on-device.
- No-runtime error path: when
-
Fat:
nxjs-nro --fat/nxjs-nsp --fat(orNXJS_FAT=1) embeds the full runtime (the ~40 MB NRO / ~21 MB NSO).--slim/NXJS_SLIM=1are accepted no-ops (slim is the default). Both default changes are breaking for existingnxjs-nro/nxjs-nspscripts. -
Build:
make -C bootstrap/launcher-nroandmake -C bootstrap/launcher-nsp(devkitPro;jqderives the runtime major frompackages/runtime/package.json→ the baked^majordefault). Each launcher's Makefile is tiny andincludesbootstrap/common.mk(shared devkitPro build scaffold; compilesbootstrap/source/*into both). CI builds both, uploads them, and the release job copiesbootstrap.nro→packages/nro/dist/andhbl.nso/hbl.npdm→packages/nsp/dist/. -
Match logic (
bootstrap/source/match.c) is split from libnx so it's host-unit-tested:bootstrap/test/run.sh(no Switch needed). -
Prerelease note: the vendored
semver.ccompares purely by precedence and does NOT apply node-semver's "prereleases excluded from non-prerelease ranges" rule, so^1DOES match1.0.0-beta.N— intentional during the v1 beta. -
nsp:runtime branch:resolve_entrypoint()insource/main.ccdetectsargv[1] == "nsp:"and mountsromfs:viaromfsMountFromCurrentProcess(the installed title's data storage), instead ofmount_nro_romfs(NRO path) orromfsMountSelf(standalone).nxjs:(runtime's own files) is unaffected. -
SD-card convention: shared runtimes live at
sdmc:/nx.js/nxjs-v<full-version>.nro; multiple versions may coexist. The same runtime NRO serves both slim NRO and slim NSP apps.
source/module.cc (shared by the device runtime and the host test binary, so
they never drift) implements native ES module resolution for the entrypoint and
its imports:
- Static
importandawait import()resolve specifiers as URLs (viaada) against the importing module's URL: relative (./,../), absolute-path (/x.js), and absolute-URL (romfs:/sdmc:/nxjs:/file:) specifiers work; bare specifiers throw (no node_modules / import maps). - Resolution is synchronous (
fopen/read_file), so only mounted devoptab schemes work.http(s):/data:imports are not supported (would need an async loader). JSON / asset (synthetic) modules are also not implemented yet. - A module cache keyed by resolved URL gives referential stability (a shared
dependency imported via multiple paths is one instance) and handles cycles.
import.meta.urlis each module's resolved URL;import.meta.mainis true only for the entrypoint. - Apps are normally esbuild-bundled (imports resolved at build time), so this is
additive — it enables unbundled multi-file apps, the REPL, and app-level lazy
import(). Top-level await works (V8 native; a rejected entry graph is routed to the error path). main.cccallsnx_init_modules(iso)once afterIsolate::New,nx_run_entry_module(...)to run the entrypoint, andnx_modules_teardown()before isolate dispose. The host test binary mirrors these calls.
- C code:
Makefileusing devkitPro/libnx toolchain (aarch64, cross-compiled) - JS/TS: pnpm workspaces, esbuild for app bundling
- Package manager: pnpm 8.x
- CI: GitHub Actions — builds, tests
- Cannot build locally unless you have devkitPro installed. Don't try to
makein CI or sandboxes.
- For link libraries: add to
LIBSinMakefile(e.g.,-lmpg123) - For single-header libs: place in
source/vendor/and include directly - Memory alignment matters on Switch: use
memalign()for DMA buffers,armDCacheFlush()for audio mempools
nx.js follows semver as of v1. Choose the bump type that matches the change:
patch— bug fixes and other backwards-compatible changes.minor— new, backwards-compatible features.major— breaking changes to the public API or runtime behavior.
---
"@nx.js/runtime": patch
---
feat: description of what changed- The repo is currently in changesets beta prerelease mode (
.changeset/pre.json), so releases publish asX.Y.Z-beta.N. Amajor/minor/patchchangeset still picks the underlying version bump; the-beta.Nsuffix is applied automatically while in pre mode. - All packages in
@nx.js/runtime,@nx.js/nro,@nx.js/nsp, andcreate-nxjs-appare version-locked (see.changeset/config.jsonfixedarray) - If your change touches
source/(C code), the changeset should include@nx.js/runtime
Each app in apps/ follows this structure:
apps/my-app/
package.json — scripts: build (esbuild), nro, nsp
src/main.ts — entry point
romfs/ — files bundled into the Switch ROM filesystem
Package.json template:
{
"name": "my-app",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "esbuild --bundle --sourcemap --sources-content=false --target=es2022 --format=esm --outdir=romfs src/main.ts",
"nro": "nxjs-nro",
"nsp": "nxjs-nsp"
},
"devDependencies": {
"@nx.js/nro": "workspace:^",
"@nx.js/nsp": "workspace:^",
"@nx.js/runtime": "workspace:^",
"esbuild": "^0.17.19"
}
}The screen global is the main display (1280×720). Use screen.getContext('2d') for drawing.
When creating a new example app, use apps/hello-world/ as a template. Copy the entire directory and modify it — this ensures you include all necessary structure and config files (.gitignore, tsconfig.json, package.json, romfs/, etc.).
- Branch from
main - PRs reviewed by @TooTallNate
- CI must pass (Build, Test, WebAssembly Conformance)
- Use
git worktreeif working on multiple branches simultaneously — never share a working directory between parallel tasks
pnpm installhangs on prompts in non-interactive shells → usepnpm install < /dev/nullor--no-frozen-lockfile- Can't call JS APIs from thread pool worker callbacks — only in
after_work_cb Switch.file(path).stream().pipeThrough(new DecompressionStream(fmt))takes a transparent FUSED native fast path — don't "optimize" it back into separate read + decompress steps.FsFile.stream()tags itsReadableStreamwithkNativeFileSource({path,start,end});DecompressionStreamtags itself withkNativeDecompressSetup(a factory); the overriddenReadableStream.prototype.pipeThrough(inpolyfills/streams.ts) detects the pair and routes to aReadableStreamdriven by$.decompressFileNew/$.decompressFilePull(source/compression.cc), which doesfread+decompress in ONE thread-pool dispatch into fixed reused buffers. This is what makes NSZ-style decompression fast (~28 MB/s app / ~10 MB/s applet vs ~0.6 MB/s through the polyfill pipe) AND memory-safe in applet mode (no realloc-grow spikes, no per-chunk promise churn). The per-pull output cap andFsFile.stream()'s default chunk size are both regime-gated (large in application, conservative in applet) because a large chunk amplifies peak memory through the pipe — on-device, an unconditional 1 MiB default crashed applet mode. Any other source/transform pair falls through to the normal polyfill pipe. Throughput past the fused decompress is then bounded by whatever the consumer does per chunk (e.g. an NSZ installer'ssubtle.encryptAES-CTR round-trips), not the runtime.- Switch memory is limited — be mindful of large allocations
romfs:/paths use forward slashes, colon after scheme- Globals like
setTimeout,setInterval,clearTimeout,clearIntervalare NOT available insidepackages/runtime/src/— they're only registered as globals inindex.tsfor user code. Within the runtime package itself, import them:import { setInterval, clearInterval } from './timers'; - Gamepad button mapping is NOT standard Web Gamepad API order. Use
@nx.js/constantsButtonenum (e.g.Button.A,Button.B). The order is: B=0, A=1, Y=2, X=3, L=4, R=5, ZL=6, ZR=7, Minus=8, Plus=9, StickL=10, StickR=11, Up=12, Down=13, Left=14, Right=15 stub()does NOT mean unimplemented. Methods marked withstub()in TypeScript (from./utils) are placeholders for type generation only. At runtime, the C side overwrites them on the prototype viaNX_DEF_FUNC()orNX_DEF_GET()/NX_DEF_GETSET(). If you seestub(), check the corresponding C file'snx_init_*or*_init_classfunction — the real implementation is there. Onlythrow new Error('Method not implemented.')means actually not implemented.nxjs.inimust be read beforeV8::Initialize()(for[v8]/[memory]), so entrypoint resolution is hoisted to the top ofmain()and the INI is parsed with plainfopen(viasource/vendor/ini.h), NOT the JSreadFileSync. The[v8] jitsetting drivescan_jit, which couples the V8 flags (--jitless), the heapreserve(180 vs 48 MiB), AND the code-range size (64 MiB vs 0) — overridecan_jitonce, don't touch those three independently. Socket overrides are clamped so a bad value can't makesocketInitialize(whichdiagAbortWithResults on failure) brick startup. Any non-honored value logs[config] … not honored: <reason>tonxjs-debug.log.Application.selfmust use$.selfNroPath, NOT$.argv[0]. In slim modesargv[0]is the shared runtime NRO, so keyingselfoff it makesself.namereport"nx.js"instead of the app.build_init_object(bothsource/main.ccand the host mirror) sets$.selfNroPathper launch mode: the app's own NRO path for a standalone/slim NRO (argv[0]standalone,argv[1]for a slim.nrolaunch), ornullfor an installed title (fat/slim NSP, and theargv[1]=="nsp:"marker) sonsAppNew(null)resolves via the processProgramId+nsGetApplicationControlData. Verified on-device across the fat/slim × NRO/NSP matrix.- devkitPro's
switch_rulesresolvesAPP_TITLE/APP_AUTHORat include time — set them (andAPP_TITLEID, wired viaNACPFLAGS += --titleid=..., not a positionalnacptoolarg) beforeinclude $(DEVKITPRO)/libnx/switch_rulesinbootstrap/common.mk, or the?=is a no-op and the launcher NACP getsUnspecified Author/PresenceGroupId 0x0. A slim app inherits the bootstrap launcher's NACP author + title id, so this is what makes a slim app'sApplication.self(author/id) match the fat build (whose base isnxjs.nro, with the title id set in the rootMakefile). - WebGL2 (
screen.getContext('webgl2')) is a separate EGL/ES3 screen path (source/webgl.cc), mutually exclusive with the Canvas 2D screen paths. One module-global GL context renders straight into the EGL window's FBO 0; the main loop's CANVAS present branch dispatches tonx_webgl_present()(swap only when a draw/clear touched the default framebuffer since the last present; otherwise sleep ~1 vblank so the loop doesn't spin). Key gotchas: (1)nx_screen_release_for_webgl()(main.cc) must release whatever owns the NWindow first — PrintConsole, raster framebuffer, or a console-initialized Skia GPU screen (demoting the screen canvas back to lazy raster vianx_canvas_release_gpu_surfaceBEFOREnx_skia_gpu_screen_exitdestroys the GrDirectContext); aconsole.logbeforegetContext('webgl2')triggers exactly this. (2) Mesa/nouveau'seglQuerySurfacereports 0x0 for the NWindow surface — read the initial GL viewport for the drawing-buffer size instead. (3) The loop gate/present/teardown all checknx_webgl_active()alongsidescreen_is_gpu(EGL owns the NWindow, soappletMainLoop()'s false return must be ignored, same as the Skia GPU path). (4) The TS class's ~220 methods are declared as a mergedinterface(types only) and installed on the prototype by$.webglInitClassat runtime (NOT stub-bodied class methods — that would mint ~220 throwaway function objects at boot); TS-level wrappers (TexImageSource normalization fortexImage2D/texSubImage2D) are installed AFTER that call. (5) WebGL object classes (WebGLBuffer, …) are minted in C with prototypes captured from the classes object passed to$.webglInitClass. (6) WebGL2 is application-mode only: in the applet regime the GL driver cannot run (verified on-device: with JIT, Mesa produces no EGL config; jitless, the context comes up but the FIRSTglCompileShaderOOM-crashes inside Mesa's GLSL builtin construction — unrecoverable), sogetContext('webgl2')returns null in applet (before touching EGL, keeping the NWindow usable for the raster error display);[renderer] mode = gpuopts in anyway. WebGL1 ('webgl') andgetExtension()are intentionally not implemented. - Classes whose constructor
returns a substitute object (e.g.Screen,Image) must NOT use#privatefields/methods for shared state.Screen's constructor doesconst c = proto($.canvasNew(...), Screen); _.set(c, {}); return c;— the returned native object never had the class's private members installed, so calling a#methodon thescreeninstance throws"Receiver must be an instance of class Screen". Use thecreateInternal()WeakMap (_) + a module-level free function instead (seeensureContext()inscreen.ts). - Third-party libs that read
navigator/window/documentat module-eval time can break runtime startup.@xterm/headlesscomputesisNode ? 'node' : navigator.userAgentat import, andnavigator.userAgent's getter walksApplication.self→ ns init (addEventListener,$.argv) — not all ready that early, so init threw on device AND host. Fix: abundle.mjsesbuildinject(xterm-process-shim.js) supplies a module-scopedprocess({title}) so xterm'sisNodeis true and it never readsnavigator. The shim is injected only into modules referencing freeprocess(xterm), not a real global. - Feed a terminal CRLF, not bare LF. xterm (and real terminals) treat
\nas line-feed only (cursor down, same column), soconsole.log's\nstaircases.Terminal.write()normalizes lone LF → CRLF. - xterm cell colors have THREE modes — handle truecolor (
isBgRGB/isFgRGB) separately.getBgColor()/getFgColor()return a palette index (0–255) in palette mode but a packed0xRRGGBBin RGB mode (e.g. kleur'sbgRgb()/rgb()emit48;2;r;g;b). A packed RGB int is almost always > 255, so if you only handle the 0–255 palette ranges it falls through to the fallback (this madeconsole.warn/errorbackgrounds render white). The terminal renderer checksisBgRGB()/isFgRGB()first and unpacks the bytes. NoteisBgRGB()/isFgRGB()returnboolean, while sibling cell predicates likeisBold()/isFgDefault()returnnumber(0/1) — don't!== 0the booleans. - Canvas terminal cell backgrounds must use integer pixel bounds — AND the cell advance itself must be a whole pixel. The monospace advance (
measureText('M').width) is fractional. Rounding each cell's edges (round(x*cw)..round((x+1)*cw)) isn't enough on its own: with a fractionalcw, adjacent cells alternate floor/ceil widths and a glyph drawn at its own fractional advance (e.g. the FULL BLOCK█) leaves thin background seams.terminal.tssnapscharWidth = Math.round(measureText('M').width)so every cell is exactlycwpx and bothfillRectbackgrounds and glyphs tile seamlessly. - The console is themeable via
console.options(ornew Console(opts)), or declaratively via the[console]section ofnxjs.ini.ConsoleOptions extends TerminalOptions(theme,fontSize,lineHeight,scrollback,cursorStyle'block'|'underline'|'bar',cursorOpacity). For the globalconsole, assignconsole.optionsBEFORE the first log — theTerminalis created lazily in#getTerminal(), and the setter drops any existing terminal so the next output rebuilds it (scrollback is reset). The[console]ini keys (font_size,cursor_style,background/foreground/cursor,black..bright_white) are parsed insource/config.ccintonx_console_config_t, exposed on$.config.console(aTerminalOptions-shaped object with athemesub-object), and the globalConsole's constructor seeds#optionsfrom$.config.consolewhen no explicit opts were passed — an explicitconsole.options =still overrides. Keepsource/main.cc's$.config.consolebuilder and the host mirror (packages/runtime/test/src/main.cc, empty object) in sync. The renderer honors the full xterm ANSI palette:terminal.tsresolves a 16-entry#palettefrom the theme'sblack..brightWhitefields over theANSI_COLORSdefaults (don't re-hardcodeANSI_COLORSin#cellColor— index#palette). Seeapps/console-themefor a Solarized Dark example. The public option type uses a locally-definedConsoleTheme(a subset of xterm'sITheme); see the next bullet for why. - Never let the runtime's public type surface reference a
node_modulestype (e.g.@xterm/headless'sITheme/Terminal).build.mjsbundlessrc/index.tsinto a single ambient-globaldist/index.d.ts(starts with/// <reference>, no top-levelimport). If any exported type references an external module type, dts-bundle-generator emits a top-levelimport { … } from '@xterm/headless'at the top ofindex.d.ts, which turns the whole file into a module — and then the runtime's ambient globals (Switch,Response,ReadableStream,TextEncoder, …) stop being declared globally, so EVERY downstream@nx.js/*package (and the docs/Vercel build) fails to typecheck withCannot find name 'Response'etc. Mirror external types locally (asConsoleThemedoes forITheme) and type any getter that returns a raw vendor instance loosely (Terminal.terminal: unknown). Guard:grep '^import' packages/runtime/dist/index.d.tsmust be empty, andpnpm buildfrom the repo root must pass all packages. - DX: the libnx PrintConsole is initialized BEFORE running
runtime.js(source/main.cc) so that if runtime.js throws during evaluation,print_js_error()'s on-screen output (exception + stack) is visible instead of a bare "Runtime initialization failed". The full error is also insdmc:/switch/nxjs-debug.log. - The applet regime sits on a memory cliff — display bring-up must be pre-funded (
main.cc). The ~380 MiB applet grant leaves little slack once V8 + the runtime boot, yet the raster present needs ~11.2 MiB AT framebufferInit time (2x ~3.7 MiB block-linear swapchain + ~3.7 MiB linear staging). Grim failure modes were observed on-device when the slack ran out:framebufferCreate's internalnvInitialize(the nvdrv:a close→reopen after the boot console'sframebufferClosedropped the refcount to 0) HANGING inside nvservices, ormemcpy(NULL)from a failed create. Mitigations (all in main.cc): (1) a process-lifetimenvInitialize()reference taken at boot so the inner nv init/exit calls are pure refcounting (no close→reopen); (2) a ~12 MiB "display parachute" reserved right AFTER runtime.js evaluation (reserving it before V8's boot growth causes a boot OOM!) and released immediately beforeframebufferCreate(); (3)framebufferCreate/framebufferMakeLinearresults checked (close + log + black-screen fallback instead of crash), with the loop'sframebufferBegin()also null-guarded. Relatedly, never useArrayBuffer::New(iso, size)for multi-MB or app-controlled sizes — it FATALLY aborts on allocation failure ("Fatal process out of memory: v8::ArrayBuffer::New"); use a checkedmalloc/nx_alloc+ArrayBuffer::NewBackingStoreso the JS caller gets a catchable throw (the console's font loaders catch it and fall back to the libnx PrintConsole — that fallback IS the expected UX when the canvas terminal can't be allocated). Fixed call sites:font.ccgetSystemFont,canvas.ccgetImageData.
The packages/runtime/test/ directory contains a unified host-platform build of the nx.js runtime (nxjs-test) that compiles the real C source files against host system libraries, with libnx stubbed out. This enables running JS/TS tests on macOS/Linux without Switch hardware.
- Real source modules compiled from
source/: async, canvas, compression, crypto, dns, dommatrix, error, font, fs, image, tcp, tls, udp, url, util, window, wrap. ada is linked from theswitch-adahost lib (/opt/host/ada), not compiled here. - Stubbed modules (no-op
nx_init_*insrc/stubs.cc): account, album, applet, audio, battery, fsdev, gamepad, irs, memory, nifm, ns, service, swkbd, web - Compat headers in
src/compat/provide libnx type/function stubs, real AES/SHA via mbedtls, host system CA certificates for TLS - 60 FPS event loop with real thread pool, networking, and async operations
When you add or change a C source file in source/, you MUST update the test binary:
-
New portable module (uses standard C libs, not libnx): Add the
.ccfile toNX_SOURCESinpackages/runtime/test/CMakeLists.txt. Add thenx_init_*call inpackages/runtime/test/src/main.ccunder the "Real modules" section. -
New Switch-only module (uses libnx APIs): Add a no-op
nx_init_*stub inpackages/runtime/test/src/stubs.cc. Add thenx_init_*call inpackages/runtime/test/src/main.ccunder the "Stubbed modules" section. -
New libnx types/functions referenced by compiled modules: Add stubs to
packages/runtime/test/src/compat/switch.h. -
New linked library: Add to the
target_link_librariesand correspondingpkg_check_modulesinCMakeLists.txt.
Test fixtures in packages/runtime/test/fixtures/*.ts run in both the nxjs-test binary (QuickJS) and Chrome (via Playwright), with TAP output compared assertion-by-assertion to verify conformance.
fixtures/*.ts → esbuild bundle → fixtures/build/*.js
│
┌───────────────┴───────────────┐
▼ ▼
nxjs-test binary Chrome (Playwright)
(TAP on stdout) (TAP from console.log)
│ │
└───────────┬───────────────────┘
▼
vitest conformance.test.ts
(parse TAP, compare assertion-by-assertion)
Writing a test fixture:
import { test } from '../src/tap';
test('my feature', async (t) => {
t.equal(1 + 1, 2, 'math works');
t.ok(true, 'truthy');
t.deepEqual([1, 2], [1, 2], 'arrays match');
t.throws(() => { throw new Error('boom'); }, 'boom', 'error message matches');
});Available assertions: t.ok, t.notOk, t.equal, t.notEqual, t.deepEqual, t.throws, t.doesNotThrow, t.match, t.pass, t.fail.
Bug fix policy: When fixing a bug, add a regression test fixture (or add assertions to an existing fixture) that would have caught the bug. The test must pass in both nxjs-test and Chrome.
# Build the test binary (one-time, re-run after source/ changes)
cd packages/runtime/test
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
# Run the conformance tests (from repo root)
pnpm --filter @nx.js/runtime testSystem dependencies needed: cmake, pkg-config, libcairo2-dev, libfreetype-dev, libharfbuzz-dev, libpng-dev, libturbojpeg0-dev, libwebp-dev, zlib1g-dev, libzstd-dev, plus Playwright Chromium (pnpm --filter @nx.js/runtime exec playwright install chromium).
These are the proven loops for verifying native (source/) and runtime
(packages/runtime/src/) changes. Read this before iterating — it captures the
shortcuts that make the dev cycle fast.
The runtime is embedded into nxjs.nro as a byte array (source/runtime_js.c).
The full chain after a change:
# 1. Bundle the TS runtime (only needed if you changed packages/runtime/src/):
pnpm --filter @nx.js/runtime bundle # -> packages/runtime/runtime.js
# 2. Embed runtime.js as the C byte array (only if runtime.js changed):
node tools/embed-runtime.mjs packages/runtime/runtime.js source/runtime_js.c
# 3. Compile + link the NRO (needs devkitPro; can build locally on macOS):
DEVKITPRO=/opt/devkitpro make -j4 # -> nxjs.nro / nxjs.elf- If you only changed
source/*.cc, skip steps 1–2 (justmake). - If you only changed TS, you still need all three steps.
runtime_js.cis byte-encoded;strings nxjs.elfwon't show JS source — use it to confirm a rebuild happened, not to grep JS.- The build is reproducible locally with the devkitPro toolchain at
/opt/devkitpro(V8/Skia portlibs installed). Code layout can differ from the CI/published binary if theswitch-v8package version differs (matters only for symbolizing a published crash — see below).
apps/hello-world is the fastest device test vehicle. Pattern:
- Write a throwaway repro into
apps/hello-world/src/main.ts(or place files directly inapps/hello-world/romfs/for unbundled tests —romfs/is gitignored). Have it log results to a file on the SD card so you can read them over FTP (console output is easy to miss; a log file is reliable):const LOG = 'sdmc:/switch/dbg.log'; let buf = ''; const log = (...a:any[]) => { buf += a.join(' ')+'\n'; try { Switch.writeFileSync(LOG, buf); } catch {}; console.log(...a); };
- Build + package the app NRO (the app embeds the runtime from the repo-root
nxjs.nrovia@nx.js/nro's../../../nxjs.nro):cd apps/hello-world && pnpm build && DEVKITPRO=/opt/devkitpro pnpm nro
- If your repro writes
romfs/main.jsdirectly (unbundled / multi-file module test), SKIPpnpm build(don't let esbuild overwrite it) and just runpnpm nro.
- If your repro writes
- Upload + reset the log, then ask the user to run it and report back:
curl -s --netrc -T hello-world.nro "ftp://192.168.1.249/switch/hello-world.nro" printf "" | curl -s --netrc -T - "ftp://192.168.1.249/switch/dbg.log" # reset
- After the user runs it, fetch the log:
curl -s --netrc --ftp-method nocwd "ftp://192.168.1.249/switch/dbg.log" -o /tmp/dbg.log - ALWAYS restore the throwaway afterward:
git checkout -- apps/hello-world/and delete any tempromfs/files you added. Never commit hello-world hacks.
Application vs applet mode matters for memory/GPU paths: application mode
(~3 GiB grant) runs GPU canvas + WASM by default; applet mode (~380 MiB) runs
raster + WASM opt-in. Both run full JIT by default now (applet was jitless
before). The user chooses how they launch it — ask which mode to test, and have
the repro log Switch.memoryUsage() / read [v8] lines from
sdmc:/switch/nxjs-debug.log when memory behavior is relevant.
The Switch runs an FTP server (e.g. ftpd/sys-ftpd). Access via curl --netrc
(credentials live in ~/.netrc; anonymous is rejected):
# Upload an app NRO:
curl -s --netrc -T myapp.nro "ftp://192.168.1.249/switch/myapp.nro"
# Download a file (use --ftp-method nocwd for reliability):
curl -s --netrc --ftp-method nocwd "ftp://192.168.1.249/switch/foo.log" -o /tmp/foo.log
# List a directory:
curl -s --netrc "ftp://192.168.1.249/atmosphere/crash_reports/"
# Delete a file:
curl -s --netrc -Q "DELE /switch/foo.log" "ftp://192.168.1.249/"Useful locations on the SD card:
/switch/— apps + any logs you write viaSwitch.writeFileSync('sdmc:/switch/...')./switch/nxjs-debug.log— the runtime's stderr (the[v8] mem_total=... regime=... mode=...startup line + anyfprintf(stderr, ...)you add temporarily)./atmosphere/crash_reports/*.log— Atmosphère crash reports (see below).
A hard crash writes /atmosphere/crash_reports/<id>_<programid>.log. To read it:
- Fetch the newest report over FTP (sort by timestamp).
- Key fields:
Exception Info(Type/Address), theAddress: ... (nxjs + 0xXXXX)offsets, and the Stack Dump (ASCII often contains the abort message, e.g.[FatalOOM] JavaScript OOM: CALL_AND_RETRY_LAST). - Symbolize the
nxjs + 0xOFFSETvalues with addr2line against a matchingnxjs.elf:/opt/devkitpro/devkitA64/bin/aarch64-none-elf-addr2line -e nxjs.elf -f -C 0xOFFSET
- The crash report's
Module Idis the build-id; compare to your elf's (aarch64-none-elf-readelf -n nxjs.elf | grep -i 'build id'). If they differ, a local rebuild from the same source usually still symbolizes our own functions correctly, but V8-internal offsets may be slightly off. To symbolize a published binary exactly, rebuild on natecube with the sameswitch-v8package (see below); verify the match by confirming thebrkopcode (d4200000) sits at the crashing offset in both NROs. - Common crash signatures seen:
OS::Abort←Utils::ReportApiFailure("Empty MaybeLocal" = an unguarded.ToLocalChecked());FatalOOM("CALL_AND_RETRY_LAST" = V8 JS-heap exhaustion);OS::Abort←FatalNoSecurityImpact; Data Abort inMarkingBarrier/SetOldGenerationPageFlags(V8 heap reservation exceeds what the Horizon mman can commit).
- The crash report's
nxjs-test is the host-platform build of the runtime (compiles the real
source/*.cc against host libs, libnx stubbed) used to run the TAP conformance
fixtures and compare them to Chrome. It needs the CI toolchain (clang-19/lld-19
/opt/hostV8/Skia/ada), which lives in thenxdbgDocker container on thenatecubehost.
# The repo is bind-mounted at /work in the nxdbg container.
ssh natecube "docker exec nxdbg bash -lc 'cd /work && <cmd>'"You do NOT need natecube hardware access beyond ssh natecube + the running
nxdbg container — this is the canonical way to run the conformance suite
when you don't have /opt/host locally (macOS dev machines won't). The recipe
below is verified end-to-end; run each step as
ssh natecube "docker exec nxdbg bash -lc 'cd /work && <cmd>'".
IMPORTANT — leave /work as you found it. /work is a real working tree the
user may have checked out to another branch with uncommitted changes. Before
touching it: git stash list + git status. If dirty, git stash push -u -m <note> first; restore at the end (git checkout <orig-branch>, git stash pop, delete any temp branch you made, rm -rf .pnpm-store).
End-to-end recipe (each line is a separate docker exec … bash -lc 'cd /work && …'; always append < /dev/null to pnpm/node so they can't hang on a prompt):
# 0. git dubious-ownership guard (once per fresh container)
git config --global --add safe.directory /work
# 1. Get your branch into /work (origin remote-tracking refs may be absent —
# fetch straight into a local branch ref, don't rely on origin/<branch>):
git fetch origin <branch>:<branch> && git checkout <branch>
# 2. node + pnpm are NOT baked in (Debian 12 / x86_64). Install the x64 build:
cd /tmp && curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz \
-o node.tar.xz && tar -xf node.tar.xz && cp -r node-v22.14.0-linux-x64/* /usr/local/
corepack enable && corepack prepare pnpm@8.15.9 --activate # match packageManager
# 3. Deps + a FRESH runtime.js bundled from your branch (the harness runs it):
pnpm install --frozen-lockfile < /dev/null
pnpm --filter @nx.js/runtime bundle < /dev/null # -> packages/runtime/runtime.js
# 4. Build the host binary (clang-19/lld-19 + /opt/host V8/Skia/ada):
cd packages/runtime/test && cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang-19 -DCMAKE_CXX_COMPILER=clang++-19 && \
cmake --build build -j"$(nproc)" # -> build/nxjs-test
# 5a. Run ONE fixture directly (NO Chrome) — absolute paths; stdbuf because the
# binary idles until the fixture calls Switch.exit():
node packages/runtime/test/build-fixtures.mjs # fixtures/*.ts -> fixtures/build/*.js
stdbuf -oL ./packages/runtime/test/build/nxjs-test \
"$(pwd)/packages/runtime/runtime.js" \
"$(pwd)/packages/runtime/test/fixtures/build/<name>.js"
# 5b. Conformance vs Chrome for SPECIFIC fixtures (avoids the flaky full run).
# Needs Playwright Chromium + its apt libs (libnspr4 etc.):
pnpm --filter @nx.js/runtime exec playwright install chromium < /dev/null
apt-get update -qq && pnpm --filter @nx.js/runtime exec playwright install-deps chromium < /dev/null
# Filter by vitest test name "<fixture>: nxjs-test vs Chrome" (run from the
# runtime package dir; -t is a substring match so be specific):
cd /work/packages/runtime && npx vitest run --config test/vitest.config.ts \
-t "<fixture>: nxjs-test vs Chrome" < /dev/nullNotes / gotchas learned:
- AppleDouble
._*files: if/workwas ever rsync'd from macOS, stray._<fixture>.jssidecars can litterfixtures/build/. The conformance fixture-discovery globs*.jsand tries to execute them;nxjs-testchokes and the test hangs 30s then fails as._<fixture>: …. This is NOT a real failure —find packages/runtime/test/fixtures/build -name '._*' -delete, then re-run. (A real fixture's result line has no._prefix.) - Don't run the full
pnpm testunattended — some fixtures hit flaky public endpoints (echo.websocket.org) and can stall for many minutes. Use the-tfilter for the fixtures your change touches. - Some apt dev headers (e.g.
libturbojpeg0-dev libjpeg62-turbo-dev) may be missing in a fresh container and are needed to compileimage.cc; CI installs them via apt. - To symbolize a published crash, check out the published tag/commit in
/work, rebuildnxjs.nrothere (sameswitch-v8as CI), and addr2line. - A fixture that exits non-zero / segfaults: the harness loses block-buffered
stdout, so a real failure can masquerade as "empty output, all fixtures fail".
Run it directly with
stdbuf -oL(step 5a) to see the actual TAP + exit code. - The CI
pacman-packagesimage SHA is pinned in.github/workflows/ci.yml(both the Build and Test jobs). When the V8/Skia portlib is rebuilt, the user provides a new image digest; bump both references and (optionally) re-create the natecubenxdbgcontainer on the new image.
- Prefer a small, deterministic on-device repro (hello-world) over re-running
the full conformance/app suite — the full suite depends on flaky public
network endpoints (e.g.
echo.websocket.org). - After ANY native change, keep
source/main.ccand the hostpackages/runtime/test/src/main.ccin sync (and add new shared logic to a common.cccompiled by both, rather than mirroring — seesource/module.cc). - Build artifacts (
nxjs.nro,nxjs.elf,packages/runtime/runtime.js,apps/*/*.nro,apps/*/romfs/main.js*) are gitignored — never commit them.