Murasaki
ガイド

Node Main

長寿命な Node のライフサイクルと、'use main' によるレンダラーから Node への型付き関数呼び出し。

Node Main は、デスクトップアプリケーションと同じ期間生存する処理のために使います。データベース接続、ファイルシステムウォッチャー、ソケット、ワーカープール、バックグラウンドキュー、そしてネイティブホストが終了する前に完了させておく必要があるクリーンアップ処理などが対象です。

ライフサイクルを定義する

src/main.ts を作成し、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 }) {
    // 通常の close を本当に止める必要がある場合だけ false を返す。
    console.log('quit requested:', reason)
  },

  async secondInstance(_context, { argv, cwd }) {
    // packaged macOS / Windows / Linux: 別 launch が primary に転送された。
    console.log('second launch:', { argv, cwd })
  },

  async openRequested(_context, event) {
    // 登録済み URL scheme / file type は ready() の完了後に届く。
    console.log('open targets:', event.targets)
  },

  async shutdown() {
    watcher?.close()
    watcher = undefined
  },
})

Murasaki はデフォルトで src/main.ts を検出します。別のエントリファイルやクリーンアップ期限は murasaki.config.ts で設定できます。

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  main: {
    entry: 'src/backend/main.ts',
    shutdownTimeoutMs: 15_000,
  },
})

src/main.ts があっても無効化する場合は main: false にします。

コンテキスト

フィールド意味
appId, productName, version解決済みのアプリケーション識別情報
isPackagedmurasaki dev では false、バンドル時は true
platform, arch実行環境 (process.platform / process.arch)
projectRoot開発時はプロジェクトルート、本番では同梱リソースの作業ディレクトリ
resourcesPath読み取り専用のアプリケーションリソースのパス
launch.argv, launch.cwd最初の起動で渡された、上限付きの未加工引数と作業ディレクトリ
paths.data永続アプリデータ
paths.cache再生成できるキャッシュ
paths.logsログファイル
paths.temp一時ファイルの保存先
logローテーション対応の構造化ログと診断レポート生成
diagnosticsローカルのクラッシュレポート — クラッシュレポート参照
sidecarsアプリに同梱された実行ファイルを管理する、上限付きのスーパーバイザー
signalbeforeQuit の後、shutdown の前に中断します

終了理由は window-closeapp-quitsignalrestartdev-reloadstartup-failure です。順序とキャンセルの扱いはプロセスモデルを参照してください。

最初の起動引数を読む

最初のアプリケーションプロセスに渡された未加工の引数は、context.launch から取得できます。これには、登録済みの Deep Link やファイル形式に該当しない、アプリ独自のフラグも含まれます。

src/main.ts
import { defineMain } from 'murasaki/main'

export default defineMain({
  async ready({ launch, paths }) {
    if (launch.argv.includes('--no-sample-data')) {
      console.log('サンプルデータなしで起動', { dataDirectory: paths.data })
    }
  },
})

バンドルされたアプリでは、実行ファイルへ直接フラグを渡します。開発時は、Murasaki 自身の CLI オプションと混同されないよう、単独の -- より後ろにアプリケーション用の引数を書きます。

murasaki dev -- --no-sample-data

launch.argv が保持するのは最大 64 個の完全な引数です。8 KiB を超える引数、JSON 配列としてエンコードした場合に 16 KiB を超える部分、件数の上限を超えた入力は、開発時・バンドル後のいずれでも同じ規則で破棄されます。すべての値と launch.cwd は、信頼できないローカル入力として検証してください。登録済みの URL とファイルは、正規化された形式で openRequested() にも届くため、同じ対象を両方で処理しないでください。2 つ目のプロセスの引数は、最初の launch スナップショットを置き換えるのではなく、secondInstance() へ渡されます。

ログと診断レポート

アプリ独自のファイルロガーを作る代わりに context.log を使用します。レコードは JSON Lines として paths.logs/murasaki-main.jsonl に書き込まれ、デフォルトでは 5 MiB でローテーションし、直近 5 ファイルを保持します。

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')
  },
})

ユーザーが明示的に同意した場合にのみ、共有可能な上限付きの診断スナップショットを生成してください。アプリケーション / ランタイムのメタデータ、上限付きのログ末尾、任意のアプリ管理下の状態を含められます。

