Murasaki
Guides

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():

src/main.ts
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:

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

FieldMeaning
appId, productName, versionResolved application identity
isPackagedfalse under murasaki dev, true in a bundle
platform, archRuntime target (process.platform / process.arch)
projectRootProject root in dev; packaged resource working directory in production
resourcesPathRead-only application resources location
launch.argv, launch.cwdBounded raw arguments and working directory from the primary cold start
paths.dataDurable app data
paths.cacheRe-creatable cache data
paths.logsLog files
paths.tempTemporary staging data
logStructured rotating application logger and diagnostic report generator
diagnosticsLocal crash report capture—see Crash reports
sidecarsBounded supervisor for executable resources bundled with the app
signalAborts 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:

src/main.ts
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-data

launch.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.

src/main.ts
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:

DomainCaptured when
nodeAn 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.
nativeThe native launcher panics, or the bundled Node process exits unexpectedly (crash, force-kill), with exit code/signal metadata.
rendererA 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:

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:

src/main.ts
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:

src/main.ts
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.

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  bundle: {
    resources: [{
      from: 'sidecars/search-indexer',
      to: 'sidecars/search-indexer',
      executable: true,
    }],
  },
})
src/main.ts
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:

FieldMeaning
event.argvArguments passed to the second launcher, including URL/file arguments supplied by the caller
event.cwdWorking 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.

src/main.ts
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.

src/backend/checksum.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.

src/app/page.tsx
'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:

src/main.ts
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 AsyncIterable directly from a 'use main' call is not implemented.
  • Export named function, async function, const, let, or var values. 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

ChooseWhen
'use main'Typed imperative calls into the long-lived application backend
'use server'React action/form-shaped mutations using ActionState
src/api/**/route.tsRequest/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.

Next

Improve this page on GitHub

On this page