diff --git a/docs/backend/node.md b/docs/backend/node.md index 8cf0973e..87b76cae 100644 --- a/docs/backend/node.md +++ b/docs/backend/node.md @@ -6,11 +6,39 @@ The Node backend owns: - frame scheduling and buffer pooling - transfer of drawlists to the engine and event batches back to core -Most apps should construct a backend via: +Most apps should construct the app via: ```ts +import { createNodeApp } from "@rezi-ui/node"; + +const app = createNodeApp({ + initialState: { count: 0 }, + config: { + fpsCap: 60, + maxEventBytes: 1 << 20, + useV2Cursor: false, + }, +}); +``` + +`createNodeApp` is the recommended path because it keeps core/backend config +knobs aligned: + +- `useV2Cursor` and drawlist v2 are paired automatically. +- `maxEventBytes` is applied to both app parsing and backend worker buffers. +- `fpsCap` is the single scheduling knob. + +Advanced path: + +```ts +import { createApp } from "@rezi-ui/core"; import { createNodeBackend } from "@rezi-ui/node"; + +const backend = createNodeBackend({ fpsCap: 60 }); +const app = createApp({ backend, initialState: { count: 0 } }); ``` -Next: [Worker model](worker-model.md). +If advanced config values conflict, Rezi now throws deterministic +`ZRUI_INVALID_PROPS` errors with exact fixes. +Next: [Worker model](worker-model.md). diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 171ebd76..fe4c690e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -48,14 +48,14 @@ bun add -d typescript tsx Create `index.ts`: ```typescript -import { createApp, ui, rgb } from "@rezi-ui/core"; -import { createNodeBackend } from "@rezi-ui/node"; +import { ui, rgb } from "@rezi-ui/core"; +import { createNodeApp } from "@rezi-ui/node"; type State = { count: number }; -const app = createApp({ - backend: createNodeBackend(), +const app = createNodeApp({ initialState: { count: 0 }, + config: { fpsCap: 60, maxEventBytes: 1 << 20, useV2Cursor: false }, }); app.view((state) => @@ -107,14 +107,14 @@ You should see a counter UI. Use Tab to navigate between buttons, Enter to activ ### Creating the Application ```typescript -const app = createApp({ - backend: createNodeBackend(), +const app = createNodeApp({ initialState: { count: 0 }, + config: { fpsCap: 60, maxEventBytes: 1 << 20, useV2Cursor: false }, }); ``` -- `createApp` creates a typed application instance -- `backend` specifies the rendering backend (`@rezi-ui/node` for Node.js and Bun) +- `createNodeApp` creates a typed application instance and compatible Node backend +- `config` controls app/backend runtime knobs in one place (`fpsCap`, `maxEventBytes`, `useV2Cursor`) - `initialState` provides the initial application state ### Defining the View diff --git a/docs/guide/lifecycle-and-updates.md b/docs/guide/lifecycle-and-updates.md index cdb489b9..e53c384b 100644 --- a/docs/guide/lifecycle-and-updates.md +++ b/docs/guide/lifecycle-and-updates.md @@ -2,26 +2,28 @@ This guide explains how a Rezi app moves through its lifecycle, how updates are committed, and what “deterministic scheduling” means in practice. -## `createApp` +## `createNodeApp` (recommended) -Apps are created via `createApp` from `@rezi-ui/core` and a backend from `@rezi-ui/node`: +Apps are usually created through `createNodeApp` from `@rezi-ui/node`: ```typescript -import { createApp, ui } from "@rezi-ui/core"; -import { createNodeBackend } from "@rezi-ui/node"; +import { ui } from "@rezi-ui/core"; +import { createNodeApp } from "@rezi-ui/node"; type State = { count: number }; -const app = createApp({ - backend: createNodeBackend(), +const app = createNodeApp({ initialState: { count: 0 }, - config: { fpsCap: 60 }, + config: { fpsCap: 60, maxEventBytes: 1 << 20, useV2Cursor: false }, }); app.view((state) => ui.text(`Count: ${state.count}`)); await app.start(); ``` +`createNodeApp` keeps app/backend cursor protocol, event caps, and fps knobs in +sync by construction. + ## State machine diagram ```mermaid diff --git a/docs/index.md b/docs/index.md index 4e84c817..ded8b8e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,12 +3,12 @@ Rezi is a **code-first terminal UI framework** for Node.js and Bun. Build rich, interactive terminal applications with a declarative widget API, automatic focus management, and native rendering performance. ```typescript -import { createApp, ui, rgb } from "@rezi-ui/core"; -import { createNodeBackend } from "@rezi-ui/node"; +import { ui, rgb } from "@rezi-ui/core"; +import { createNodeApp } from "@rezi-ui/node"; -const app = createApp({ - backend: createNodeBackend(), +const app = createNodeApp({ initialState: { count: 0 }, + config: { fpsCap: 60, maxEventBytes: 1 << 20, useV2Cursor: false }, }); app.view((state) => diff --git a/docs/packages/node.md b/docs/packages/node.md index 2dc30611..a16febbd 100644 --- a/docs/packages/node.md +++ b/docs/packages/node.md @@ -21,21 +21,42 @@ bun add @rezi-ui/node - A stable message protocol between main thread and worker - Integration with `@rezi-ui/native` (prebuilt binaries when available) -## Creating a backend +## Creating an app (recommended) ```ts -import { createNodeBackend } from "@rezi-ui/node"; - -const backend = createNodeBackend({ - fpsCap: 60, +import { createNodeApp } from "@rezi-ui/node"; + +const app = createNodeApp({ + initialState: { count: 0 }, + config: { + fpsCap: 60, + maxEventBytes: 1 << 20, + useV2Cursor: false, + }, }); ``` -Pass the backend into `createApp` from `@rezi-ui/core`. This backend is supported in Node.js and Bun runtimes. +`createNodeApp` is the default path because it keeps core/backend config in +lockstep: + +- `useV2Cursor` <-> drawlist v2 +- app/backend `maxEventBytes` +- app/backend `fpsCap` + +## Creating a backend directly (advanced) + +```ts +import { createApp } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; + +const backend = createNodeBackend({ fpsCap: 60 }); +const app = createApp({ backend, initialState: { count: 0 } }); +``` ## Native engine config passthrough -`createNodeBackend` accepts `nativeConfig`, a JSON-ish object forwarded to the native layer’s engine creation config. +`createNodeBackend` accepts `nativeConfig`, a JSON-ish object forwarded to the +native layer’s engine creation config. Keys are forwarded as-is. If you want a close match to the engine’s public C structs, use `snake_case` field names: @@ -44,7 +65,7 @@ import { createNodeBackend } from "@rezi-ui/node"; const backend = createNodeBackend({ nativeConfig: { - target_fps: 60, + target_fps: 60, // must match fpsCap when provided limits: { dl_max_total_bytes: 16 << 20, }, diff --git a/docs/terminal-io-contract.md b/docs/terminal-io-contract.md new file mode 100644 index 00000000..ac7a5906 --- /dev/null +++ b/docs/terminal-io-contract.md @@ -0,0 +1,126 @@ +# Terminal I/O Contract + +This document defines Rezi's terminal input contract. + +Contract path: + +1. Raw bytes arrive from a real PTY/ConPTY session. +2. Zireael normalizes bytes into ZREV batches. +3. Rezi parses those batches with `parseEventBatchV1`. +4. App/runtime routing consumes parsed events. + +All behavior in this document is covered by deterministic integration tests in: + +- `packages/node/src/__e2e__/terminal_io_contract.e2e.test.ts` +- `packages/node/src/__e2e__/fixtures/terminal-io-contract-target.ts` + +## Constants + +- Key codes follow `@rezi-ui/core/keybindings` / `include/zr/zr_event.h`. +- Modifier bitmask: + - `SHIFT=1` + - `CTRL=2` + - `ALT=4` + - `META=8` + +## Keyboard Contract + +### Byte-to-event mapping + +| Input bytes | Expected parsed event(s) | +| --- | --- | +| `ESC [ 1 ; 5 A` | `{ kind: "key", key: ZR_KEY_UP, mods: ZR_MOD_CTRL, action: "down" }` | +| `ESC [ Z` | `{ kind: "key", key: ZR_KEY_TAB, mods: ZR_MOD_SHIFT, action: "down" }` | +| `ESC [ 9 ; 5 u` | `{ kind: "key", key: ZR_KEY_TAB, mods: ZR_MOD_CTRL, action: "down" }` | +| `ESC [ 13 ; 5 u` | `{ kind: "key", key: ZR_KEY_ENTER, mods: ZR_MOD_CTRL, action: "down" }` | +| `ESC [ 127 ; 5 u` | `{ kind: "key", key: ZR_KEY_BACKSPACE, mods: ZR_MOD_CTRL, action: "down" }` | +| `ESC [ 97 ; 3 u` | Alt/Meta text policy fallback: `ESC` key prefix then payload (`'a'` text or equivalent Alt key payload event) | +| `ESC [ 98 ; 9 u` | Alt/Meta text policy fallback: `ESC` key prefix then payload (`'b'` text or equivalent Meta key payload event) | + +### ESC ambiguity/incomplete policy + +- Incomplete supported escape prefixes are buffered. +- If a sequence is completed in a later read, it resolves as the completed key event. +- If a supported prefix remains incomplete at flush, fallback is deterministic: + - emit `ESC` key + - emit remaining bytes as text scalars (example: `ESC [` -> `ESC` key then `'['` text) + +## Bracketed Paste Contract + +### Framing + +- Begin marker: `ESC [ 200 ~` +- End marker: `ESC [ 201 ~` +- Payload between markers produces one `paste` event: + - `{ kind:"paste", bytes: }` + +### Missing end marker + +- Missing end marker must not wedge input. +- Engine may finalize and emit a best-effort `paste` event with captured bytes, or drop the incomplete paste. +- Subsequent key/text input must continue normally. + +### Max paste size behavior + +- Paste capture is bounded by engine paste buffer capacity. +- On overrun, the oversized paste is dropped (no truncated `paste` event is emitted). +- Input stream continues after paste end or idle flush. + +## Focus Contract + +### Focus in/out + +| Input bytes | Expected parsed event | +| --- | --- | +| `ESC [ I` | `{ kind:"key", key:30 /*FOCUS_IN*/, mods:0, action:"down" }` | +| `ESC [ O` | `{ kind:"key", key:31 /*FOCUS_OUT*/, mods:0, action:"down" }` | + +### Gating + +- Focus events are emitted when terminal capabilities report focus support. +- Rezi integration coverage asserts capability-gated suppression (`ZIREAEL_CAP_FOCUS_EVENTS=0`). +- Native runtime config gating (`enableFocusEvents`) is implemented by Zireael platform config. + +## Mouse Contract (SGR) + +| Input bytes | Expected parsed event | +| --- | --- | +| `ESC [ < 0 ; 300 ; 400 M` | `{ kind:"mouse", mouseKind:3 /*down*/, x:299, y:399, buttons:1 }` | +| `ESC [ < 0 ; 300 ; 400 m` | `{ kind:"mouse", mouseKind:4 /*up*/, x:299, y:399, buttons:1 }` | +| `ESC [ < 64 ; 400 ; 500 M` | `{ kind:"mouse", mouseKind:5 /*wheel*/, x:399, y:499, wheelY:1 }` | + +Notes: + +- SGR coordinates are 1-based on wire and normalized to 0-based in events. +- High coordinates must remain stable (no 223-column legacy clipping). + +## Resize Contract + +- Engine emits an initial resize event at startup. +- Subsequent terminal size changes emit `resize` events with latest cols/rows. +- Ordering expectation: + - initial resize appears before later explicit resize updates + - observed size values match PTY resize requests + +## Split Reads / Partial Sequences + +Examples (explicitly tested): + +1. Split complete sequence across reads: + - read #1: `ESC [` + - read #2: `A` + - expected output: one `UP` key event, no premature `ESC` fallback before completion. +2. Split incomplete sequence flushed without completion: + - read #1: `ESC [` + - no completion bytes + - expected output after flush: `ESC` key event, then `'['` text event. +3. Split paste begin/content without end marker: + - read #1: `ESC [ 200 ~ xyz` + - no end marker + - expected output: input remains live (and may include a best-effort paste flush). + +## Platform Coverage + +- Linux/macOS: full contract suite runs through real PTY. +- Windows: ConPTY-guarded test covers at least arrows/modifiers + bracketed paste. + - If ConPTY path is unavailable in environment, tests skip with explicit reason. diff --git a/package-lock.json b/package-lock.json index 3c8e572e..f3ea73df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -371,6 +371,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/ansi-escapes": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", @@ -876,6 +886,13 @@ "node": ">=6.0.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-bitmap": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz", @@ -884,6 +901,17 @@ "node": ">=v0.6.5" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", @@ -1342,6 +1370,10 @@ "@rezi-ui/core": "0.1.0-alpha.11", "@rezi-ui/native": "0.1.0-alpha.11" }, + "devDependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.1.0" + }, "engines": { "bun": ">=1.3.0", "node": ">=18" diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 06fa2afb..02c49a64 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -24,6 +24,9 @@ import { ZrUiError, type ZrUiErrorCode } from "../abi.js"; import { + BACKEND_DRAWLIST_V2_MARKER, + BACKEND_FPS_CAP_MARKER, + BACKEND_MAX_EVENT_BYTES_MARKER, type BackendEventBatch, FRAME_ACCEPTED_ACK_MARKER, type RuntimeBackend, @@ -134,6 +137,30 @@ function getAcceptedFrameAck(p: Promise): Promise | null { return marker as Promise; } +function readBackendBooleanMarker( + backend: RuntimeBackend, + marker: typeof BACKEND_DRAWLIST_V2_MARKER, +): boolean | null { + const value = (backend as RuntimeBackend & Readonly>)[marker]; + if (value === undefined) return null; + if (typeof value !== "boolean") { + invalidProps(`backend marker ${marker} must be a boolean when present`); + } + return value; +} + +function readBackendPositiveIntMarker( + backend: RuntimeBackend, + marker: typeof BACKEND_MAX_EVENT_BYTES_MARKER | typeof BACKEND_FPS_CAP_MARKER, +): number | null { + const value = (backend as RuntimeBackend & Readonly>)[marker]; + if (value === undefined) return null; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + invalidProps(`backend marker ${marker} must be a positive integer when present`); + } + return value; +} + /** Apply defaults to user-provided config, validating all values. */ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConfig { if (!config) return DEFAULT_CONFIG; @@ -268,6 +295,42 @@ export function createApp( ): App { const backend = opts.backend; const config = resolveAppConfig(opts.config); + + const backendUseDrawlistV2 = readBackendBooleanMarker(backend, BACKEND_DRAWLIST_V2_MARKER); + if (backendUseDrawlistV2 !== null && backendUseDrawlistV2 !== config.useV2Cursor) { + if (config.useV2Cursor) { + invalidProps( + "config.useV2Cursor=true but backend.useDrawlistV2=false. " + + "Fix: set createNodeBackend({ useDrawlistV2: true }) or use createNodeApp({ config: { useV2Cursor: true } }).", + ); + } + invalidProps( + "config.useV2Cursor=false but backend.useDrawlistV2=true. " + + "Fix: set createApp({ config: { useV2Cursor: true } }) or set createNodeBackend({ useDrawlistV2: false }).", + ); + } + + const backendMaxEventBytes = readBackendPositiveIntMarker( + backend, + BACKEND_MAX_EVENT_BYTES_MARKER, + ); + if (backendMaxEventBytes !== null && backendMaxEventBytes !== config.maxEventBytes) { + invalidProps( + `config.maxEventBytes=${String(config.maxEventBytes)} must match backend maxEventBytes=${String( + backendMaxEventBytes, + )}. Fix: set the same maxEventBytes in createApp({ config }) and createNodeBackend({ maxEventBytes }), or use createNodeApp({ config: { maxEventBytes: ... } }).`, + ); + } + + const backendFpsCap = readBackendPositiveIntMarker(backend, BACKEND_FPS_CAP_MARKER); + if (backendFpsCap !== null && backendFpsCap !== config.fpsCap) { + invalidProps( + `config.fpsCap=${String(config.fpsCap)} must match backend fpsCap=${String( + backendFpsCap, + )}. Fix: set the same fpsCap in createApp({ config }) and createNodeBackend({ fpsCap }), or use createNodeApp({ config: { fpsCap: ... } }).`, + ); + } + let theme = coerceToLegacyTheme(opts.theme ?? defaultTheme); const sm = new AppStateMachine(); diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 9da22316..4c55e276 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -11,6 +11,24 @@ import type { TerminalCaps } from "./terminalCaps.js"; */ export const FRAME_ACCEPTED_ACK_MARKER = "__reziFrameAcceptedAckPromise" as const; +/** + * Optional marker on RuntimeBackend objects exposing the backend drawlist protocol. + * Used by createApp() to reject core/backend cursor-protocol mismatches early. + */ +export const BACKEND_DRAWLIST_V2_MARKER = "__reziBackendUseDrawlistV2" as const; + +/** + * Optional marker on RuntimeBackend objects exposing the backend event-batch cap. + * Used by createApp() to reject event-buffer cap mismatches early. + */ +export const BACKEND_MAX_EVENT_BYTES_MARKER = "__reziBackendMaxEventBytes" as const; + +/** + * Optional marker on RuntimeBackend objects exposing the backend frame pacing cap. + * Used by createApp() to reject fps-cap mismatches early. + */ +export const BACKEND_FPS_CAP_MARKER = "__reziBackendFpsCap" as const; + // ============================================================================= // BackendEventBatch (from docs/guide/lifecycle-and-updates.md) // ============================================================================= diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35e368d9..78b8bc62 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,6 +78,9 @@ export const ZR_DRAWLIST_VERSION: 1 = ZR_DRAWLIST_VERSION_V1; export const ZR_EVENT_BATCH_VERSION: 1 = ZR_EVENT_BATCH_VERSION_V1; export { + BACKEND_DRAWLIST_V2_MARKER, + BACKEND_FPS_CAP_MARKER, + BACKEND_MAX_EVENT_BYTES_MARKER, FRAME_ACCEPTED_ACK_MARKER, type BackendEventBatch, type RuntimeBackend, diff --git a/packages/node/README.md b/packages/node/README.md index 8e134d40..6daa2231 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -6,13 +6,16 @@ Node.js/Bun backend for Rezi. This package owns: - frame scheduling and buffer pooling - transfer of drawlists/events between core and the native addon -Typical usage: +Recommended usage: ```ts -import { createApp, ui } from "@rezi-ui/core"; -import { createNodeBackend } from "@rezi-ui/node"; +import { createNodeApp } from "@rezi-ui/node"; ``` +Use `createNodeApp({ initialState, config })` as the default path. It wires +`@rezi-ui/core` and `@rezi-ui/node` with matched cursor protocol, event caps, +and fps settings. + Install: ```bash diff --git a/packages/node/package.json b/packages/node/package.json index 4d624507..fc877f32 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -28,5 +28,9 @@ "dependencies": { "@rezi-ui/core": "0.1.0-alpha.11", "@rezi-ui/native": "0.1.0-alpha.11" + }, + "devDependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.1.0" } } diff --git a/packages/node/src/__e2e__/fixtures/terminal-app.ts b/packages/node/src/__e2e__/fixtures/terminal-app.ts index ad250043..2e9af475 100644 --- a/packages/node/src/__e2e__/fixtures/terminal-app.ts +++ b/packages/node/src/__e2e__/fixtures/terminal-app.ts @@ -1,11 +1,11 @@ -import { createApp, ui } from "@rezi-ui/core"; -import { createNodeBackend } from "@rezi-ui/node"; +import { ui } from "@rezi-ui/core"; +import { createNodeApp } from "@rezi-ui/node"; type State = { step: number }; -const app = createApp({ - backend: createNodeBackend({ executionMode: "inline", fpsCap: 30 }), +const app = createNodeApp({ initialState: { step: 0 }, + config: { executionMode: "inline", fpsCap: 30 }, }); app.view((state) => diff --git a/packages/node/src/__e2e__/fixtures/terminal-io-contract-target.ts b/packages/node/src/__e2e__/fixtures/terminal-io-contract-target.ts new file mode 100644 index 00000000..087a1ce6 --- /dev/null +++ b/packages/node/src/__e2e__/fixtures/terminal-io-contract-target.ts @@ -0,0 +1,282 @@ +import net from "node:net"; +import type { TerminalCaps } from "@rezi-ui/core"; +import { createNodeBackend } from "../../index.js"; + +type ControlCommand = Readonly<{ id: string; cmd: "pollOnce" | "getCaps" | "stop" }>; + +type ReadyMessage = Readonly<{ + type: "ready"; + caps: TerminalCaps; +}>; + +type ResponseMessage = + | Readonly<{ + type: "response"; + id: string; + ok: true; + result: + | Readonly<{ + kind: "pollOnce"; + bytesBase64: string; + droppedBatches: number; + }> + | Readonly<{ kind: "getCaps"; caps: TerminalCaps }> + | Readonly<{ kind: "stop" }>; + }> + | Readonly<{ type: "response"; id: string; ok: false; error: string }>; + +type FatalMessage = Readonly<{ type: "fatal"; detail: string }>; + +type OutboundMessage = ReadyMessage | ResponseMessage | FatalMessage; +type TargetEnv = NodeJS.ProcessEnv & + Readonly<{ + REZI_TERMINAL_IO_CTRL_PORT?: string; + REZI_TERMINAL_IO_NATIVE_CONFIG?: string; + }>; + +const targetEnv = process.env as TargetEnv; + +function failAndExit(msg: string): never { + process.stderr.write(`${msg}\n`); + process.exit(1); +} + +function parsePortFromEnv(): number { + const raw = targetEnv.REZI_TERMINAL_IO_CTRL_PORT; + if (raw === undefined) + failAndExit("terminal-io-contract-target: REZI_TERMINAL_IO_CTRL_PORT is required"); + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0 || n > 65535) { + failAndExit(`terminal-io-contract-target: invalid REZI_TERMINAL_IO_CTRL_PORT=${String(raw)}`); + } + return n; +} + +function parseNativeConfigFromEnv(): Readonly> { + const raw = targetEnv.REZI_TERMINAL_IO_NATIVE_CONFIG; + if (typeof raw !== "string" || raw.length === 0) return Object.freeze({}); + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + failAndExit( + `terminal-io-contract-target: failed to parse REZI_TERMINAL_IO_NATIVE_CONFIG: ${detail}`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + failAndExit( + "terminal-io-contract-target: REZI_TERMINAL_IO_NATIVE_CONFIG must be a JSON object", + ); + } + return parsed as Readonly>; +} + +function asErrorDetail(err: unknown): string { + return err instanceof Error ? `${err.name}: ${err.message}` : String(err); +} + +function sendJsonLine(socket: net.Socket, msg: OutboundMessage): void { + socket.write(`${JSON.stringify(msg)}\n`); +} + +const ctrlPort = parsePortFromEnv(); +const nativeConfig = parseNativeConfigFromEnv(); + +const backend = createNodeBackend({ + executionMode: "worker", + fpsCap: 1000, + maxEventBytes: 1 << 20, + nativeConfig, +}); + +let socket: net.Socket | null = null; +let cachedCaps: TerminalCaps | null = null; +let shuttingDown = false; +let processing = false; +const queue: ControlCommand[] = []; +let lineBuf = ""; + +async function stopBackendBestEffort(): Promise { + if (shuttingDown) return; + shuttingDown = true; + try { + await backend.stop(); + } catch { + // best-effort cleanup + } + try { + backend.dispose(); + } catch { + // best-effort cleanup + } +} + +async function handleCommand(cmd: ControlCommand): Promise { + switch (cmd.cmd) { + case "pollOnce": { + const batch = await backend.pollEvents(); + try { + const bytes = new Uint8Array(batch.bytes.byteLength); + bytes.set(batch.bytes); + return { + type: "response", + id: cmd.id, + ok: true, + result: { + kind: "pollOnce", + bytesBase64: Buffer.from(bytes).toString("base64"), + droppedBatches: batch.droppedBatches, + }, + }; + } finally { + batch.release(); + } + } + + case "getCaps": { + const caps = cachedCaps ?? (await backend.getCaps()); + cachedCaps = caps; + return { + type: "response", + id: cmd.id, + ok: true, + result: { + kind: "getCaps", + caps, + }, + }; + } + + case "stop": { + await stopBackendBestEffort(); + return { + type: "response", + id: cmd.id, + ok: true, + result: { kind: "stop" }, + }; + } + } +} + +async function flushQueue(): Promise { + if (processing) return; + processing = true; + try { + while (queue.length > 0) { + const next = queue.shift(); + if (next === undefined) continue; + try { + const response = await handleCommand(next); + if (socket !== null) { + sendJsonLine(socket, response); + } + } catch (err) { + if (socket !== null) { + sendJsonLine(socket, { + type: "response", + id: next.id, + ok: false, + error: asErrorDetail(err), + }); + } + } + + if (next.cmd === "stop") { + if (socket !== null) { + socket.end(); + } + setImmediate(() => { + process.exit(0); + }); + return; + } + } + } finally { + processing = false; + } +} + +function enqueueLine(line: string): void { + const trimmed = line.trim(); + if (trimmed.length === 0) return; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + return; + } + if (typeof parsed !== "object" || parsed === null) return; + const rec = parsed as Partial; + if (typeof rec.id !== "string") return; + if (rec.cmd !== "pollOnce" && rec.cmd !== "getCaps" && rec.cmd !== "stop") return; + queue.push(Object.freeze({ id: rec.id, cmd: rec.cmd })); + void flushQueue(); +} + +async function main(): Promise { + await backend.start(); + cachedCaps = await backend.getCaps(); + + socket = net.createConnection({ host: "127.0.0.1", port: ctrlPort }); + + socket.setEncoding("utf8"); + + socket.on("connect", () => { + if (socket === null || cachedCaps === null) return; + sendJsonLine(socket, { + type: "ready", + caps: cachedCaps, + }); + }); + + socket.on("data", (chunk: string) => { + lineBuf += chunk; + for (;;) { + const idx = lineBuf.indexOf("\n"); + if (idx < 0) break; + const line = lineBuf.slice(0, idx); + lineBuf = lineBuf.slice(idx + 1); + enqueueLine(line); + } + }); + + socket.on("error", async (err) => { + const detail = asErrorDetail(err); + if (socket !== null) { + sendJsonLine(socket, { type: "fatal", detail }); + } + await stopBackendBestEffort(); + process.exit(1); + }); + + socket.on("close", async () => { + await stopBackendBestEffort(); + process.exit(0); + }); +} + +process.on("uncaughtException", async (err) => { + if (socket !== null) { + sendJsonLine(socket, { type: "fatal", detail: asErrorDetail(err) }); + } + await stopBackendBestEffort(); + process.exit(1); +}); + +process.on("unhandledRejection", async (err) => { + if (socket !== null) { + sendJsonLine(socket, { type: "fatal", detail: asErrorDetail(err) }); + } + await stopBackendBestEffort(); + process.exit(1); +}); + +void main().catch(async (err) => { + if (socket !== null) { + sendJsonLine(socket, { type: "fatal", detail: asErrorDetail(err) }); + } + await stopBackendBestEffort(); + process.exit(1); +}); diff --git a/packages/node/src/__e2e__/terminal_io_contract.e2e.test.ts b/packages/node/src/__e2e__/terminal_io_contract.e2e.test.ts new file mode 100644 index 00000000..d43be69a --- /dev/null +++ b/packages/node/src/__e2e__/terminal_io_contract.e2e.test.ts @@ -0,0 +1,937 @@ +import { once } from "node:events"; +import { createRequire } from "node:module"; +import net from "node:net"; +import type { TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; +import { type TerminalCaps, type ZrevEvent, parseEventBatchV1 } from "@rezi-ui/core"; +import { + ZR_KEY_BACKSPACE, + ZR_KEY_ENTER, + ZR_KEY_ESCAPE, + ZR_KEY_TAB, + ZR_KEY_UP, + ZR_MOD_ALT, + ZR_MOD_CTRL, + ZR_MOD_META, + ZR_MOD_SHIFT, +} from "@rezi-ui/core/keybindings"; +import { assert, test } from "@rezi-ui/testkit"; + +type PtyExit = Readonly<{ exitCode: number; signal?: number }>; + +type PtyProcess = Readonly<{ + pid: number; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: (signal?: string) => void; + onData: (cb: (data: string) => void) => void; + onExit: (cb: (e: PtyExit) => void) => void; +}>; + +type PtySpawn = ( + file: string, + args: string[], + opts: Readonly<{ + name: string; + cols: number; + rows: number; + cwd: string; + env: Readonly>; + }>, +) => PtyProcess; + +type ControlCommand = + | Readonly<{ id: string; cmd: "pollOnce" }> + | Readonly<{ id: string; cmd: "getCaps" }> + | Readonly<{ id: string; cmd: "stop" }>; + +type ControlResult = + | Readonly<{ + kind: "pollOnce"; + bytesBase64: string; + droppedBatches: number; + }> + | Readonly<{ + kind: "getCaps"; + caps: TerminalCaps; + }> + | Readonly<{ + kind: "stop"; + }>; + +type ControlReady = Readonly<{ + type: "ready"; + caps: TerminalCaps; +}>; + +type ControlResponse = + | Readonly<{ type: "response"; id: string; ok: true; result: ControlResult }> + | Readonly<{ type: "response"; id: string; ok: false; error: string }>; + +type ControlFatal = Readonly<{ type: "fatal"; detail: string }>; + +type ControlMessage = ControlReady | ControlResponse | ControlFatal; + +type HarnessConfig = Readonly<{ + nativeConfig?: Readonly>; + env?: Readonly>; + cols?: number; + rows?: number; +}>; + +type ParsedBatch = Readonly<{ + events: readonly ZrevEvent[]; + droppedBatches: number; +}>; + +const ZR_KEY_FOCUS_IN = 30; +const ZR_KEY_FOCUS_OUT = 31; +const CONTROL_COMMAND_TIMEOUT_MS = 5_000; + +function closeServerQuiet(server: net.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function loadPtySpawn(): PtySpawn | null { + try { + const require = createRequire(import.meta.url); + const mod = require("node-pty") as unknown; + const rec = mod as Readonly<{ spawn?: unknown }>; + return typeof rec.spawn === "function" ? (rec.spawn as PtySpawn) : null; + } catch { + return null; + } +} + +function asErrorDetail(err: unknown): string { + return err instanceof Error ? `${err.name}: ${err.message}` : String(err); +} + +function terminatePtyBestEffort(pty: PtyProcess): void { + try { + pty.kill(); + return; + } catch { + // fall through + } + try { + pty.kill("SIGTERM"); + } catch { + // best-effort cleanup + } +} + +class ContractHarness { + caps: TerminalCaps; + + #pty: PtyProcess; + #socket: net.Socket; + #server: net.Server; + #nextId = 1; + #lineBuf = ""; + #closed = false; + #exitObserved = false; + #pending = new Map< + string, + { resolve: (v: ControlResult) => void; reject: (err: Error) => void } + >(); + + private constructor(pty: PtyProcess, socket: net.Socket, server: net.Server, caps: TerminalCaps) { + this.#pty = pty; + this.#socket = socket; + this.#server = server; + this.caps = caps; + } + + static async create(cfg: HarnessConfig = {}): Promise { + const ptySpawn = loadPtySpawn(); + if (ptySpawn === null) { + throw new Error( + 'terminal-io-contract e2e requires "node-pty". Install: npm i -w @rezi-ui/node -D node-pty', + ); + } + + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + throw new Error("terminal-io-contract: failed to obtain control server address"); + } + + const targetPath = fileURLToPath( + new URL("./fixtures/terminal-io-contract-target.js", import.meta.url), + ); + const cols = cfg.cols ?? 120; + const rows = cfg.rows ?? 40; + let pty: PtyProcess | null = null; + let socket: net.Socket | null = null; + let ptyOutput = ""; + try { + const spawnedPty = ptySpawn(process.execPath, [targetPath], { + name: process.platform === "win32" ? "xterm" : "xterm-256color", + cols, + rows, + cwd: process.cwd(), + env: { + ...process.env, + TERM: process.platform === "win32" ? "xterm" : "xterm-256color", + REZI_TERMINAL_IO_CTRL_PORT: String(address.port), + REZI_TERMINAL_IO_NATIVE_CONFIG: JSON.stringify(cfg.nativeConfig ?? {}), + ...(cfg.env ?? {}), + }, + }); + pty = spawnedPty; + + spawnedPty.onData((chunk) => { + ptyOutput = `${ptyOutput}${chunk}`.slice(-4_096); + }); + + const ctrlSocket = await new Promise((resolve, reject) => { + const handshakeTimer = setTimeout(() => { + cleanup(); + reject( + new Error( + `terminal-io-contract target handshake timeout: no control socket connection; output=${JSON.stringify(ptyOutput)}`, + ), + ); + }, 2_000); + const onConn = (s: net.Socket) => { + cleanup(); + resolve(s); + }; + const onErr = (err: Error) => { + cleanup(); + reject(err); + }; + const cleanup = () => { + clearTimeout(handshakeTimer); + server.off("connection", onConn); + server.off("error", onErr); + }; + + server.once("connection", onConn); + server.once("error", onErr); + }); + socket = ctrlSocket; + + ctrlSocket.setEncoding("utf8"); + + const ready = await new Promise((resolve, reject) => { + let buf = ""; + const onData = (chunk: string) => { + buf += chunk; + for (;;) { + const idx = buf.indexOf("\n"); + if (idx < 0) break; + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + const msg = parseControlMessage(line); + if (msg === null) continue; + if (msg.type === "fatal") { + cleanup(); + reject( + new Error(`terminal-io-contract target fatal during handshake: ${msg.detail}`), + ); + return; + } + if (msg.type === "ready") { + cleanup(); + resolve(msg); + return; + } + } + }; + + const onErr = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + ctrlSocket.off("data", onData); + ctrlSocket.off("error", onErr); + }; + + ctrlSocket.on("data", onData); + ctrlSocket.on("error", onErr); + }); + + const harness = new ContractHarness(spawnedPty, ctrlSocket, server, ready.caps); + harness.#attachControlListeners(); + return harness; + } catch (err) { + if (socket !== null) { + socket.destroy(); + } + if (pty !== null) { + terminatePtyBestEffort(pty); + } + await closeServerQuiet(server); + throw err; + } + } + + #attachControlListeners(): void { + this.#socket.on("data", (chunk: string) => { + this.#lineBuf += chunk; + for (;;) { + const idx = this.#lineBuf.indexOf("\n"); + if (idx < 0) break; + const line = this.#lineBuf.slice(0, idx); + this.#lineBuf = this.#lineBuf.slice(idx + 1); + const msg = parseControlMessage(line); + if (msg === null) continue; + if (msg.type === "fatal") { + this.#rejectPending(new Error(`terminal-io-contract target fatal: ${msg.detail}`)); + continue; + } + if (msg.type !== "response") continue; + const waiter = this.#pending.get(msg.id); + if (waiter === undefined) continue; + this.#pending.delete(msg.id); + if (msg.ok) { + waiter.resolve(msg.result); + } else { + waiter.reject(new Error(msg.error)); + } + } + }); + + this.#socket.on("error", (err) => { + this.#rejectPending( + new Error(`terminal-io-contract control socket error: ${asErrorDetail(err)}`), + ); + }); + + this.#pty.onExit(({ exitCode, signal }) => { + const msg = `terminal-io-contract target exited: exit=${String(exitCode)} signal=${String(signal ?? "")}`; + this.#exitObserved = true; + this.#rejectPending(new Error(msg)); + this.#closed = true; + }); + } + + #rejectPending(err: Error): void { + for (const waiter of this.#pending.values()) { + waiter.reject(err); + } + this.#pending.clear(); + } + + async #sendCommand( + cmd: Omit, + timeoutMs = CONTROL_COMMAND_TIMEOUT_MS, + ): Promise { + if (this.#closed) throw new Error("terminal-io-contract harness is closed"); + const id = String(this.#nextId++); + const payload = { ...cmd, id } as ControlCommand; + const result = new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + }); + const timeout = setTimeout(() => { + const waiter = this.#pending.get(id); + if (waiter === undefined) return; + this.#pending.delete(id); + waiter.reject( + new Error( + `terminal-io-contract command timeout: cmd=${cmd.cmd} timeoutMs=${String(timeoutMs)}`, + ), + ); + }, timeoutMs); + this.#socket.write(`${JSON.stringify(payload)}\n`); + try { + return await result; + } finally { + clearTimeout(timeout); + } + } + + writeRaw(bytes: string): void { + this.#pty.write(bytes); + } + + resize(cols: number, rows: number): void { + this.#pty.resize(cols, rows); + } + + async pollOnce(): Promise { + const response = await this.#sendCommand({ cmd: "pollOnce" }); + if (response.kind !== "pollOnce") { + throw new Error(`terminal-io-contract: expected pollOnce result, got ${response.kind}`); + } + const bytes = Uint8Array.from(Buffer.from(response.bytesBase64, "base64")); + const parsed = parseEventBatchV1(bytes); + if (!parsed.ok) { + assert.fail( + `parseEventBatchV1 failed: code=${parsed.error.code} offset=${String(parsed.error.offset)} detail=${parsed.error.detail}`, + ); + } + return { + events: parsed.value.events, + droppedBatches: response.droppedBatches, + }; + } + + async stop(): Promise { + if (this.#closed) return; + try { + await this.#sendCommand({ cmd: "stop" }); + } catch { + // best-effort stop + } + this.#closed = true; + for (let i = 0; i < 200; i++) { + if (this.#exitObserved) break; + await delay(10); + } + if (!this.#exitObserved) { + terminatePtyBestEffort(this.#pty); + } + try { + this.#socket.destroy(); + } catch { + // ignore + } + try { + await closeServerQuiet(this.#server); + } catch { + // ignore + } + } +} + +function parseControlMessage(line: string): ControlMessage | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const rec = parsed as Partial; + if (rec.type === "ready") { + if (typeof rec.caps !== "object" || rec.caps === null) return null; + return rec as ControlReady; + } + if (rec.type === "fatal") { + if (typeof rec.detail !== "string") return null; + return rec as ControlFatal; + } + if (rec.type === "response") { + if (typeof rec.id !== "string") return null; + if (typeof rec.ok !== "boolean") return null; + return rec as ControlResponse; + } + return null; +} + +function isKey( + ev: ZrevEvent, + key: number, + mods: number, +): ev is Extract> { + return ev.kind === "key" && ev.key === key && ev.mods === mods && ev.action === "down"; +} + +function isText( + ev: ZrevEvent, + codepoint: number, +): ev is Extract> { + return ev.kind === "text" && ev.codepoint === codepoint; +} + +async function collectEvents( + harness: ContractHarness, + maxPolls: number, + stopWhen: (events: readonly ZrevEvent[]) => boolean, +): Promise { + const events: ZrevEvent[] = []; + for (let i = 0; i < maxPolls; i++) { + const batch = await harness.pollOnce(); + events.push(...batch.events); + if (stopWhen(events)) return events; + } + return events; +} + +async function writeAndCollectUntil( + harness: ContractHarness, + bytes: string, + maxPolls: number, + stopWhen: (events: readonly ZrevEvent[]) => boolean, +): Promise { + harness.writeRaw(bytes); + return await collectEvents(harness, maxPolls, stopWhen); +} + +async function writeAndCollectUntilWithRetries( + harness: ContractHarness, + bytes: string, + maxPolls: number, + stopWhen: (events: readonly ZrevEvent[]) => boolean, + maxAttempts: number, +): Promise { + const combined: ZrevEvent[] = []; + for (let i = 0; i < maxAttempts; i++) { + const events = await writeAndCollectUntil(harness, bytes, maxPolls, stopWhen); + combined.push(...events); + if (stopWhen(combined)) return combined; + } + return combined; +} + +function findIndex(events: readonly ZrevEvent[], pred: (ev: ZrevEvent) => boolean): number { + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + if (ev !== undefined && pred(ev)) return i; + } + return -1; +} + +async function createHarnessOrSkip( + t: TestContext, + cfg: HarnessConfig = {}, +): Promise { + let lastErr: unknown = null; + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await ContractHarness.create(cfg); + } catch (err) { + lastErr = err; + const detail = asErrorDetail(err); + if ( + attempt === 0 && + (detail.includes("exited before handshake") || detail.includes("handshake timeout")) + ) { + await delay(50); + continue; + } + t.skip(`terminal-io-contract harness unavailable: ${detail}`); + return null; + } + } + t.skip(`terminal-io-contract harness unavailable: ${asErrorDetail(lastErr)}`); + return null; +} + +test("terminal io contract: keyboard + paste + focus + mouse + resize + split reads", async (t: TestContext) => { + if (process.platform === "win32") { + t.skip( + "full terminal contract assertions run on Unix PTY; Windows uses ConPTY-specific coverage", + ); + return; + } + + const harness = await createHarnessOrSkip(t, { + env: { + ZIREAEL_CAP_MOUSE: "1", + ZIREAEL_CAP_BRACKETED_PASTE: "1", + ZIREAEL_CAP_FOCUS_EVENTS: "1", + }, + }); + if (harness === null) return; + + try { + // Initial resize is part of the contract and must arrive before explicit resizes. + const startupEvents = await collectEvents(harness, 20, (xs) => { + return findIndex(xs, (ev) => ev.kind === "resize") >= 0; + }); + assert.ok( + findIndex(startupEvents, (ev) => ev.kind === "resize") >= 0, + "missing initial resize event", + ); + + // Wait for at least one scheduler tick after initial resize before key assertions. + const readyTicks = await collectEvents(harness, 40, (xs) => { + return findIndex(xs, (ev) => ev.kind === "tick") >= 0; + }); + assert.ok( + findIndex(readyTicks, (ev) => ev.kind === "tick") >= 0, + "no post-startup tick observed before key assertions", + ); + + const ctrlUp = await writeAndCollectUntilWithRetries( + harness, + "\x1b[1;5A", + 40, + (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0; + }, + 3, + ); + assert.ok( + findIndex(ctrlUp, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0, + `missing Ctrl+Up; keys=${ctrlUp + .filter((ev): ev is Extract> => ev.kind === "key") + .map((ev) => `${String(ev.key)}/${String(ev.mods)}`) + .join(",")}`, + ); + + const shiftTab = await writeAndCollectUntil(harness, "\x1b[Z", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_SHIFT)) >= 0; + }); + assert.ok( + findIndex(shiftTab, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_SHIFT)) >= 0, + "missing Shift+Tab", + ); + + const ctrlTab = await writeAndCollectUntil(harness, "\x1b[9;5u", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_CTRL)) >= 0; + }); + assert.ok( + findIndex(ctrlTab, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_CTRL)) >= 0, + "missing Ctrl+Tab CSI-u", + ); + + const ctrlEnter = await writeAndCollectUntil(harness, "\x1b[13;5u", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_ENTER, ZR_MOD_CTRL)) >= 0; + }); + assert.ok( + findIndex(ctrlEnter, (ev) => isKey(ev, ZR_KEY_ENTER, ZR_MOD_CTRL)) >= 0, + "missing Ctrl+Enter CSI-u", + ); + + const ctrlBackspace = await writeAndCollectUntil(harness, "\x1b[127;5u", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_BACKSPACE, ZR_MOD_CTRL)) >= 0; + }); + assert.ok( + findIndex(ctrlBackspace, (ev) => isKey(ev, ZR_KEY_BACKSPACE, ZR_MOD_CTRL)) >= 0, + "missing Ctrl+Backspace CSI-u", + ); + + const altPolicy = await writeAndCollectUntil(harness, "\x1b[97;3u", 40, (xs) => { + const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const payload = findIndex( + xs, + (ev) => isText(ev, 97) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_ALT), + ); + return esc >= 0 && payload >= 0; + }); + const altEscIndex = findIndex(altPolicy, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const altPayloadIndex = findIndex( + altPolicy, + (ev) => isText(ev, 97) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_ALT), + ); + assert.ok(altEscIndex >= 0, "missing Alt escape prefix"); + assert.ok(altPayloadIndex >= 0, "missing Alt payload fallback event"); + assert.ok(altEscIndex < altPayloadIndex, "escape prefix must precede Alt payload"); + + const metaPolicy = await writeAndCollectUntil(harness, "\x1b[98;9u", 40, (xs) => { + const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const payload = findIndex( + xs, + (ev) => isText(ev, 98) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_META), + ); + return esc >= 0 && payload >= 0; + }); + const metaEscIndex = findIndex(metaPolicy, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const metaPayloadIndex = findIndex( + metaPolicy, + (ev) => isText(ev, 98) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_META), + ); + assert.ok(metaEscIndex >= 0, "missing Meta escape prefix"); + assert.ok(metaPayloadIndex >= 0, "missing Meta payload fallback event"); + assert.ok(metaEscIndex < metaPayloadIndex, "escape prefix must precede Meta payload"); + + const focusIn = await writeAndCollectUntil(harness, "\x1b[I", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0)) >= 0; + }); + assert.ok( + findIndex(focusIn, (ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0)) >= 0, + "missing focus-in event", + ); + + const focusOut = await writeAndCollectUntil(harness, "\x1b[O", 40, (xs) => { + return findIndex(xs, (ev) => isKey(ev, ZR_KEY_FOCUS_OUT, 0)) >= 0; + }); + assert.ok( + findIndex(focusOut, (ev) => isKey(ev, ZR_KEY_FOCUS_OUT, 0)) >= 0, + "missing focus-out event", + ); + + const mouseDown = await writeAndCollectUntil(harness, "\x1b[<0;300;400M", 40, (xs) => { + return ( + findIndex( + xs, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 3 && + ev.x === 299 && + ev.y === 399 && + ev.buttons === 1, + ) >= 0 + ); + }); + assert.ok( + findIndex( + mouseDown, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 3 && + ev.x === 299 && + ev.y === 399 && + ev.buttons === 1, + ) >= 0, + "missing mouse down with high coordinates", + ); + + const mouseUp = await writeAndCollectUntil(harness, "\x1b[<0;300;400m", 40, (xs) => { + return ( + findIndex( + xs, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 4 && + ev.x === 299 && + ev.y === 399 && + ev.buttons === 1, + ) >= 0 + ); + }); + assert.ok( + findIndex( + mouseUp, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 4 && + ev.x === 299 && + ev.y === 399 && + ev.buttons === 1, + ) >= 0, + "missing mouse up with high coordinates", + ); + + const mouseWheel = await writeAndCollectUntil(harness, "\x1b[<64;400;500M", 40, (xs) => { + return ( + findIndex( + xs, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 5 && + ev.x === 399 && + ev.y === 499 && + ev.wheelY === 1, + ) >= 0 + ); + }); + assert.ok( + findIndex( + mouseWheel, + (ev) => + ev.kind === "mouse" && + ev.mouseKind === 5 && + ev.x === 399 && + ev.y === 499 && + ev.wheelY === 1, + ) >= 0, + "missing mouse wheel with high coordinates", + ); + + // Split-read completion across multiple writes. + harness.writeRaw("\x1b["); + harness.writeRaw("A"); + const splitEvents = await collectEvents(harness, 20, (xs) => { + const up = findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, 0)); + const fallbackEsc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const fallbackBracket = findIndex(xs, (ev) => isText(ev, 91)); + return up >= 0 || (fallbackEsc >= 0 && fallbackBracket >= 0); + }); + const splitUp = findIndex(splitEvents, (ev) => isKey(ev, ZR_KEY_UP, 0)); + assert.ok(splitUp >= 0, "split CSI arrow completion did not produce Up key"); + assert.equal( + splitEvents.some((ev) => isKey(ev, ZR_KEY_ESCAPE, 0) || isText(ev, 91)), + false, + "split CSI arrow should not fallback to ESC+'[' when completed", + ); + + // Incomplete sequence fallback policy: ESC+[ without completion flushes as ESC key + text '['. + harness.writeRaw("\x1b["); + const fallbackEvents = await collectEvents(harness, 20, (xs) => { + const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const bracket = findIndex(xs, (ev) => isText(ev, 91)); + return esc >= 0 && bracket >= 0; + }); + const escFallbackIndex = findIndex(fallbackEvents, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0)); + const textBracketIndex = findIndex(fallbackEvents, (ev) => isText(ev, 91)); + assert.ok(escFallbackIndex >= 0, "incomplete escape fallback missing ESC event"); + assert.ok(textBracketIndex >= 0, "incomplete escape fallback missing text '[' event"); + assert.ok(escFallbackIndex < textBracketIndex, "fallback ESC must precede text '['"); + + // Bracketed paste framing. + harness.writeRaw("\x1b[200~hello\x1b[201~"); + const pasteEvents = await collectEvents(harness, 80, (xs) => { + return findIndex(xs, (ev) => ev.kind === "paste") >= 0; + }); + const framedPasteIndex = findIndex(pasteEvents, (ev) => ev.kind === "paste"); + assert.ok(framedPasteIndex >= 0, "missing framed paste event"); + const framedPaste = pasteEvents[framedPasteIndex]; + assert.ok(framedPaste !== undefined); + if (framedPaste !== undefined && framedPaste.kind === "paste") { + assert.equal(new TextDecoder().decode(framedPaste.bytes), "hello"); + } + + // Missing paste end marker must flush and not wedge input. + harness.writeRaw("\x1b[200~xyz"); + const missingEndEvents = await collectEvents(harness, 120, (xs) => { + return findIndex(xs, (ev) => ev.kind === "paste") >= 0; + }); + const missingEndPasteIndex = findIndex(missingEndEvents, (ev) => ev.kind === "paste"); + if (missingEndPasteIndex >= 0) { + const missingEndPaste = missingEndEvents[missingEndPasteIndex]; + assert.ok(missingEndPaste !== undefined); + if (missingEndPaste !== undefined && missingEndPaste.kind === "paste") { + assert.equal(new TextDecoder().decode(missingEndPaste.bytes), "xyz"); + } + } + + harness.writeRaw("q"); + const postMissingEnd = await collectEvents(harness, 40, (xs) => { + return findIndex(xs, (ev) => isText(ev, 113)) >= 0; + }); + assert.ok( + findIndex(postMissingEnd, (ev) => isText(ev, 113)) >= 0, + "input wedged after paste without end marker", + ); + + // Oversized paste overrun drops paste event and must not wedge input. + const oversizedPayload = "a".repeat(70_000); + harness.writeRaw(`\x1b[200~${oversizedPayload}\x1b[201~`); + const oversizedEvents = await collectEvents(harness, 120, (xs) => { + return findIndex(xs, (ev) => ev.kind === "paste") >= 0; + }); + assert.equal( + oversizedEvents.some((ev) => ev.kind === "paste"), + false, + "oversized paste should not emit a paste event", + ); + + harness.writeRaw("z"); + const postOversized = await collectEvents(harness, 20, (xs) => { + return findIndex(xs, (ev) => isText(ev, 122)) >= 0; + }); + assert.ok( + findIndex(postOversized, (ev) => isText(ev, 122)) >= 0, + "input wedged after oversized paste", + ); + + // Resize semantics and ordering. + harness.resize(100, 30); + const resizedEvents = await collectEvents(harness, 40, (xs) => { + return findIndex(xs, (ev) => ev.kind === "resize" && ev.cols === 100 && ev.rows === 30) >= 0; + }); + assert.ok( + findIndex(resizedEvents, (ev) => ev.kind === "resize" && ev.cols === 100 && ev.rows === 30) >= + 0, + "missing resize event after terminal resize", + ); + } finally { + await harness.stop(); + } +}); + +test("terminal io contract: focus gating when disabled", async (t: TestContext) => { + if (process.platform === "win32") { + t.skip("focus-gating contract assertion is covered on Unix PTY lanes"); + return; + } + + const harness = await createHarnessOrSkip(t, { + env: { + ZIREAEL_CAP_FOCUS_EVENTS: "0", + }, + }); + if (harness === null) return; + + try { + await harness.pollOnce(); + harness.writeRaw("\x1b[I\x1b[O"); + harness.writeRaw("k"); + const gatedEvents = await collectEvents(harness, 20, (xs) => { + return findIndex(xs, (ev) => isText(ev, 107)) >= 0; + }); + assert.equal( + gatedEvents.some((ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0) || isKey(ev, ZR_KEY_FOCUS_OUT, 0)), + false, + "focus events were emitted while focus mode was disabled", + ); + } finally { + await harness.stop(); + } +}); + +test("terminal io contract: windows ConPTY guarded coverage", async (t: TestContext) => { + if (process.platform !== "win32") { + t.skip("windows-only ConPTY coverage"); + return; + } + const ci = (process.env as NodeJS.ProcessEnv & { CI?: string }).CI; + if (ci === "true") { + t.skip("ConPTY guarded coverage is skipped on Windows CI; run locally on Windows for coverage"); + return; + } + + const harness = await createHarnessOrSkip(t, { + env: { + ZIREAEL_CAP_BRACKETED_PASTE: "1", + ZIREAEL_CAP_FOCUS_EVENTS: "1", + }, + }); + if (harness === null) return; + + try { + try { + await harness.pollOnce(); + + harness.writeRaw("\x1b[1;5A"); + harness.writeRaw("\x1b[200~win\x1b[201~"); + + const events = await collectEvents(harness, 50, (xs) => { + const arrow = findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0; + const paste = findIndex( + xs, + (ev) => ev.kind === "paste" && new TextDecoder().decode(ev.bytes) === "win", + ); + return arrow && paste >= 0; + }); + + assert.ok( + findIndex(events, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0, + "missing Ctrl+Up", + ); + assert.ok( + findIndex( + events, + (ev) => ev.kind === "paste" && new TextDecoder().decode(ev.bytes) === "win", + ) >= 0, + "missing bracketed paste on ConPTY", + ); + } catch (err) { + const detail = asErrorDetail(err); + if (detail.includes("command timeout")) { + t.skip(`ConPTY coverage unavailable in this environment: ${detail}`); + return; + } + throw err; + } + } finally { + await harness.stop(); + } +}); diff --git a/packages/node/src/__tests__/config_guards.test.ts b/packages/node/src/__tests__/config_guards.test.ts new file mode 100644 index 00000000..d09c5d06 --- /dev/null +++ b/packages/node/src/__tests__/config_guards.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "@rezi-ui/core"; +import { ZrUiError } from "@rezi-ui/core"; +import { createNodeApp, createNodeBackend } from "../index.js"; + +test("config guard: app useV2Cursor=true requires backend drawlist v2", () => { + const backend = createNodeBackend({ useDrawlistV2: false }); + try { + assert.throws( + () => + createApp({ + backend, + initialState: { value: 0 }, + config: { useV2Cursor: true }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("config.useV2Cursor=true but backend.useDrawlistV2=false"), + ); + } finally { + backend.dispose(); + } +}); + +test("config guard: backend drawlist v2 requires app useV2Cursor=true", () => { + const backend = createNodeBackend({ useDrawlistV2: true }); + try { + assert.throws( + () => + createApp({ + backend, + initialState: { value: 0 }, + config: { useV2Cursor: false }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("config.useV2Cursor=false but backend.useDrawlistV2=true"), + ); + } finally { + backend.dispose(); + } +}); + +test("config guard: maxEventBytes must match between app and backend", () => { + const backend = createNodeBackend({ maxEventBytes: 4096 }); + try { + assert.throws( + () => + createApp({ + backend, + initialState: { value: 0 }, + config: { maxEventBytes: 8192 }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("config.maxEventBytes=8192 must match backend maxEventBytes=4096"), + ); + } finally { + backend.dispose(); + } +}); + +test("config guard: fpsCap must match between app and backend", () => { + const backend = createNodeBackend({ fpsCap: 90 }); + try { + assert.throws( + () => + createApp({ + backend, + initialState: { value: 0 }, + config: { fpsCap: 60 }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("config.fpsCap=60 must match backend fpsCap=90"), + ); + } finally { + backend.dispose(); + } +}); + +test("config guard: fpsCap is canonical over nativeConfig target fps (worker path)", () => { + assert.throws( + () => + createNodeBackend({ + fpsCap: 60, + nativeConfig: { targetFps: 120 }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("fpsCap=60 must match nativeConfig.targetFps/target_fps=120"), + ); +}); + +test("config guard: fpsCap is canonical over nativeConfig target fps (inline path)", () => { + assert.throws( + () => + createNodeBackend({ + executionMode: "inline", + fpsCap: 30, + nativeConfig: { target_fps: 60 }, + }), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_INVALID_PROPS" && + err.message.includes("fpsCap=30 must match nativeConfig.targetFps/target_fps=60"), + ); +}); + +test("config guard: matching fpsCap/native target fps is accepted", () => { + const backend = createNodeBackend({ + fpsCap: 75, + nativeConfig: { target_fps: 75 }, + }); + backend.dispose(); +}); + +test("createNodeApp constructs a compatible app/backend pair", () => { + const app = createNodeApp({ + initialState: { value: 0 }, + config: { + useV2Cursor: true, + maxEventBytes: 4096, + fpsCap: 60, + executionMode: "inline", + }, + }); + app.dispose(); +}); diff --git a/packages/node/src/__tests__/worker_integration.test.ts b/packages/node/src/__tests__/worker_integration.test.ts index d71add12..3ab3b3a9 100644 --- a/packages/node/src/__tests__/worker_integration.test.ts +++ b/packages/node/src/__tests__/worker_integration.test.ts @@ -517,6 +517,46 @@ test("backend: maps fpsCap to native targetFps during init", async () => { backend.dispose(); }); +test("backend: worker path fails deterministically on invalid engine_poll_events byte counts", async () => { + const shim = new URL("../worker/testShims/invalidPollBytesNative.js", import.meta.url).href; + const backend = createNodeBackendInternal({ + config: { fpsCap: 1000, maxEventBytes: 64 }, + nativeShimModule: shim, + }); + + await backend.start(); + await assert.rejects( + backend.pollEvents(), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_BACKEND_ERROR" && + err.message.includes( + "engine_poll_events returned invalid byte count: written=65 capacity=64", + ), + ); + backend.dispose(); +}); + +test("backend: inline path fails deterministically on invalid engine_poll_events byte counts", async () => { + const shim = new URL("../worker/testShims/invalidPollBytesNative.js", import.meta.url).href; + const backend = createNodeBackendInternal({ + config: { executionMode: "inline", fpsCap: 1000, maxEventBytes: 64 }, + nativeShimModule: shim, + }); + + await backend.start(); + await assert.rejects( + backend.pollEvents(), + (err) => + err instanceof ZrUiError && + err.code === "ZRUI_BACKEND_ERROR" && + err.message.includes( + "engine_poll_events returned invalid byte count: written=65 capacity=64", + ), + ); + backend.dispose(); +}); + test("backend: mailbox resolves coalesced frame sequences", async () => { const shim = new URL("../worker/testShims/mockNative.js", import.meta.url).href; const backend = createNodeBackendInternal({ diff --git a/packages/node/src/backend/nodeBackend.ts b/packages/node/src/backend/nodeBackend.ts index 7dcd742d..005fc6c7 100644 --- a/packages/node/src/backend/nodeBackend.ts +++ b/packages/node/src/backend/nodeBackend.ts @@ -18,7 +18,13 @@ import type { RuntimeBackend, TerminalCaps, } from "@rezi-ui/core"; -import { DEFAULT_TERMINAL_CAPS, FRAME_ACCEPTED_ACK_MARKER } from "@rezi-ui/core"; +import { + BACKEND_DRAWLIST_V2_MARKER, + BACKEND_FPS_CAP_MARKER, + BACKEND_MAX_EVENT_BYTES_MARKER, + DEFAULT_TERMINAL_CAPS, + FRAME_ACCEPTED_ACK_MARKER, +} from "@rezi-ui/core"; import { ZR_DRAWLIST_VERSION_V1, ZR_DRAWLIST_VERSION_V2, @@ -153,9 +159,32 @@ function parsePositiveInt(n: unknown): number | null { return n; } -function readNativeTargetFps(cfg: Readonly>): number | null { +function readNativeTargetFpsValues( + cfg: Readonly>, +): Readonly<{ camel: number | null; snake: number | null }> { const targetFpsCfg = cfg as Readonly<{ targetFps?: unknown; target_fps?: unknown }>; - return parsePositiveInt(targetFpsCfg.targetFps) ?? parsePositiveInt(targetFpsCfg.target_fps); + return { + camel: parsePositiveInt(targetFpsCfg.targetFps), + snake: parsePositiveInt(targetFpsCfg.target_fps), + }; +} + +function resolveTargetFps(fpsCap: number, nativeConfig: Readonly>): number { + const values = readNativeTargetFpsValues(nativeConfig); + if (values.camel !== null && values.snake !== null && values.camel !== values.snake) { + throw new ZrUiError( + "ZRUI_INVALID_PROPS", + `createNodeBackend config mismatch: nativeConfig.targetFps=${String(values.camel)} must match nativeConfig.target_fps=${String(values.snake)}.`, + ); + } + const nativeTargetFps = values.camel ?? values.snake; + if (nativeTargetFps !== null && nativeTargetFps !== fpsCap) { + throw new ZrUiError( + "ZRUI_INVALID_PROPS", + `createNodeBackend config mismatch: fpsCap=${String(fpsCap)} must match nativeConfig.targetFps/target_fps=${String(nativeTargetFps)}. Fix: set nativeConfig.targetFps (or target_fps) to ${String(fpsCap)}, or remove the override and use fpsCap only.`, + ); + } + return fpsCap; } function safeErr(err: unknown): Error { @@ -320,12 +349,11 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N !Array.isArray(cfg.nativeConfig) ? (cfg.nativeConfig as Record) : Object.freeze({}); - const nativeTargetFps = readNativeTargetFps(nativeConfig) ?? fpsCap; + const nativeTargetFps = resolveTargetFps(fpsCap, nativeConfig); const initConfig: EngineCreateConfig = { ...nativeConfig, - // Keep native tick generation aligned with app/backend fpsCap unless - // explicitly overridden in nativeConfig. + // fpsCap is the single frame-scheduling knob; native target fps must align. targetFps: nativeTargetFps, // Negotiation pins (docs/16 + docs/01) requestedEngineAbiMajor: ZR_ENGINE_ABI_MAJOR, @@ -552,6 +580,38 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N } case "events": { + if (!Number.isInteger(msg.byteLen) || msg.byteLen < 0) { + fatal = new ZrUiError( + "ZRUI_BACKEND_ERROR", + `events: invalid byteLen=${String(msg.byteLen)}`, + ); + failAll(fatal); + return; + } + if (msg.byteLen > msg.batch.byteLength) { + fatal = new ZrUiError( + "ZRUI_BACKEND_ERROR", + `events: byteLen=${String(msg.byteLen)} exceeds batch.byteLength=${String(msg.batch.byteLength)}`, + ); + failAll(fatal); + return; + } + if (msg.byteLen > maxEventBytes) { + fatal = new ZrUiError( + "ZRUI_BACKEND_ERROR", + `events: byteLen=${String(msg.byteLen)} exceeds maxEventBytes=${String(maxEventBytes)}`, + ); + failAll(fatal); + return; + } + if (!Number.isInteger(msg.droppedSinceLast) || msg.droppedSinceLast < 0) { + fatal = new ZrUiError( + "ZRUI_BACKEND_ERROR", + `events: invalid droppedSinceLast=${String(msg.droppedSinceLast)}`, + ); + failAll(fatal); + return; + } const waiter = eventWaiters.shift(); if (waiter !== undefined) { const buf = msg.batch; @@ -1159,5 +1219,32 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N }), }; - return Object.assign(backend, { debug, perf }) satisfies NodeBackend; + const out = Object.assign(backend, { debug, perf }) as NodeBackend & + Record< + | typeof BACKEND_DRAWLIST_V2_MARKER + | typeof BACKEND_MAX_EVENT_BYTES_MARKER + | typeof BACKEND_FPS_CAP_MARKER, + boolean | number + >; + Object.defineProperties(out, { + [BACKEND_DRAWLIST_V2_MARKER]: { + value: useDrawlistV2, + writable: false, + enumerable: false, + configurable: false, + }, + [BACKEND_MAX_EVENT_BYTES_MARKER]: { + value: maxEventBytes, + writable: false, + enumerable: false, + configurable: false, + }, + [BACKEND_FPS_CAP_MARKER]: { + value: fpsCap, + writable: false, + enumerable: false, + configurable: false, + }, + }); + return out; } diff --git a/packages/node/src/backend/nodeBackendInline.ts b/packages/node/src/backend/nodeBackendInline.ts index 0afade07..3b4ac720 100644 --- a/packages/node/src/backend/nodeBackendInline.ts +++ b/packages/node/src/backend/nodeBackendInline.ts @@ -17,7 +17,12 @@ import type { RuntimeBackend, TerminalCaps, } from "@rezi-ui/core"; -import { DEFAULT_TERMINAL_CAPS } from "@rezi-ui/core"; +import { + BACKEND_DRAWLIST_V2_MARKER, + BACKEND_FPS_CAP_MARKER, + BACKEND_MAX_EVENT_BYTES_MARKER, + DEFAULT_TERMINAL_CAPS, +} from "@rezi-ui/core"; import { ZR_DRAWLIST_VERSION_V1, ZR_DRAWLIST_VERSION_V2, @@ -155,9 +160,32 @@ function parsePositiveInt(n: unknown): number | null { return n; } -function readNativeTargetFps(cfg: Readonly>): number | null { +function readNativeTargetFpsValues( + cfg: Readonly>, +): Readonly<{ camel: number | null; snake: number | null }> { const targetFpsCfg = cfg as Readonly<{ targetFps?: unknown; target_fps?: unknown }>; - return parsePositiveInt(targetFpsCfg.targetFps) ?? parsePositiveInt(targetFpsCfg.target_fps); + return { + camel: parsePositiveInt(targetFpsCfg.targetFps), + snake: parsePositiveInt(targetFpsCfg.target_fps), + }; +} + +function resolveTargetFps(fpsCap: number, nativeConfig: Readonly>): number { + const values = readNativeTargetFpsValues(nativeConfig); + if (values.camel !== null && values.snake !== null && values.camel !== values.snake) { + throw new ZrUiError( + "ZRUI_INVALID_PROPS", + `createNodeBackend config mismatch: nativeConfig.targetFps=${String(values.camel)} must match nativeConfig.target_fps=${String(values.snake)}.`, + ); + } + const nativeTargetFps = values.camel ?? values.snake; + if (nativeTargetFps !== null && nativeTargetFps !== fpsCap) { + throw new ZrUiError( + "ZRUI_INVALID_PROPS", + `createNodeBackend config mismatch: fpsCap=${String(fpsCap)} must match nativeConfig.targetFps/target_fps=${String(nativeTargetFps)}. Fix: set nativeConfig.targetFps (or target_fps) to ${String(fpsCap)}, or remove the override and use fpsCap only.`, + ); + } + return fpsCap; } function safeErr(err: unknown): Error { @@ -236,10 +264,11 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = !Array.isArray(cfg.nativeConfig) ? (cfg.nativeConfig as Record) : Object.freeze({}); - const nativeTargetFps = readNativeTargetFps(nativeConfig) ?? fpsCap; + const nativeTargetFps = resolveTargetFps(fpsCap, nativeConfig); const initConfig = { ...nativeConfig, + // fpsCap is the single frame-scheduling knob; native target fps must align. targetFps: nativeTargetFps, requestedEngineAbiMajor: ZR_ENGINE_ABI_MAJOR, requestedEngineAbiMinor: ZR_ENGINE_ABI_MINOR, @@ -341,11 +370,10 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } } - function failWith(where: string, code: number, detail: string): never { + function failWith(where: string, code: number, detail: string): void { const err = new ZrUiError("ZRUI_BACKEND_ERROR", `${where} (${String(code)}): ${detail}`); fatal = err; rejectWaiters(err); - throw err; } function clearPollLoop(): void { @@ -434,6 +462,7 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = written = native.enginePollEvents(engineId, 0, new Uint8Array(outBuf)); } catch (err) { failWith("enginePollEvents", -1, `engine_poll_events threw: ${safeDetail(err)}`); + return; } if (PERF_ENABLED) { perfRecord("event_poll", performance.now() - startMs); @@ -444,9 +473,19 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = schedulePoll(POLL_BUSY_MS); return; } + if (!Number.isInteger(written) || written > outBuf.byteLength) { + if (outBuf !== discardBuffer) eventPool.push(outBuf); + failWith( + "enginePollEvents", + -1, + `engine_poll_events returned invalid byte count: written=${String(written)} capacity=${String(outBuf.byteLength)}`, + ); + return; + } if (written < 0) { if (outBuf !== discardBuffer) eventPool.push(outBuf); failWith("enginePollEvents", written, "engine_poll_events failed"); + return; } if (written === 0) { if (outBuf !== discardBuffer) eventPool.push(outBuf); @@ -833,5 +872,32 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = perfSnapshot: async (): Promise => perfSnapshot(), }; - return Object.freeze({ ...backend, debug, perf }); + const out = { ...backend, debug, perf } as NodeBackend & + Record< + | typeof BACKEND_DRAWLIST_V2_MARKER + | typeof BACKEND_MAX_EVENT_BYTES_MARKER + | typeof BACKEND_FPS_CAP_MARKER, + boolean | number + >; + Object.defineProperties(out, { + [BACKEND_DRAWLIST_V2_MARKER]: { + value: useDrawlistV2, + writable: false, + enumerable: false, + configurable: false, + }, + [BACKEND_MAX_EVENT_BYTES_MARKER]: { + value: maxEventBytes, + writable: false, + enumerable: false, + configurable: false, + }, + [BACKEND_FPS_CAP_MARKER]: { + value: fpsCap, + writable: false, + enumerable: false, + configurable: false, + }, + }); + return Object.freeze(out); } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 81e0af8c..b8148a06 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,3 +1,10 @@ +import { + type App, + type AppConfig, + type Theme, + type ThemeDefinition, + createApp, +} from "@rezi-ui/core"; import { type NodeBackend, type NodeBackendConfig, @@ -7,6 +14,74 @@ import { export type { NodeBackendConfig }; export type { NodeBackend }; +export type NodeAppConfig = Readonly< + AppConfig & Omit +>; + +export type CreateNodeAppOptions = Readonly<{ + initialState: S; + config?: NodeAppConfig; + theme?: Theme | ThemeDefinition; +}>; + +function toAppConfig(config: NodeAppConfig | undefined): AppConfig | undefined { + if (config === undefined) return undefined; + return { + ...(config.fpsCap !== undefined ? { fpsCap: config.fpsCap } : {}), + ...(config.maxEventBytes !== undefined ? { maxEventBytes: config.maxEventBytes } : {}), + ...(config.maxDrawlistBytes !== undefined ? { maxDrawlistBytes: config.maxDrawlistBytes } : {}), + ...(config.useV2Cursor !== undefined ? { useV2Cursor: config.useV2Cursor } : {}), + ...(config.drawlistValidateParams !== undefined + ? { drawlistValidateParams: config.drawlistValidateParams } + : {}), + ...(config.drawlistReuseOutputBuffer !== undefined + ? { drawlistReuseOutputBuffer: config.drawlistReuseOutputBuffer } + : {}), + ...(config.drawlistEncodedStringCacheCap !== undefined + ? { drawlistEncodedStringCacheCap: config.drawlistEncodedStringCacheCap } + : {}), + ...(config.maxFramesInFlight !== undefined + ? { maxFramesInFlight: config.maxFramesInFlight } + : {}), + ...(config.internal_onRender !== undefined + ? { internal_onRender: config.internal_onRender } + : {}), + ...(config.internal_onLayout !== undefined + ? { internal_onLayout: config.internal_onLayout } + : {}), + }; +} + +function toBackendConfig(config: NodeAppConfig | undefined): NodeBackendConfig { + if (config === undefined) return {}; + return { + ...(config.executionMode !== undefined ? { executionMode: config.executionMode } : {}), + ...(config.fpsCap !== undefined ? { fpsCap: config.fpsCap } : {}), + ...(config.maxEventBytes !== undefined ? { maxEventBytes: config.maxEventBytes } : {}), + ...(config.useV2Cursor === true ? { useDrawlistV2: true } : {}), + ...(config.frameTransport !== undefined ? { frameTransport: config.frameTransport } : {}), + ...(config.frameSabSlotCount !== undefined + ? { frameSabSlotCount: config.frameSabSlotCount } + : {}), + ...(config.frameSabSlotBytes !== undefined + ? { frameSabSlotBytes: config.frameSabSlotBytes } + : {}), + ...(config.nativeConfig !== undefined ? { nativeConfig: config.nativeConfig } : {}), + }; +} + +export function createNodeApp(opts: CreateNodeAppOptions): App { + const appConfig = toAppConfig(opts.config); + const backend = createNodeBackend(toBackendConfig(opts.config)); + + return createApp({ + backend, + initialState: opts.initialState, + ...(appConfig !== undefined ? { config: appConfig } : {}), + ...(opts.theme !== undefined ? { theme: opts.theme } : {}), + }); +} + export function createNodeBackend(config: NodeBackendConfig = {}): NodeBackend { return createNodeBackendInternal({ config }); } diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index 593334f9..763028f3 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -767,6 +767,17 @@ function tick(): void { break; } + if (!Number.isInteger(written) || written > outBuf.byteLength) { + if (outBuf !== discard) eventPool.push(outBuf); + fatal( + "enginePollEvents", + -1, + `engine_poll_events returned invalid byte count: written=${String(written)} capacity=${String(outBuf.byteLength)}`, + ); + running = false; + return; + } + if (written < 0) { if (outBuf !== discard) eventPool.push(outBuf); fatal("enginePollEvents", written, "engine_poll_events failed"); diff --git a/packages/node/src/worker/testShims/invalidPollBytesNative.ts b/packages/node/src/worker/testShims/invalidPollBytesNative.ts new file mode 100644 index 00000000..e75c95f9 --- /dev/null +++ b/packages/node/src/worker/testShims/invalidPollBytesNative.ts @@ -0,0 +1,76 @@ +type EngineState = Readonly<{ + destroyed: boolean; + pollCalls: number; +}>; + +let nextEngineId = 1; +const engines = new Map(); + +export const native = { + engineCreate(_config?: object | null): number { + const id = nextEngineId++; + engines.set(id, Object.freeze({ destroyed: false, pollCalls: 0 })); + return id; + }, + + engineDestroy(engineId: number): void { + engines.delete(engineId); + }, + + engineSubmitDrawlist(engineId: number, _drawlist: Uint8Array): number { + return engines.has(engineId) ? 0 : -1; + }, + + enginePresent(engineId: number): number { + return engines.has(engineId) ? 0 : -1; + }, + + enginePollEvents(engineId: number, _timeoutMs: number, out: Uint8Array): number { + const state = engines.get(engineId); + if (!state) return -1; + const nextPollCalls = state.pollCalls + 1; + engines.set(engineId, Object.freeze({ ...state, pollCalls: nextPollCalls })); + if (nextPollCalls === 1) { + // Return an impossible byte count to verify guard behavior. + return out.byteLength + 1; + } + return 0; + }, + + enginePostUserEvent(engineId: number, _tag: number, _payload: Uint8Array): number { + return engines.has(engineId) ? 0 : -1; + }, + + engineSetConfig(engineId: number, _cfg?: object | null): number { + return engines.has(engineId) ? 0 : -1; + }, + + engineGetCaps(engineId: number) { + if (!engines.has(engineId)) { + return { + colorMode: 0, + supportsMouse: false, + supportsBracketedPaste: false, + supportsFocusEvents: false, + supportsOsc52: false, + supportsSyncUpdate: false, + supportsScrollRegion: false, + supportsCursorShape: false, + supportsOutputWaitWritable: false, + sgrAttrsSupported: 0, + }; + } + return { + colorMode: 2, + supportsMouse: true, + supportsBracketedPaste: true, + supportsFocusEvents: true, + supportsOsc52: false, + supportsSyncUpdate: true, + supportsScrollRegion: true, + supportsCursorShape: true, + supportsOutputWaitWritable: true, + sgrAttrsSupported: 0xffffffff, + }; + }, +} as const;