Scope. This document describes the core platform of Streamlabs Desktop — the process/window model, the services layer, cross‑window communication, and state management. It deliberately does not cover feature domains (streaming, scenes/sources, platforms, widgets, etc.); those may get their own docs later.
Conventions. Non‑obvious claims name the source file (and the relevant function or symbol) rather than a line number — grep the symbol to find the code, and the reference stays valid as the code moves. Sharp edges and non‑obvious invariants are collected in Known wrinkles.
Keeping this current. Treat it like the matklad model: document the things that change slowly (the boundaries and invariants) and point at code for the rest. When a change alters one of those boundaries, update this file in the same PR.
Streamlabs Desktop is an Electron app. Electron gives us two kinds of process:
- one main process (Node.js) — this is
main.js, the entry point named inpackage.json"main". It owns windows, native lifecycle, the updater, logging, and routes all inter‑window IPC. It is not a UI. - many renderer processes — Chromium windows, each running the same compiled
bundle but parameterised by a
windowIdURL query (main.js, theloadURL(...?windowId=…)calls).
The renderers are not peers of equal responsibility. Exactly one — the worker window — runs the entire services layer and hosts the native OBS backend. Every other window is a UI client that calls services remotely.
┌─────────────────────────┐ ┌──────────────────────────────┐
│ UI windows (renderers) │ │ worker window (renderer) │
│ main / child / one-off │ │ • runs ALL services │
│ • Vue + React UI │ │ • hosts obs-studio-node │
│ • InternalApiClient │ │ • executes calls; emits │
│ turns calls into IPC │ │ results + events │
└────────────┬────────────┘ └───────────────┬──────────────┘
│ │
│ renderers never message each other directly —
│ every request / response / event hops through main.js
│ │
┌────────────▼────────────────────────────────────────▼─────────────┐
│ Electron main process — main.js (Node) │
│ The "router": a process in its own right (Electron's main │
│ process), but NOT a window. Forwards each service-request from a │
│ UI window to the worker, routes the response back, and relays │
│ vuex-mutation / services-message between windows. │
└────────────────────────────────────────────────────────────────────┘
The single most misunderstood point: the main process (main.js) is the
router — not the main window. The main window is just another UI client,
exactly like the child window. Service calls from any UI window go through the
main process to the worker window; the main window is never in that path.
(main.js, sendRequest and the services-request handlers; the worker is the
only window that runs services — app/services-manager.ts init, and the worker
branch of the DOMContentLoaded handler in app/app.ts.)
All windows load the same bundle; behaviour is selected by windowId (app/app.ts,
the root component's render switch on windowId).
| Window | Created | Role |
|---|---|---|
| worker | main.js (workerWindow creation) |
Invisible (show: false), persistent. Runs the entire services layer and hosts OBS (app/app.ts, the isWorkerWindow() branch). |
| main | main.js (mainWindow creation) |
The primary application UI. A service client, not a host. |
| child | created/kept warm by WindowsService |
Always running, hidden until needed (e.g. Source Properties) because spinning up a window is slow (README.md, Development). |
| one‑off | on demand | Transient windows: projectors, pop‑outs, etc. Also clients. |
Plus other JS runtimes for Apps, embedded webviews, and the like (README.md,
Development).
Security posture worth knowing: worker and main windows run with
nodeIntegration: true and contextIsolation: false (main.js, the
BrowserWindow webPreferences). This is why renderer code can require Node
modules and use @electron/remote directly. Treat anything loaded into these
windows as fully privileged.
Shutdown ordering is deliberate: closing the main window triggers a
shutdown message to the worker with a 10‑second grace period (main.js,
mainWindow.on('close')); the worker window refuses to close first so test
teardown and App.stop() behave (main.js, workerWindow.on('close')).
Domain logic lives in services under app/services/. A service is a strict
singleton; the base class is app/services/core/service.ts.
Service enforces single‑instance construction with a symbol guard — you cannot
new a service yourself (service.ts, createInstance and the singletonEnforcer
guard). Access is always through the static instance getter (service.ts).
Every service is hand‑registered in app/app-services.ts. ServicesManager
(app/services-manager.ts) holds that registry (the services map) and resolves
names to instances/helpers (getResource). A service that isn't registered cannot
be resolved.
Normal application code reaches a service through the @Inject() decorator, which
defines a lazy getter resolving the service by property name (or an explicit name)
at access time (app/services/core/injector.ts, Inject).
getResource<T>(name) (injector.ts) and ServicesManager.getResource
(services-manager.ts) also exist, but these are plumbing — for infrastructure
(the IPC layer, serialization) and for poking at services from a dev console.
Application code should use @Inject(), not getResource.
Created via the factory createInstance (service.ts), in this order:
init()— once per application lifetime.mounted()— once per window.afterInit()— once per app, after observers are listening.
(All three are defined on Service in service.ts.) Services can observe another
service's initialization with @InitAfter('Other'); ServicesManager.initObservers
wires this up off the serviceAfterInit subject in the worker.
ServicesManager.init (services-manager.ts) branches on whether it is running in
the worker:
- Non‑worker windows: install an IPC proxy over every service via
Service.setupProxy(...), and replace the init function with a no‑op viaService.setupInitFunction(...). So in a UI window, "calling a service" actually produces an IPC request, and services do not run theirinit()locally. - Worker window: no proxy; services run for real, and the
InitAfterobserver wiring is enabled.
This single branch is what makes the same service class behave as a local object in the worker and a remote stub everywhere else.
This is the heart of the platform. A method call in a UI window has to execute in
the worker and return a serializable result. The path has three parts: a client
proxy, the main‑process router, and a worker‑side executor. The wire format is
JSON‑RPC (app/services/api/jsonrpc).
app/services/api/internal-api-client.ts. Instantiated in every non‑worker (UI)
window — main, child, and one‑off alike — by the proxy setup in
ServicesManager.init (services-manager.ts). The main window is not special
here: it goes through the same InternalApiClient as any other UI window.
applyIpcProxy wraps a service in a Proxy (internal-api-client.ts). When you
call a method it builds a JSON‑RPC request and ships it over IPC. There are three
call shapes, selected by how you access the method (all handled in
getRequestHandler):
| You write | Transport | Returns | When |
|---|---|---|---|
Service.actions.foo() |
ipcRenderer.send('services-request-async') with noReturn:true |
void |
Default. Fire‑and‑forget. |
Service.actions.return.foo() |
same async channel, fetchMutations:true, noReturn:false |
Promise<result> (resolved later via the services-response-async handler in listenWorkerWindowMessages) |
Need a return value. Use sparingly. |
Service.foo() |
ipcRenderer.sendSync('services-request') |
result synchronously | Avoid — blocks the UI process. Logs a warning in dev. |
The actions/return accessors are matched literally in the applyIpcProxy
proxy. Methods marked __executeInCurrentWindow (via the @ExecuteInCurrentWindow()
decorator) bypass the proxy and run locally — this is how, e.g., Realm connections
are made per‑process (see §5.3).
Arguments and results that contain service helpers are serialized by reference
({_type:'HELPER', resourceId}) and re‑wrapped on the other side (in
getRequestHandler and handleResult). Helpers themselves can't be proxied —
attempting to call a helper method through the client throws (in applyIpcProxy).
A UI window's ipcRenderer send goes to ipcMain in main.js, never directly to
another renderer. main.js forwards every request to the worker and remembers how
to reply:
- The
services-requestandservices-request-asynchandlers both callsendRequest, which doesworkerWindow.webContents.send('services-request', …)and stores the originatingeventkeyed by request id (main.js). - When the worker replies
services-response, theservices-responsehandler routes it back: sync requests resolve viaevent.returnValue, async viaevent.reply('services-response-async', …)(main.js).
There's a standing intent to eventually make renderers talk to the worker without
round‑tripping the main process (see the comment near getWorkerWindowId in
main.js), but today everything is proxied through it.
The worker registers a single handler, ipcRenderer.on('services-request', …), in
IpcServerService.listen (app/services/api/ipc-server/ipc-server.ts), which
executes the request via InternalApiService.executeServiceRequest and sends a
services-response unless the request was noReturn.
executeServiceRequest lives in the shared base app/services/api/rpc-api.ts. Its
chain executeServiceRequest → handleServiceRequest → serializePayload looks up
the resource, invokes the method, and serializes the payload into something
transferable:
- a plain value → returned as‑is.
- an RxJS
Observable→ subscribed once; a{_type:'SUBSCRIPTION', emitter: 'STREAM'}token is returned, and subsequent emissions are pushed asservices-messageevents (serializePayload). - a
Promise→ a{_type:'SUBSCRIPTION', emitter:'PROMISE'}token is returned immediately; when it settles,sendPromiseMessageemits aservices-messageevent carrying the resolution/rejection. - a
Service/ helper /RealmObject→ serialized by reference (inserializePayload).
Events (stream emissions, promise settlements) flow worker → main process →
all non‑worker windows via services-message (ipc-server.ts sendEvent;
main.js the services-message handler; internal-api-client.ts
listenWorkerWindowMessages).
- UI:
SomeService.actions.return.doThing(arg). InternalApiClientsendsservices-request-asyncwithfetchMutations:true(getRequestHandler).main.jsforwards to the worker, remembers the reply target (theservices-request-asynchandler).- Worker
IpcServerServicerunsRpcApi.executeServiceRequest; the method returns aPromise, so the worker first replies with aPROMISEsubscription token plus any buffered mutations (handleServiceRequest/serializePayload). - When the worker‑side promise settles, a
services-messagePROMISE event is broadcast; the client matches it by id and resolves the caller's promise (listenWorkerWindowMessages).
State is mid‑migration between two systems. Pick by scope:
- UI‑only state → React local state (
useState/hooks). - Service state that must sync across windows/processes, or must persist to disk across application runs → Realm (§5.3). Realm covers both: it has a persistent (on‑disk) database and an ephemeral (in‑memory) one.
- Legacy → Vuex via
StatefulService(§5.2). Still pervasive; maintained where it exists, not chosen for new state.
Because services execute in the worker but UI renders in other windows, the UI needs a local, reactive copy of state — it can't synchronously reach across processes on every render. The two systems solve this differently: Vuex replicates state by broadcasting mutations over IPC; Realm shares state at the database layer, with each process holding its own connection.
app/services/core/stateful-service.ts + app/store/index.ts.
- A
StatefulService<TState>gets an auto‑generated Vuex module; read state viathis.state, which is just the service's slice of the store (StatefulService.stateinstateful-service.ts). Modules are assembled increateStore(store/index.ts). - State is mutated only through
@mutation()methods (mutation/registerMutationinstateful-service.ts). Mutations must be pure — touch onlythis.stateand their arguments, no side effects, no async. In dev aProxyinsideregisterMutationenforces this and throws on any other access. The reason: a mutation has to be replayable in any window from its serialized payload, so it can't depend on worker‑only context. - Replication: the store‑sync plugin (
store/index.ts) subscribes to local commits and, for any non‑__vuexSyncIgnoremutation, both feeds it to the worker's mutation buffer and broadcasts it to other windows. The broadcast is queued and flushed on a timer to coalesce re‑renders (sendMutationToRendererWindows/flushMutations). The main process relaysvuex-mutationto every other registered window (main.js, thevuex-mutationhandler). - New window bootstrap: a freshly opened window registers (the
vuex-registersend instore/index.ts), the main process asks the worker to dump current state (main.js, thevuex-registerhandler), and the window applies it viaBULK_LOAD_STATE(thevuex-loadStatehandler instore/index.ts). Ordering invariant: a renderer window ignores incoming mutations until after that bulk load (storeCanReceiveMutationsinstore/index.ts), so early mutations are intentionally dropped to avoid applying deltas to a state that hasn't loaded yet. - Consistency on synchronous reads: when a UI window makes a synchronous
service call, the worker buffers any mutations produced during the call and
attaches them to the response (
handleServiceRequestwithstartBufferingMutations/stopBufferingMutationsinrpc-api.ts), and the client commits them immediately before returning (getRequestHandlerininternal-api-client.ts). This keeps the caller's local store consistent with the value it just received without waiting for the async broadcast.
app/services/realm.ts, with a React hook at
app/components-react/hooks/realm.ts.
- Two databases: a persistent on‑disk realm (
persistent.realmunderuserData) and an ephemeral in‑memory realm (RealmService,persistentConfig/ephemeralConfiginrealm.ts). ARealmObjectsubclass declares which it belongs to at registration (registerObject). Choose the persistent DB for state that must survive across application runs (it's written to disk); the ephemeral DB for cross‑process state that needn't persist. - Each process opens its own connections —
connect()andgetDb()are marked@ExecuteInCurrentWindow()so they run locally rather than being proxied to the worker (realm.ts); the worker triggers the initialRealmService…connect()during bootstrap (app/app.ts). Because every process points at the same realm(s), Realm propagates changes across all connections — that's the cross‑process sync, achieved without our IPC mutation broadcast. - Across the service RPC, a
RealmObjectis passed by reference ({_type:'REALM_OBJECT', resourceId, realmType}) and reconstructed on the other side viaRealmService.registeredClasses[type].fromId(...)(serializePayloadinrpc-api.ts;handleResultininternal-api-client.ts). The receiving window then reads through its own local connection.
Note: the ephemeral realm still propagates across processes despite being in‑memory — a file‑backed
inMemoryrealm is shared by path, so every process's connection observes the same data.
Cross‑service and cross‑window notifications use RxJS Subjects. Within the worker
they're ordinary subscriptions; when a subscription crosses the RPC boundary it
becomes a STREAM subscription delivered over services-message (§4.3).
These are real, load‑bearing quirks an agent (or human) will trip over.
window['servicesManager']global (production plumbing — leave it alone). Set increateStore(store/index.ts, "This is bad and I should feel bad") to break an import cycle. It is load‑bearing: read byViewHandler.getServiceViews(stateful-service.ts, which backs cross‑serviceviewsaccess in ~15+ services) and byservice-helper.ts. Those existing uses stay. Do not add new ones, and never reach for it from application‑level services code.window.sm— dev console only. A debug handle on the services manager, set only in dev mode or underSLOBS_PRODUCTION_DEBUG(services-manager.ts,init). It's for poking at services from the console; never use it in real code or plumbing.- Sync IPC blocks the UI.
Service.foo()(no.actions) usessendSyncand freezes the calling renderer until the worker replies (main.js,sendRequest;internal-api-client.ts,getRequestHandler). Only acceptable where truly necessary. - Helpers can't be proxied. Calling a helper method through the IPC client
throws by design (
internal-api-client.ts,applyIpcProxy). strictNullChecksis off globally with an opt‑in subset (strict-null-check-files/,SLOBS_STRICT_NULLS). Don't flip it repo‑wide.- Privileged renderers.
nodeIntegration: true/contextIsolation: false(main.js, theBrowserWindowwebPreferences).
| File | Responsibility |
|---|---|
main.js |
Electron main process: window creation, IPC routing, mutation relay, updater, logging, shutdown. |
app/app.ts |
Renderer bootstrap; worker‑only OBS init; Realm connect; Vue root. |
app/services/core/service.ts |
Singleton base, lifecycle, actions proxy. |
app/services/core/injector.ts |
@Inject() and getResource. |
app/services/core/stateful-service.ts |
Vuex‑backed services, @mutation(), mutation‑purity proxy, ViewHandler. |
app/app-services.ts |
The service registry. |
app/services-manager.ts |
Instance management; worker/non‑worker proxy split; InitAfter wiring. |
app/store/index.ts |
Vuex store, mutation replication, bulk‑load bootstrap. |
app/services/api/internal-api-client.ts |
Client side of the service RPC (UI windows). |
app/services/api/ipc-server/ipc-server.ts |
Worker side: receives requests, returns responses/events. |
app/services/api/rpc-api.ts |
Request execution + payload serialization (Promises, Observables, helpers, Realm). |
app/services/realm.ts |
Realm databases, per‑process connections, cross‑process state. |
Maintenance: this document covers the slow‑moving core‑platform boundaries and invariants. When a change moves one of them, update this file (and its file + symbol references) in the same PR.