const reportPath = await context.log.createDiagnosticReport({
  extra: { database: { integrity: 'ok' }, queueDepth: 0 },
})

authorizationcookiepasswordsecrettokenapiKeyprivateKey など、認証情報に見えるフィールド名は自動的にマスクされます。値、ネスト、配列、含まれるログ末尾にはそれぞれ上限があり、OS が対応していればレポートは所有者のみが読めるパーミッションで作成されます。ただし、このマスク処理は安全網であって、機密情報を記録してよいという意味ではありません。アクセストークン、ファイルの内容、個人情報を、メッセージや非構造化文字列に書き込まないでください。通常の上限付きシャットダウンでは Murasaki がロガーをフラッシュします。アプリ独自のクラッシュ / 再起動の境界ですべてのレコードを確実に永続化したい場合は、事前に await log.flush() を呼び出してください。

クラッシュレポート

上記のオプトイン方式のスナップショットとは別に、Murasaki は 3 つのドメインにわたって、バージョン管理されたローカルのクラッシュレポートを自動的にキャプチャします。context.log と同じ方法で上限が設けられ、マスク処理された上で <paths.data>/crash-reports に書き込まれます。

ドメインキャプチャされるタイミング
nodeNode Main で未処理の例外または未処理の Promise の拒否が発生したとき。レポートはプロセスがクラッシュする前に同期的に書き込まれます — Murasaki の有無にかかわらず、クラッシュ自体の挙動は変わりません。
nativeネイティブランチャーがパニックした、または同梱の Node プロセスが予期せず終了(クラッシュ、強制終了)したとき。終了コード / シグナルのメタデータを伴います。
renderer本番ビルドのレンダラーで未処理のエラーまたは未処理の Promise 拒否が発生したとき。murasaki dev では何も行いません(同じ 2 つのイベントは、すでに DevErrorOverlay が表示しているためです)。

デフォルトで有効になっています。保持件数の調整や無効化は murasaki.config.ts で行います。

murasaki.config.ts
export default defineConfig({
  appId: 'com.example.notes',
  productName: 'Notes',
  diagnostics: {
    // crashReports: false,
    keepReports: 20, // app毎に保持する最新report数。範囲外の値は1-100へclampされる
  },
})

キャプチャされたレポートは 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 })
  },
})

レポートは { reportVersion, domain, timestamp, appVersion, frameworkVersion, os, arch, message, stack?, extra? } という形式です。minidump のキャプチャやネイティブのシンボリケーションは行いません — message / stack は、失敗したプロセスがそのまま観測した値です。Murasaki はレポートをどこにもアップロードしません。クラッシュレポーティングサービスへ流し込みたい場合は、アプリケーションコード側の責務です。たとえば起動時に次のように実装します。

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')
  },
})

同梱サイドカーを監督する

ヘルパー実行ファイルを bundle.resources に配置し、context.sidecars から起動します。Murasaki は実体のパスが resourcesPath 配下にあることを検証し、シェルを経由せずに起動し、引数 / 環境変数 / 再起動回数の上限を設け、シャットダウン時には稼働中のヘルパーをすべて停止します。

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 は相対パスのみを受け付け、解決後の実体がパッケージ済みリソースルートの内側に収まっている必要があります。シンボリックリンクによる脱出やパストラバーサルは拒否され、POSIX では実行可能ビットも要求されます。必要に応じて、ターゲット別ビルドで search-indexer.exe のようなプラットフォーム固有のファイル名を使用してください。ヘルパーバイナリには executable: true の指定が必須です。これにより、アプリ / インストーラーを封印する前にネストした Mach-O / PE へ署名します。executable を指定していない bundle.resources が実行可能ファイルに見える場合、署名処理は fail-closed(安全側に倒して停止)します。 作業ディレクトリとして指定できるのは resourcesdatacachetemp のみです。サイドカーは Node Main の環境変数と明示的な env を継承しますが、Murasaki のランタイム権限用トークンと署名用シークレットは除外されます。MURASAKI_RUNTIME_TOKENMURASAKI_DEV_LAUNCHMURASAKI_UPDATE_KEYMURASAKI_WINDOWS_CERTIFICATE_PASSWORDAPPLE_APP_PASSWORD は、明示的な env にも指定できません。信頼できない実行ファイルを起動せず、レンダラー由来の引数は必ず検証してください。Windows では子孫プロセスもランチャーの Job Object に含まれ、通常の停止処理はまず終了シグナルを送り、上限時間を超えると強制終了に進みます。

