Building & Deployment#
Get your OneJS app running in standalone builds, WebGL, and stripped AOT targets like mobile.
How It Works#
When you build your Unity project, the JSRunnerBuildProcessor runs automatically and handles everything:
- Locates each JSRunner's
app.js.txtbundle and assigns it as a TextAsset - Copies
assets/toStreamingAssets/onejs/assets/(images, fonts, data files) - Extracts cartridge files into each runner's working directory (
~/@cartridges/)
Styles (CSS Modules and Tailwind) are embedded in the JavaScript bundle, so they work automatically in builds.
Standalone Builds#
Works out of the box. Build normally from File > Build Settings.
The JavaScript bundle is stored as a TextAsset in the JSRunner's instance folder, which sits alongside your scene. The folder is named after the runner's GameObject (e.g. App), with a numbered suffix only on name collision (App_1, App_2):
Assets/Scenes/YourScene/
└── App/
└── app.js.txt # TextAsset used in buildsIn the Editor, JSRunner loads from the filesystem (enabling hot reload). In builds, it uses this TextAsset.
WebGL Builds#
WebGL works with some differences:
- JavaScript runs in the browser's V8/SpiderMonkey engine (JIT compiled, not QuickJS)
- Uses the browser's native
requestAnimationFrame - Full browser API access
Build normally and deploy to a web server.
Embedding in a website#
The OneJS runtime shares the page's global scope and reroutes setTimeout/setInterval/requestAnimationFrame through its own frame loop while the app runs. This is cleaned up automatically on shutdown: tearing the player down with unityInstance.Quit() (for example in a React unmount cleanup) stops the OneJS loop, hands pending timers back to the browser, restores the native timer functions, and ignores stale JS callbacks that fire after the C# side is gone. Timers the host page creates while the game runs keep firing after it quits.
One thing OneJS cannot do for you: it has no way to tell your app's timers from the host page's, so an interval your bundle never cleans up is handed to the browser too and keeps running (its C# calls are ignored, but it runs forever). Create timers inside React effects, which are cleaned up automatically on teardown, rather than at module level.
Assets in Builds#
Image and data files from your assets/ folder are automatically copied to StreamingAssets/onejs/assets/ during the build. This means <Image src="logo.png" /> and loadImage("logo.png") work identically in Editor and builds.
The destination is flushed before each build, so deleted or renamed files won't linger as stale copies.
See the Asset Loading guide for more details.
Code Stripping (link.xml)#
On desktop (Mono) almost anything works. But AOT and IL2CPP targets (iOS, Android, WebGL) strip unused C# code, and OneJS calls into C# dynamically from JS through the CS proxy. The stripper can't see those calls, so types your JS relies on can get removed. A call that runs fine in the Editor then throws at runtime in the build:
[QuickJS] Method not found: System.IO.Path.Combine
[QuickJS] Type not found: UnityEngine.SomeType"Method not found" means the type survived but that method was stripped. "Type not found" means the whole class was stripped. Both have the same fix: preserve it with a link.xml.
List the assembly of every class you call dynamically from JS in a link.xml placed anywhere under Assets/. Unity merges every link.xml in the project:
<linker>
<assembly fullname="mscorlib" preserve="all" />
<assembly fullname="OneJS.Runtime" preserve="all" />
<assembly fullname="UnityEngine.CoreModule" preserve="all" />
<assembly fullname="UnityEngine.PhysicsModule" preserve="all" />
<assembly fullname="UnityEngine.TextRenderingModule" preserve="all" />
<assembly fullname="UnityEngine.UIElementsModule" preserve="all" />
<assembly fullname="UnityEngine.IMGUIModule" preserve="all" />
<assembly fullname="Unity.Mathematics" preserve="all" />
</linker>Preserve every assembly you reach from JS that your own C# doesn't already reference: mscorlib (the BCL: collections, System.IO, etc.), the UnityEngine.* modules whose APIs you use, Unity.Mathematics, and your own gameplay assembly. Start broad with whole-assembly preserve="all", then tighten to specific types later if build size matters. OneJS ships its own link.xml for its runtime internals, so you only handle your app's calls.
A few link.xml gotchas that trip people up the first time:
- The file must be named
link.xml, notlinker.xmlorlinks.xml. - The extension must be
.xml, not something like.xml.txt. - The root node must be
<linker>.
Quick way to confirm stripping is the culprit: set Player Settings > Other Settings > Managed Stripping Level to Minimal (or Disabled) and rebuild. If the error disappears, add the missing entries to your link.xml, then turn stripping back on. Don't ship with stripping off, it bloats the build.
Production Checklist#
Minify Your Code#
// esbuild.config.mjs
await esbuild.build({
minify: true,
treeShaking: true,
drop: ["console", "debugger"],
})Test Before Building#
Run npm run build and verify your app works in the Editor before making a Unity build.
Check Console#
Watch the Unity Console for build processor messages:
[JSRunner] Copied 3 asset file(s) to StreamingAssets for: App
[JSRunner] Build preprocessing complete. Processed 1 runner(s), created 1 asset(s), copied 3 asset file(s) to StreamingAssets.Platform-Specific Code#
const isWebGL = typeof window !== "undefined"
function App() {
useEffect(() => {
if (isWebGL) {
console.log("Running in browser")
} else {
console.log("Running in Unity")
}
}, [])
}Troubleshooting#
"Bundle not found" in Build#
- Ensure
npm run buildcompleted successfully - Verify
app.js.txtexists in the instance folder - Check Unity Console for build processor errors
"Script error" in WebGL#
- Open browser DevTools for the full error message
- Check for CORS issues if loading external resources
- Verify all imports are bundled (not externalized)
An await never returns in WebGL#
Update to OneJS 3.2.1 or later. Before that release no C# task ever settled its promise in a web build: the promise neither resolved nor rejected, and nothing was logged. Anything returning a Task was affected, which includes audio.load, <Image src="https://...">, loadImageFromUrl, loadResourceAsync, and your own async Task methods. fetch and WebSocket were fine, since WebGL uses the browser's own.
It only ever failed in web builds, so an app that works in the Editor and on desktop tells you nothing. Test anything asynchronous in an actual WebGL build. See Async C# Methods.
If it is specifically a remote image that never appears, also update onejs-react to 0.1.44 or later: see Asset Loading.
Assets Missing in Build#
- Verify files are in the
assets/folder inside your working directory (~/) - Check the build log for "Copied N asset file(s) to StreamingAssets"
- Ensure the JSRunner is enabled and its GameObject is active
Size Optimization#
Analyze your bundle. esbuild-visualizer needs a metafile, which the default config doesn't emit, so capture one first:
// esbuild.config.mjs
const result = await esbuild.build({ ...config, metafile: true })
fs.writeFileSync("meta.json", JSON.stringify(result.metafile))Then visualize it:
npx esbuild-visualizer --metadata meta.json --openCommon optimizations:
- Import only what you need from libraries
- Remove unused exports with tree shaking
- Use
drop: ["console"]in production builds