Skip to content
TwinScope0.3.10

Import boundaries

Three rules about what may import what. They are the architecture, and two of the three fail your build rather than relying on you to remember them.

ContributingEdit this page

Architecture that lives in a document drifts. Architecture that fails npm run lint does not. TwinScope keeps three import boundaries, and the two most dangerous to break are enforced mechanically.

1. The renderer cannot import node or electron#

eslint.config.mjsjs
// --- Renderer: React rules, and NO access to node or Electron. ---
{
files: ['src/renderer/**/*.{ts,tsx}'],
rules: {
  'no-restricted-imports': ['error', {
    patterns: [{
      group: ['node:*', 'fs', 'path', 'os', 'child_process', 'electron'],
      message:
        'The renderer must not touch node or Electron. Go through the preload bridge (window.twinscope).',
    }],
  }],
},
}

Why#

A compromised renderer is the threat model context isolation exists for. The renderer displays file contents, and file contents are attacker-controlled input — a crafted diff, a hostile JSON blob, a malformed image. If that process could reach fs or child_process, a rendering bug would become arbitrary file access rather than a visual glitch.

So the renderer talks to main through exactly one object, window.twinscope, exposed by the sandboxed preload. Main zod-parses every payload that arrives, because "the renderer sent it" is not evidence of anything.

2. Engines cannot import electron#

eslint.config.mjsjs
// --- Engines: pure logic. No Electron, so the CLI can reuse them. ---
{
files: ['src/engines/**/*.ts'],
rules: {
  'no-restricted-imports': ['error', {
    patterns: [{
      group: ['electron'],
      message:
        'Engines must stay host-agnostic. Filesystem access is injected via EngineCtx.fs.',
    }],
  }],
},
}

Why#

So a CLI can reuse them unchanged. The comparison logic is the valuable part of the app and it has nothing to do with windows: a text diff is a function from two strings to a row model. The moment an engine imports electron, it can only ever run inside a desktop app.

Everything an engine needs from its host is injected through EngineCtx instead — filesystem access as ctx.fs, image decoding as ctx.image, a yield point as ctx.yieldNow. The Electron app supplies a Node-backed implementation; a CLI would supply its own; a test supplies a fake.

3. src/shared/channels.ts takes no runtime dependency#

src/shared/channels.tsts
import type { InputKind, InputRef, Summary } from '../engines/types';

/**

- The contract between processes: channel names, wire shapes, and the bridge.
-
- No _runtime_ dependency may enter this file — the sandboxed preload imports
- it, and anything heavier lands in its bundle. Type-only imports are fine,
- since they erase at build time. Runtime validation lives in `./schemas`,
- which only main imports.
*/

Why#

The sandboxed preload imports it, and anything heavier lands in its bundle. The preload is the single most security-sensitive file in the app: it runs with more privilege than the renderer and it is what the renderer talks to. Every runtime dependency pulled into channels.ts is pulled into that bundle too — more code in the privileged position, for a file whose entire job is to declare names and shapes.

The whole file is therefore channel-name constants, TypeScript types, and one type-only import, which erases at build time. Runtime validation lives next door in schemas.ts, where the zod schemas are — and only main imports that.

Checking your work#

Terminalbash
npm run lint       # the boundary rules, plus the rest of ESLint
npm run typecheck  # three projects: node / web / e2e
npm run gate       # typecheck · lint · format:check · test · verify, in CI order

A renderer file importing node:fs fails npm run lint. That is the point: the architecture is not a thing you have to remember, it is a thing the build checks.