Murasaki

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: 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
paths.dataDurable app data
paths.cacheRe-creatable cache data
paths.logsLog files
paths.tempTemporary staging data
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.

secondInstance(context, event)

Packaged macOS and Windows 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 currently delivered by murasaki dev or Linux.

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.

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 experimental 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. API routes are not a public network server; Murasaki protects them with the same app runtime session as the internal RPC endpoints.

Current limits

Node Main does not provide an imperative native-window creation API; declared windows are managed 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 single-instance delivery is available through secondInstance(); configured URL schemes and file associations are delivered through openRequested(). Windows portable archives, Linux, and murasaki dev do not install system association metadata. 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

On this page