Files
SubMiner/docs-site/ipc-contracts.md
2026-03-10 19:47:16 -07:00

6.1 KiB

IPC + Runtime Contracts

SubMiner's Electron app runs two isolated processes — main and renderer — that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic.

The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit — drift between any of those layers is treated as a bug.

Message Flow

Renderer-initiated calls (invoke) pass through four boundaries before reaching a service. Fire-and-forget messages (send) follow the same path but skip the response leg. Malformed payloads are caught at the validator and never reach domain code.

flowchart LR
  classDef rend fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
  classDef bridge fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
  classDef valid fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
  classDef handler fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
  classDef svc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
  classDef err fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px

  R["Renderer"]:::rend
  P(["preload.ts"]):::bridge
  M["ipcMain handler"]:::handler
  V{"Validator"}:::valid
  S["Service"]:::svc
  E["Structured error"]:::err

  R -->|"invoke / send"| P
  P -->|"ipcRenderer"| M
  M --> V
  V -->|"valid"| S
  V -->|"malformed"| E
  S -->|"result"| P
  E -->|"{ ok: false }"| P
  P -->|"return"| R

  style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px

Core Surfaces

File Role
src/shared/ipc/contracts.ts Canonical channel names and payload type contracts. Single source of truth for both processes.
src/shared/ipc/validators.ts Runtime payload parsers and type guards. Every invoke payload is validated here before the handler runs.
src/preload.ts Renderer-side bridge. Exposes a typed API surface to the renderer — only approved channels are accessible.
src/main/ipc-runtime.ts Main-process handler registration and routing. Wires validated channels to domain handlers.
src/core/services/ipc.ts Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic.
src/core/services/anki-jimaku-ipc.ts Integration-specific IPC boundary for Anki and Jimaku operations.
src/main/cli-runtime.ts CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer.

Contract Rules

These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes — which surfaces as undefined fields, swallowed errors, or state corruption.

  • Use shared constants. Channel names come from contracts.ts, never ad-hoc literal strings. This makes channels greppable and refactor-safe.
  • Validate before handling. Every invoke payload passes through validators.ts before reaching domain logic. This catches shape drift at the boundary instead of deep inside a service.
  • Return structured failures. Handlers return { ok: false, error: string } on failure rather than throwing. The renderer can always distinguish success from failure without try/catch.
  • Keep payloads narrow. Send only what the handler needs. Avoid passing entire state objects across the bridge — it couples the renderer to internal main-process structure.
  • Co-evolve all layers. When a payload shape changes, update contracts.ts, validators.ts, preload.ts, and the handler in the same commit. Partial updates are treated as bugs.

Two Message Patterns

Invoke (request/response): The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result — lookups, config reads, mining actions.

Fire-and-forget (send): The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation — UI state hints, focus events, position updates.

Add a New IPC Action

  1. Add the channel constant in src/shared/ipc/contracts.ts.
  2. Add or extend the payload validator in src/shared/ipc/validators.ts.
  3. Expose a typed bridge method in src/preload.ts.
  4. Register the handler in src/main/ipc-runtime.ts (or the relevant domain runtime module).
  5. Add tests for both valid and malformed payload cases in src/core/services/*.
  6. Update renderer tests when behavior or state transitions change.

Runtime State Notes

  • Prefer runtime/domain composition via src/main/runtime/composers/* and src/main/runtime/domains/*. IPC handlers should delegate to composers rather than containing orchestration logic.
  • Route shared mutable state updates through transition helpers in src/main/state.ts for migrated domains. Direct mutation from IPC handlers bypasses invariant checks.
  • Keep IPC handlers thin — they validate, delegate, and return. Business logic belongs in services.

Troubleshooting

  • Unknown payload in handler: The validator is not being applied before the handler runs. Check that the channel is routed through ipc-runtime.ts with validation, not registered directly.
  • Renderer invoke fails: Verify the preload bridge method exists and matches the channel constant. Check that the handler is registered and returning (not throwing).
  • Contract drift: When invoke calls return unexpected shapes, compare the shared contract, validator, preload bridge, and main handler signatures side by side. One of them was updated without the others.