Skip to content

Commit 19b2d9c

Browse files
refactor(node): share backend helpers (#257)
1 parent c7d3f6c commit 19b2d9c

File tree

6 files changed

+280
-375
lines changed

6 files changed

+280
-375
lines changed

packages/node/src/__tests__/debugBufferRetry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3-
import { readDebugBytesWithRetry } from "../backend/nodeBackendInline.js";
3+
import { readDebugBytesWithRetry } from "../backend/backendSharedDebug.js";
44

55
test("readDebugBytesWithRetry retries when first read exactly fills the buffer", () => {
66
let callCount = 0;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { ZrUiError } from "@rezi-ui/core";
2+
3+
export const DEFAULT_FPS_CAP = 60 as const;
4+
export const MAX_SAFE_FPS_CAP = 1000 as const;
5+
export const DEFAULT_MAX_EVENT_BYTES = 1 << 20;
6+
export const MAX_SAFE_EVENT_BYTES = 4 << 20;
7+
8+
const EMPTY_NATIVE_CONFIG = Object.freeze({}) as Readonly<Record<string, unknown>>;
9+
10+
const DEFAULT_NATIVE_LIMITS: Readonly<Record<string, number>> = Object.freeze({
11+
// Align native validation caps with JS drawlist builder defaults.
12+
//
13+
// Native defaults are intentionally conservative; however, @rezi-ui/core's
14+
// drawlist builders default to 2 MiB max drawlist bytes and large command
15+
// budgets. Without overriding, moderately large frames (e.g. images/canvas)
16+
// can fail with ZR_ERR_LIMIT (-3) at submit time.
17+
outMaxBytesPerFrame: 2 * 1024 * 1024,
18+
dlMaxTotalBytes: 2 * 1024 * 1024,
19+
dlMaxCmds: 100_000,
20+
dlMaxStrings: 10_000,
21+
dlMaxBlobs: 10_000,
22+
});
23+
24+
function isPlainObject(v: unknown): v is Record<string, unknown> {
25+
return typeof v === "object" && v !== null && !Array.isArray(v);
26+
}
27+
28+
export function mergeNativeLimits(
29+
nativeConfig: Readonly<Record<string, unknown>>,
30+
): Readonly<Record<string, unknown>> {
31+
// biome-ignore lint/complexity/useLiteralKeys: bracket access is required by noPropertyAccessFromIndexSignature.
32+
const limitsValue = nativeConfig["limits"];
33+
const existingLimits = isPlainObject(limitsValue)
34+
? (limitsValue as Record<string, unknown>)
35+
: null;
36+
const limits: Record<string, unknown> = { ...(existingLimits ?? {}) };
37+
38+
const has = (camel: string): boolean => {
39+
const snake = camel.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
40+
return (
41+
Object.prototype.hasOwnProperty.call(limits, camel) ||
42+
Object.prototype.hasOwnProperty.call(limits, snake)
43+
);
44+
};
45+
46+
for (const [camel, value] of Object.entries(DEFAULT_NATIVE_LIMITS)) {
47+
if (has(camel)) continue;
48+
limits[camel] = value;
49+
}
50+
51+
return Object.freeze({ ...nativeConfig, limits: Object.freeze(limits) });
52+
}
53+
54+
export function normalizeBackendNativeConfig(
55+
nativeConfig: unknown,
56+
): Readonly<Record<string, unknown>> {
57+
return isPlainObject(nativeConfig)
58+
? mergeNativeLimits(nativeConfig)
59+
: mergeNativeLimits(EMPTY_NATIVE_CONFIG);
60+
}
61+
62+
export function parsePositiveIntOr(n: unknown, fallback: number): number {
63+
if (typeof n !== "number") return fallback;
64+
if (!Number.isFinite(n)) return fallback;
65+
if (!Number.isInteger(n)) return fallback;
66+
if (n <= 0) return fallback;
67+
return n;
68+
}
69+
70+
export function parsePositiveInt(n: unknown): number | null {
71+
if (typeof n !== "number") return null;
72+
if (!Number.isFinite(n)) return null;
73+
if (!Number.isInteger(n)) return null;
74+
if (n <= 0) return null;
75+
return n;
76+
}
77+
78+
export function parseBoundedPositiveIntOrThrow(
79+
name: string,
80+
value: unknown,
81+
fallback: number,
82+
max: number,
83+
): number {
84+
if (value === undefined) return fallback;
85+
const parsed = parsePositiveInt(value);
86+
if (parsed === null) {
87+
throw new ZrUiError("ZRUI_INVALID_PROPS", `${name} must be a positive integer`);
88+
}
89+
if (parsed > max) {
90+
throw new ZrUiError("ZRUI_INVALID_PROPS", `${name} must be <= ${String(max)}`);
91+
}
92+
return parsed;
93+
}
94+
95+
function readNativeTargetFpsValues(
96+
cfg: Readonly<Record<string, unknown>>,
97+
): Readonly<{ camel: number | null; snake: number | null }> {
98+
const targetFpsCfg = cfg as Readonly<{ targetFps?: unknown; target_fps?: unknown }>;
99+
return {
100+
camel: parsePositiveInt(targetFpsCfg.targetFps),
101+
snake: parsePositiveInt(targetFpsCfg.target_fps),
102+
};
103+
}
104+
105+
export function resolveTargetFps(
106+
fpsCap: number,
107+
nativeConfig: Readonly<Record<string, unknown>>,
108+
): number {
109+
const values = readNativeTargetFpsValues(nativeConfig);
110+
if (values.camel !== null && values.snake !== null && values.camel !== values.snake) {
111+
throw new ZrUiError(
112+
"ZRUI_INVALID_PROPS",
113+
`createNodeBackend config mismatch: nativeConfig.targetFps=${String(values.camel)} must match nativeConfig.target_fps=${String(values.snake)}.`,
114+
);
115+
}
116+
const nativeTargetFps = values.camel ?? values.snake;
117+
if (nativeTargetFps !== null && nativeTargetFps !== fpsCap) {
118+
throw new ZrUiError(
119+
"ZRUI_INVALID_PROPS",
120+
`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.`,
121+
);
122+
}
123+
return fpsCap;
124+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ZrUiError } from "@rezi-ui/core";
2+
3+
export const DEBUG_QUERY_DEFAULT_RECORDS = 4096 as const;
4+
export const DEBUG_QUERY_MAX_RECORDS = 16384 as const;
5+
6+
const MAX_DEBUG_BYTES_RETRY_CAP = 64 << 20;
7+
8+
export function readDebugBytesWithRetry<TEmpty>(
9+
read: (out: Uint8Array) => number,
10+
initialCapacity: number,
11+
emptyValue: TEmpty,
12+
operation: string,
13+
): Uint8Array | TEmpty {
14+
const baseCapacity = Number.isFinite(initialCapacity) ? Math.floor(initialCapacity) : 1;
15+
let capacity = Math.min(MAX_DEBUG_BYTES_RETRY_CAP, Math.max(1, baseCapacity));
16+
while (true) {
17+
const out = new Uint8Array(capacity);
18+
const written = read(out);
19+
if (!Number.isInteger(written) || written > out.byteLength) {
20+
throw new ZrUiError(
21+
"ZRUI_BACKEND_ERROR",
22+
`${operation} returned invalid byte count: written=${String(written)} capacity=${String(out.byteLength)}`,
23+
);
24+
}
25+
if (written <= 0) {
26+
return emptyValue;
27+
}
28+
if (written < out.byteLength) {
29+
return out.slice(0, written);
30+
}
31+
if (capacity >= MAX_DEBUG_BYTES_RETRY_CAP) {
32+
// Native debug APIs return written byte count but not total required size.
33+
// If the output exactly fills our max-cap buffer, it's likely truncated.
34+
process.emitWarning(
35+
`[rezi][debug] ${operation} filled ${String(capacity)} bytes at max cap; payload may be truncated.`,
36+
);
37+
return out.slice(0, written);
38+
}
39+
capacity = Math.min(MAX_DEBUG_BYTES_RETRY_CAP, capacity * 2);
40+
}
41+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { BackendRawWrite } from "@rezi-ui/core";
2+
import {
3+
BACKEND_BEGIN_FRAME_MARKER,
4+
BACKEND_DRAWLIST_VERSION_MARKER,
5+
BACKEND_FPS_CAP_MARKER,
6+
BACKEND_MAX_EVENT_BYTES_MARKER,
7+
BACKEND_RAW_WRITE_MARKER,
8+
} from "@rezi-ui/core";
9+
import type { BackendBeginFrame } from "@rezi-ui/core/backend";
10+
11+
type BackendMarkerOptions = Readonly<{
12+
requestedDrawlistVersion: number;
13+
maxEventBytes: number;
14+
fpsCap: number;
15+
beginFrame?: BackendBeginFrame | null;
16+
}>;
17+
18+
const backendRawWrite = ((text: string): void => {
19+
if (typeof text !== "string" || text.length === 0) return;
20+
try {
21+
process.stdout.write(text);
22+
} catch {
23+
// Preserve backend determinism: clipboard write failures are non-fatal.
24+
}
25+
}) satisfies BackendRawWrite;
26+
27+
export function attachBackendMarkers<TBackend extends object>(
28+
backend: TBackend,
29+
options: BackendMarkerOptions,
30+
): TBackend {
31+
const descriptors: PropertyDescriptorMap = {
32+
[BACKEND_DRAWLIST_VERSION_MARKER]: {
33+
value: options.requestedDrawlistVersion,
34+
writable: false,
35+
enumerable: false,
36+
configurable: false,
37+
},
38+
[BACKEND_MAX_EVENT_BYTES_MARKER]: {
39+
value: options.maxEventBytes,
40+
writable: false,
41+
enumerable: false,
42+
configurable: false,
43+
},
44+
[BACKEND_FPS_CAP_MARKER]: {
45+
value: options.fpsCap,
46+
writable: false,
47+
enumerable: false,
48+
configurable: false,
49+
},
50+
[BACKEND_RAW_WRITE_MARKER]: {
51+
value: backendRawWrite,
52+
writable: false,
53+
enumerable: false,
54+
configurable: false,
55+
},
56+
};
57+
58+
if (options.beginFrame !== null && options.beginFrame !== undefined) {
59+
descriptors[BACKEND_BEGIN_FRAME_MARKER] = {
60+
value: options.beginFrame,
61+
writable: false,
62+
enumerable: false,
63+
configurable: false,
64+
};
65+
}
66+
67+
Object.defineProperties(backend, descriptors);
68+
return backend;
69+
}

0 commit comments

Comments
 (0)