Node Main
Long-lived Node lifecycle and typed renderer-to-Node functions with 'use main'.
Use Node Main for work that must live as long as the desktop application: database connections, filesystem watchers, sockets, worker pools, background queues, and cleanup that must finish before the native host exits.
Define the lifecycle
Create src/main.ts and default-export defineMain():
import { defineMain } from 'murasaki/main'
import { watch, type FSWatcher } from 'node:fs'
let watcher: FSWatcher | undefined
export default defineMain({
async ready({ paths, signal, isPackaged }) {
watcher = watch(paths.data, { recursive: true }, (_event, filename) => {
console.log('changed', filename)
})
signal.addEventListener('abort', () => watcher?.close(), { once: true })
console.log(isPackaged ? 'packaged main ready' : 'development main ready')
},
async beforeQuit({ reason }) {
// Return false only when a normal close really must be cancelled.
console.log('quit requested:', reason)
},
async secondInstance(_context, { argv, cwd }) {
// Packaged macOS/Windows/Linux: another launch was redirected here.
console.log('second launch:', { argv, cwd })
},
async openRequested(_context, event) {
// Registered URL schemes and file types arrive here after ready().
console.log('open targets:', event.targets)
},
async shutdown() {
watcher?.close()
watcher = undefined
},
})Murasaki detects src/main.ts by default. Configure another entry or cleanup
deadline in murasaki.config.ts:
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
main: {
entry: 'src/backend/main.ts',
shutdownTimeoutMs: 15_000,
},
})Set main: false to disable discovery even when src/main.ts exists.
Context reference
| Field | Meaning |
|---|---|
appId, productName, version | Resolved application identity |
isPackaged | false under murasaki dev, true in a bundle |
platform, arch | Runtime target (process.platform / process.arch) |
projectRoot | Project root in dev; packaged resource working directory in production |
resourcesPath | Read-only application resources location |
launch.argv, launch.cwd | Bounded raw arguments and working directory from the primary cold start |
paths.data | Durable app data |
paths.cache | Re-creatable cache data |
paths.logs | Log files |
paths.temp | Temporary staging data |
log | Structured rotating application logger and diagnostic report generator |
diagnostics | Local crash report capture—see Crash reports |
sidecars | Bounded supervisor for executable resources bundled with the app |
signal | Aborts after beforeQuit and before shutdown |
Quit reasons are window-close, app-quit, signal, restart, dev-reload,
and startup-failure. See the process model
for ordering and cancellation behavior.
Read primary launch arguments
The first application process receives its raw launch arguments in
context.launch. This includes arbitrary app flags that are not registered
deep links or file types:
import { defineMain } from 'murasaki/main'
export default defineMain({
async ready({ launch, paths }) {
if (launch.argv.includes('--no-sample-data')) {
console.log('start without seeded data', { dataDirectory: paths.data })
}
},
})In a packaged app, launch the executable with the flag. During development,
put application arguments after a standalone -- so Murasaki's own CLI flags
cannot be mistaken for app input:
murasaki dev -- --no-sample-datalaunch.argv contains at most 64 intact arguments; an argument larger than
8 KiB, input beyond the 16 KiB encoded-array limit, and excess input are
discarded consistently in development and packaged builds. Treat every value and launch.cwd as
untrusted local input. Registered URLs and files are also delivered in a
normalized form through openRequested(); do not process the same target in
both paths. A second process uses secondInstance() instead of replacing the
primary launch snapshot.
Logs and diagnostic reports
Use context.log instead of building an app-specific file logger. Records are
written as JSON Lines to paths.logs/murasaki-main.jsonl, rotated at 5 MiB,
and retain five rotated files by default.
import { defineMain } from 'murasaki/main'
export default defineMain({
async ready({ log }) {
log.info('database opened', { engine: 'sqlite', schemaVersion: 4 })
},
async shutdown({ log }) {
log.info('database closed')
},
})Create a bounded, user-shareable diagnostic snapshot only after the user opts in. It contains application/runtime metadata, bounded log tails, and optional app-owned state:
const reportPath = await context.log.createDiagnosticReport({
extra: { database: { integrity: 'ok' }, queueDepth: 0 },
})Fields whose names look like credentials—such as authorization, cookie,
password, secret, token, apiKey, or privateKey—are redacted. Values,
nesting, arrays, and included log tails are bounded, and the report is created
with owner-only permissions where the OS supports them. Redaction is a safety
net, not permission to log secrets: do not put access tokens, file contents,
or personal data in log messages or unstructured strings. Murasaki flushes the
logger during a normal bounded shutdown; call await log.flush() before an
app-owned crash/restart boundary when every queued record must be durable.
Crash reports
Separately from the opt-in snapshot above, Murasaki automatically captures
versioned local crash reports across three domains—bounded and redacted the
same way as context.log, and written to <paths.data>/crash-reports:
| Domain | Captured when |
|---|---|
node | An uncaught exception or unhandled rejection reaches Node Main. The report is written synchronously before the process crashes exactly as it would without Murasaki—nothing here changes that outcome. |
native | The native launcher panics, or the bundled Node process exits unexpectedly (crash, force-kill), with exit code/signal metadata. |
renderer | A production build's renderer throws an uncaught error or unhandled promise rejection. This is a no-op under murasaki dev, where DevErrorOverlay already surfaces the same two events. |
Enabled by default. Configure retention, or opt out entirely, in
murasaki.config.ts:
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
diagnostics: {
// crashReports: false,
keepReports: 20, // newest reports kept per app; out-of-range values clamp to 1-100
},
})Read captured reports back through context.diagnostics:
import { defineMain } from 'murasaki/main'
export default defineMain({
async ready({ diagnostics, log }) {
const reports = await diagnostics.listCrashReports()
log.info('crash reports on disk', { count: reports.length })
},
})A report is { reportVersion, domain, timestamp, appVersion, frameworkVersion, os, arch, message, stack?, extra? }. There is no minidump capture or native
symbolication—message/stack are exactly what the failing process observed.
Murasaki never uploads a report anywhere; draining them into a crash-reporting
service, if you want one, is application code. For example, on launch:
import { defineMain } from 'murasaki/main'
import * as Sentry from '@sentry/node'
Sentry.init({ dsn: process.env.SENTRY_DSN })
export default defineMain({
async ready({ diagnostics, log }) {
for (const { id } of await diagnostics.listCrashReports()) {
const report = await diagnostics.readCrashReport(id)
if (!report) continue
Sentry.captureException(new Error(report.message), {
tags: { domain: report.domain, appVersion: report.appVersion },
extra: { stack: report.stack, ...report.extra },
})
}
await diagnostics.clearCrashReports()
log.info('drained crash reports to Sentry')
},
})Supervise a bundled sidecar
Put helper executables in bundle.resources, then start them through
context.sidecars. Murasaki resolves the real executable below
resourcesPath, never invokes a shell, bounds arguments/environment/restarts,
and stops every live helper during shutdown.
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
bundle: {
resources: [{
from: 'sidecars/search-indexer',
to: 'sidecars/search-indexer',
executable: true,
}],
},
})import { defineMain } from 'murasaki/main'
export default defineMain({
async ready({ sidecars, signal, log }) {
const indexer = await sidecars.spawn({
name: 'search-indexer',
resource: 'sidecars/search-indexer',
args: ['--stdio'],
cwd: 'data',
restart: { maxRestarts: 3, delayMs: 1_000 },
})
indexer.onEvent((event) => {
if (event.type === 'stderr') log.warn('indexer stderr', { output: event.data })
})
signal.addEventListener('abort', () => void indexer.stop(), { once: true })
},
})resource must be a relative path whose resolved target stays below the
packaged resource root; symlink escapes and traversal are rejected. On POSIX
the resource must be executable. Use a platform-specific filename such as
search-indexer.exe in a target-specific build when necessary.
executable: true is mandatory for helper binaries: it makes Murasaki sign
the nested Mach-O/PE before sealing the app or installer. Signed packaging
fails closed if an unmarked bundle.resources file looks executable.
Working
directories are restricted to resources, data, cache, or temp.
Sidecars inherit Node Main's environment plus the explicit env entries, except
that Murasaki strips its runtime authority and signing secrets. Reserved keys
(MURASAKI_RUNTIME_TOKEN, MURASAKI_DEV_LAUNCH, MURASAKI_UPDATE_KEY,
MURASAKI_WINDOWS_CERTIFICATE_PASSWORD, and APPLE_APP_PASSWORD) cannot be
forwarded explicitly either. Do not start an untrusted executable or forward
renderer-controlled arguments without validation. Windows descendants remain covered by the launcher's Job
Object; normal stop sends termination first and escalates after a bounded
deadline.
secondInstance(context, event)
Packaged macOS, Windows, and Linux apps use a per-user, per-appId lock. When another
launch is redirected to the primary app, Murasaki focuses its window and calls
secondInstance() with:
| Field | Meaning |
|---|---|
event.argv | Arguments passed to the second launcher, including URL/file arguments supplied by the caller |
event.cwd | Working directory of the second launch |
Validate every argument before use. secondInstance() is the low-level process
activation hook: it receives raw launch arguments whether or not they match a
registered URL scheme or file type. It is not delivered by murasaki dev.
openRequested(context, event)
Use openRequested() for configured URL schemes and file associations. It
normalizes cold-start argv, second-instance argv, and native URL/file events
into typed URL/file targets, and runs only after ready() has completed.
On a second launch with a recognized target, both secondInstance() and
openRequested() run. Do not open the same item in both hooks: keep generic
activation behavior in secondInstance() and semantic URL/file handling in
openRequested().
See Deep links and file associations for the event shape, configuration, packaging behavior, and security requirements.
Control declared windows from Node Main
windows controls only the labels declared in murasaki.config.*. The calls
travel over a native-host-only loopback channel authenticated with the
per-launch runtime token; they do not grant a renderer any additional native
capability.
import { defineMain, windows } from 'murasaki/main'
const unsubscribe = windows.subscribe((event) => {
console.log(event.type, event.label, event.generation, event.state)
})
export default defineMain({
async secondInstance() {
await windows.show('main')
await windows.focus('main')
},
async openRequested() {
const report = await windows.get('report')
?? await windows.create('report')
console.log('report generation', report.generation)
},
async shutdown() {
unsubscribe()
},
})The manager provides list(), get(label), create(label), show(label),
hide(label), focus(label), and destroy(label). create() accepts only a
configured, currently dormant secondary label; it never accepts a URL or a
runtime capability list. destroy() releases a live secondary so the same
declaration can be created again. The primary cannot be created or destroyed
through this API.
subscribe() receives created, shown, hidden, focused, blurred, and
closed events. State snapshots and events include a monotonic generation
for the native instance of that label. Use both label and generation when
correlating long-running work across destroy/recreate. A snapshot also contains
the primary, visibility, focus, minimized, and maximized state.
Subscribe inside ready(), but do not await a window command there in this
first release. Native windows are attached after ready() completes, so
commands inside that hook reject instead of deadlocking startup. Issue them
from later lifecycle callbacks, background work, or a 'use main' function.
Windows remain declarative: this API cannot create an undeclared label or
override its route, network settings, or capability policy. Set a secondary's
createOnLaunch: false when it should start dormant. Use hide() for a live
window that will be shown again and destroy() to release its native resources.
Closing the primary remains part of the normal application quit lifecycle.
Lifecycle events currently cover create, visibility, focus, and close—not
move, resize, minimize, or maximize notifications.
Call Node from the renderer with 'use main'
A top-level 'use main' directive exposes the module's exported functions as
typed calls to the same Node module graph as src/main.ts.
'use main'
import { createHash } from 'node:crypto'
export async function sha256(text: string): Promise<string> {
if (typeof text !== 'string' || text.length > 1_000_000) {
throw new TypeError('text must be a string no larger than 1 MB')
}
return createHash('sha256').update(text).digest('hex')
}Import it normally from a client component. The renderer bundle receives a fetch stub; the Node implementation does not ship to the WebView.
'use client'
import { useState } from 'react'
import { sha256 } from '../backend/checksum'
export default function Page() {
const [digest, setDigest] = useState('')
return (
<button onClick={() => void sha256('Murasaki').then(setDigest)}>
Hash {digest && `(${digest.slice(0, 8)}…)`}
</button>
)
}Stream Main events to the renderer
Use the typed event channel for connection status, progress, device events, watcher output, or other values that originate in long-lived Node work:
import { defineMain, emitMainEvent } from 'murasaki/main'
export default defineMain({
async ready({ signal }) {
const timer = setInterval(() => {
emitMainEvent('relay.status', { connected: true, at: new Date() })
}, 1_000)
signal.addEventListener('abort', () => clearInterval(timer), { once: true })
},
})Subscribe in a client component:
'use client'
import { useEffect, useState } from 'react'
import { subscribeMainEvent } from 'murasaki/main-client'
export function RelayStatus() {
const [connected, setConnected] = useState(false)
useEffect(() => subscribeMainEvent<{ connected: boolean; at: Date }>(
'relay.status',
(event) => setConnected(event.connected),
), [])
return <output>{connected ? 'Connected' : 'Disconnected'}</output>
}Events use the same rich-value wire codec as 'use main' and travel over an
authenticated app-local SSE connection. EventSource reconnects after a
temporary disconnect, but events are live-only: there is no replay buffer or
durable queue. Store durable state in Node and expose a 'use main' snapshot
function when a subscriber must recover missed values.
'use main' is an RPC boundary, not an authorization boundary.
Treat every argument as untrusted renderer input: validate types, normalize
filesystem paths, and authorize access to application data inside the Node
function.
Wire values and limits
Calls use Murasaki's versioned wire codec. It supports primitives,
undefined, bigint, Date, Map, Set, cyclic plain objects/arrays,
ArrayBuffer, typed arrays, Blob, File, FormData, and structured
Error values.
- Maximum request or response payload: 32 MiB.
- Functions, symbols, and instances with custom prototypes are rejected.
- Calls are request/response today; use Main events for live push. Returning
AsyncIterabledirectly from a'use main'call is not implemented. - Export named
function,async function,const,let, orvarvalues. At runtime the selected export must be callable.
For large files, do not send the bytes through RPC. Pass a validated app-owned identifier or path and stream the data entirely within Node.
'use main' vs 'use server' vs API routes
| Choose | When |
|---|---|
'use main' | Typed imperative calls into the long-lived application backend |
'use server' | React action/form-shaped mutations using ActionState |
src/api/**/route.ts | Request/Response semantics, streaming bodies, HTTP method routing |
All three execute in the app-local Node runtime. They are not a public network
server: Murasaki authenticates the exact native window and checks its separate
main:*, action:*, or api:METHOD:/path backend grant before dispatch.
Current limits
Node Main can create and destroy only secondary window templates declared in
murasaki.config.*; it cannot construct an ad-hoc URL, capability policy, or
undeclared native window at runtime. Declared windows are managed through the
Node Main windows manager or from trusted renderers through murasaki/native.
Tray menus are available through that renderer API, while Node Main itself
still has no direct tray-menu or global-shortcut API. Packaged macOS, Windows,
and Linux single-instance delivery is available
through secondInstance(); configured URL schemes and file associations are
delivered through openRequested(). macOS bundles and Windows installers
register declared associations; Linux .deb packages install the generated
desktop metadata. Manually extracted Windows portable archives, AppDir/AppImage
artifacts, and murasaki dev do not perform that OS-level registration step.
Renderer-safe dialogs, clipboard, notifications, shell, and basic window commands are separately available from
murasaki/native behind the capabilities allowlist. Node packages may also
require explicit bundle.external / bundle.resources entries when they use
computed imports, dynamic native addons, or runtime-discovered assets; always
test the installed artifact on a clean machine, not only murasaki dev.