secondInstance(context, event)

パッケージ化された macOS / Windows / Linux アプリは、ユーザーごと・appId ごとのロックを使用します。別の起動がプライマリアプリへリダイレクトされると、Murasaki はそのウィンドウをフォーカスし、次の値を渡して secondInstance() を呼び出します。

フィールド意味
event.argv2 回目のランチャーに渡された引数。呼び出し元が渡した URL / ファイル引数を含む
event.cwd2 回目の起動の作業ディレクトリ

使用前に、すべての引数を検証してください。secondInstance() は低レベルなプロセスアクティベーションフックであり、登録済みの URL スキームやファイルタイプに一致するかどうかに関わらず、未加工の起動引数をそのまま受け取ります。murasaki dev ではこのフックは配信されません。

openRequested(context, event)

設定済みの URL スキームやファイルアソシエーションには openRequested() を使用します。コールドスタート時の argv、2 回目のインスタンス起動時の argv、ネイティブの URL / ファイルイベントを、型付きの URL / ファイルターゲットへ正規化し、ready() の完了後にだけ実行されます。

認識済みのターゲットを伴う 2 回目の起動では、secondInstance()openRequested() の両方が実行されます。同じ項目を両方で開かないようにしてください。汎用的なアクティベーション処理は secondInstance() に、URL / ファイルの意味的な処理は openRequested() に置きます。

イベントの形状、設定、パッケージング時の挙動、セキュリティ要件については、Deep Link と File Associationを参照してください。

Node Main から宣言済みウィンドウを操作する

windowsmurasaki.config.* で宣言したラベルだけを操作します。呼び出しは、起動ごとのランタイムトークンで認証された、ネイティブホスト専用のループバックチャネルを経由し、レンダラーに追加のネイティブケイパビリティを付与することはありません。

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()
  },
})

このマネージャーは list()get(label)create(label)show(label)hide(label)focus(label)destroy(label) を提供します。create() が受け付けるのは、設定済みで現在休止中のセカンダリラベルだけであり、URL やランタイムケイパビリティのリストは受け取りません。destroy() で稼働中のセカンダリのネイティブリソースを解放すれば、同じ宣言を再作成できます。プライマリはこの API で作成・破棄できません。

subscribe() には createdshownhiddenfocusedblurredclosed の各イベントが届きます。状態のスナップショットとイベントには、そのラベルのネイティブインスタンスを表す、単調増加する generation が含まれます。destroy / recreate をまたぐ長時間処理を対応付ける際は、ラベルと generation の両方を使用してください。スナップショットには、プライマリ、表示、フォーカス、最小化、最大化の各状態も含まれます。

ready() 内で購読すること自体は可能ですが、この初期リリースでは、その中でウィンドウコマンドを await しないでください。ネイティブウィンドウは ready() の完了後に接続されるため、起動時のデッドロックを避ける目的で、このフック内のコマンドは reject されます。後続のライフサイクルコールバック、バックグラウンド処理、または 'use main' 関数から実行してください。

ウィンドウは引き続き宣言的な仕組みであり、未宣言のラベルを作成したり、ルート・ネットワーク設定・ケイパビリティポリシーを実行時に上書きしたりすることはできません。起動時に作成しないセカンダリには createOnLaunch: false を設定してください。再表示する稼働中のウィンドウには hide() を、ネイティブリソースを解放する場合は destroy() を使用してください。プライマリのクローズは、通常のアプリケーション終了ライフサイクルの一部です。ライフサイクルイベントが対象とするのは作成・表示状態・フォーカス・クローズであり、移動・リサイズ・最小化・最大化の通知は含まれません。

'use main' でレンダラーから Node を呼ぶ

トップレベルの 'use main' ディレクティブは、そのモジュールがエクスポートする関数を、src/main.ts と同じ Node モジュールグラフ上の型付き呼び出しとして公開します。

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 します。レンダラーのバンドルには fetch のスタブだけが含まれ、Node 側の実装は 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>
  )
}

Main イベントをレンダラーへストリームする

接続状態、進捗、デバイスイベント、ウォッチャーの出力など、長寿命な Node 処理から発生する値には、型付きのイベントチャネルを使用します。

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 })
  },
})

クライアントコンポーネントで購読します。

'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>
}

イベントは 'use main' と同じ rich-value wire codec を使用し、認証済みのアプリローカル SSE 経由で届きます。一時的な切断のあとは EventSource が再接続しますが、イベントはライブ配信のみであり、リプレイバッファや永続キューはありません。取りこぼしを復旧する必要がある場合は、永続的な状態を Node 側に保存したうえで、'use main' のスナップショット関数も公開してください。

'use main' は RPC 境界であり、認可境界ではありません。すべての引数を、信頼できないレンダラー入力として扱い、型の検証、ファイルシステムパスの正規化、アプリデータへのアクセス認可を Node 関数内で実施してください。

Wire 値と上限

バージョン管理された wire codec は、プリミティブ値、undefinedbigintDateMapSet、循環参照を含む通常のオブジェクトや配列、ArrayBuffer、型付き配列、BlobFileFormData、構造化された Error を扱えます。

  • リクエスト / レスポンスの最大サイズ: 32 MiB
  • 関数、シンボル、カスタムプロトタイプを持つインスタンスは拒否されます
  • 呼び出し自体はリクエスト / レスポンス形式です。ライブプッシュには Main イベントを使用してください。'use main' の呼び出しから AsyncIterable を直接返すことは未実装です
  • 名前付きの function / async function / const / let / var をエクスポートできます。実行時に選択されたエクスポートは呼び出し可能である必要があります

大きなファイルのバイト列を RPC 経由で送らないでください。検証済みの、アプリが管理する id やパスだけを渡し、Node 内で完結してストリーム処理します。

'use main' / 'use server' / API Routes

選択用途
'use main'長寿命なアプリケーションバックエンドへの型付き命令的呼び出し
'use server'ActionState を使う React action / form 形式のミューテーション
src/api/**/route.tsRequest / Response、ストリーミングボディ、HTTP メソッドによるルーティング

これら 3 つはいずれもアプリローカルの Node ランタイムで実行され、パブリックなネットワークサーバーではありません。Murasaki は接続元のネイティブウィンドウを厳密に認証し、そのウィンドウが main:*action:*api:METHOD:/path のいずれかのバックエンド許可を持っていることを確認してからディスパッチします。

現在の制限

Node Main が作成・破棄できるのは、murasaki.config.* に宣言したセカンダリウィンドウのテンプレートだけです。実行時にアドホックな URL、ケイパビリティポリシー、未宣言のネイティブウィンドウを構築することはできません。宣言済みのウィンドウは、Node Main の windows マネージャー、または信頼されたレンダラーから murasaki/native を通じて操作します。トレイメニューはそのレンダラー API から利用できますが、Node Main 自体には、トレイメニューやグローバルショートカットを直接扱う API はまだありません。 パッケージ化された macOS / Windows / Linux でのシングルインスタンス配信は secondInstance()、設定済みの URL スキームやファイルアソシエーションは openRequested() で利用できます。macOS のバンドルと Windows のインストーラーは宣言済みのアソシエーションを登録し、Linux の .deb は生成されたデスクトップメタデータをインストールします。手動で展開した Windows のポータブルアーカイブ、AppDir / AppImage、および murasaki dev は、この OS への登録処理を行いません。 レンダラーセーフなダイアログ、クリップボード、通知、シェル、基本的なウィンドウコマンドは、capabilities の許可リストのもとで、別途 murasaki/native から利用できます。動的に算出されたインポート、動的なネイティブアドオン、実行時に検出されるアセットを使用する Node パッケージでは、明示的な bundle.external / bundle.resources の指定が必要になる場合があります。murasaki dev だけでなく、クリーンな環境にインストールした成果物でも必ずテストしてください。

次へ

GitHub でこのページを改善

On this page