@rezi-ui/native 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @rezi-ui/native
2
+
3
+ Rust + `napi-rs` Node-API addon that hosts the Zireael C engine and exposes a minimal, safe JS API for the Node backend.
4
+
5
+ This package is not used directly by most applications. Install and use `@rezi-ui/node`, which depends on this package.
6
+
7
+ ## Local development
8
+
9
+ Build the native addon for your host platform:
10
+
11
+ ```bash
12
+ npm -w @rezi-ui/native run build:native
13
+ ```
14
+
15
+ Smoke test:
16
+
17
+ ```bash
18
+ npm -w @rezi-ui/native run test:native:smoke
19
+ ```
20
+
21
+ ## Design and constraints
22
+
23
+ - Engine ownership lives on a worker thread (never the Node main thread).
24
+ - All buffers across the boundary are caller-owned; binary formats are validated strictly.
25
+
26
+ See:
27
+
28
+ - [Native addon docs](../../docs/backend/native.md)
29
+ - [Releasing](RELEASING.md)
package/index.d.ts ADDED
@@ -0,0 +1,94 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ export interface EngineMetrics {
7
+ structSize: number;
8
+ negotiatedEngineAbiMajor: number;
9
+ negotiatedEngineAbiMinor: number;
10
+ negotiatedEngineAbiPatch: number;
11
+ negotiatedDrawlistVersion: number;
12
+ negotiatedEventBatchVersion: number;
13
+ frameIndex: bigint;
14
+ fps: number;
15
+ bytesEmittedTotal: bigint;
16
+ bytesEmittedLastFrame: number;
17
+ dirtyLinesLastFrame: number;
18
+ dirtyColsLastFrame: number;
19
+ usInputLastFrame: number;
20
+ usDrawlistLastFrame: number;
21
+ usDiffLastFrame: number;
22
+ usWriteLastFrame: number;
23
+ eventsOutLastPoll: number;
24
+ eventsDroppedTotal: number;
25
+ arenaFrameHighWaterBytes: bigint;
26
+ arenaPersistentHighWaterBytes: bigint;
27
+ damageRectsLastFrame: number;
28
+ damageCellsLastFrame: number;
29
+ damageFullFrame: boolean;
30
+ }
31
+ export interface TerminalCaps {
32
+ /** Color mode: 0=unknown, 1=16, 2=256, 3=rgb */
33
+ colorMode: number;
34
+ supportsMouse: boolean;
35
+ supportsBracketedPaste: boolean;
36
+ supportsFocusEvents: boolean;
37
+ supportsOsc52: boolean;
38
+ supportsSyncUpdate: boolean;
39
+ supportsScrollRegion: boolean;
40
+ supportsCursorShape: boolean;
41
+ supportsOutputWaitWritable: boolean;
42
+ /** Bitmask of supported SGR attributes */
43
+ sgrAttrsSupported: number;
44
+ }
45
+ export declare function engineCreate(config?: object | undefined | null): number;
46
+ export declare function engineDestroy(engineId: number): void;
47
+ export declare function engineSubmitDrawlist(engineId: number, drawlist: Uint8Array): number;
48
+ export declare function enginePresent(engineId: number): number;
49
+ export declare function enginePollEvents(
50
+ engineId: number,
51
+ timeoutMs: number,
52
+ out: Uint8Array,
53
+ ): number;
54
+ export declare function enginePostUserEvent(
55
+ engineId: number,
56
+ tag: number,
57
+ payload: Uint8Array,
58
+ ): number;
59
+ export declare function engineSetConfig(engineId: number, cfg?: object | undefined | null): number;
60
+ export declare function engineGetMetrics(engineId: number): EngineMetrics;
61
+ export declare function engineGetCaps(engineId: number): TerminalCaps;
62
+ export interface DebugStats {
63
+ totalRecords: bigint;
64
+ totalDropped: bigint;
65
+ errorCount: number;
66
+ warnCount: number;
67
+ currentRingUsage: number;
68
+ ringCapacity: number;
69
+ }
70
+ export interface DebugQueryResult {
71
+ recordsReturned: number;
72
+ recordsAvailable: number;
73
+ oldestRecordId: bigint;
74
+ newestRecordId: bigint;
75
+ recordsDropped: number;
76
+ }
77
+ export declare function engineDebugEnable(
78
+ engineId: number,
79
+ config?: object | undefined | null,
80
+ ): number;
81
+ export declare function engineDebugDisable(engineId: number): number;
82
+ export declare function engineDebugQuery(
83
+ engineId: number,
84
+ query: object | undefined | null,
85
+ outHeaders: Uint8Array,
86
+ ): DebugQueryResult;
87
+ export declare function engineDebugGetPayload(
88
+ engineId: number,
89
+ recordId: bigint,
90
+ outPayload: Uint8Array,
91
+ ): number;
92
+ export declare function engineDebugGetStats(engineId: number): DebugStats;
93
+ export declare function engineDebugExport(engineId: number, outBuf: Uint8Array): number;
94
+ export declare function engineDebugReset(engineId: number): number;
package/index.js ADDED
@@ -0,0 +1,50 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const rootDir = dirname(fileURLToPath(import.meta.url));
8
+
9
+ const discoveredNodeFiles = readdirSync(rootDir).filter((f) => f.endsWith(".node"));
10
+
11
+ const candidates = ["index.node", "rezi_ui_native.node", ...discoveredNodeFiles];
12
+
13
+ let native = null;
14
+ let lastErr = null;
15
+ for (const file of new Set(candidates)) {
16
+ try {
17
+ native = require(join(rootDir, file));
18
+ break;
19
+ } catch (err) {
20
+ lastErr = err;
21
+ }
22
+ }
23
+
24
+ if (!native) {
25
+ const extra =
26
+ lastErr instanceof Error ? `\n\nLast error:\n${lastErr.stack ?? lastErr.message}` : "";
27
+ throw new Error(
28
+ `Failed to load @rezi-ui/native binary. Tried: ${[...new Set(candidates)].join(", ")}${extra}`,
29
+ );
30
+ }
31
+
32
+ export const {
33
+ engineCreate,
34
+ engineDestroy,
35
+ engineSubmitDrawlist,
36
+ enginePresent,
37
+ enginePollEvents,
38
+ enginePostUserEvent,
39
+ engineGetMetrics,
40
+ engineSetConfig,
41
+ engineGetCaps,
42
+ // Debug trace API
43
+ engineDebugEnable,
44
+ engineDebugDisable,
45
+ engineDebugQuery,
46
+ engineDebugGetPayload,
47
+ engineDebugGetStats,
48
+ engineDebugExport,
49
+ engineDebugReset,
50
+ } = native;
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@rezi-ui/native",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Native addon for Rezi (napi-rs + Zireael engine).",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://rtlzeromemory.github.io/Rezi/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/RtlZeroMemory/Rezi.git",
10
+ "directory": "packages/native"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/RtlZeroMemory/Rezi/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./index.js",
17
+ "types": "./index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./index.d.ts",
21
+ "default": "./index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "index.js",
26
+ "index.d.ts",
27
+ "*.node",
28
+ "scripts/*.mjs",
29
+ "package.json"
30
+ ],
31
+ "scripts": {
32
+ "build:native": "node ./scripts/build-native.mjs",
33
+ "test:native:smoke": "node ./scripts/smoke.mjs"
34
+ },
35
+ "devDependencies": {
36
+ "@napi-rs/cli": "^2.18.4"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "napi": {
42
+ "name": "rezi_ui_native"
43
+ }
44
+ }
Binary file
Binary file
@@ -0,0 +1,487 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+
6
+ function splitPathList(value, delim) {
7
+ return value
8
+ .split(delim)
9
+ .map((p) => p.trim().replace(/^"+|"+$/g, ""))
10
+ .filter(Boolean);
11
+ }
12
+
13
+ function envHasLib(env, libName) {
14
+ if (process.platform !== "win32") return true;
15
+ const libPath = env.LIB;
16
+ if (typeof libPath !== "string" || libPath.length === 0) return false;
17
+ for (const dir of splitPathList(libPath, ";")) {
18
+ if (existsSync(join(dir, libName))) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function parseCmdSetOutput(output) {
24
+ const next = {};
25
+ for (const raw of output.split(/\r?\n/)) {
26
+ const line = raw.trimEnd();
27
+ if (line.length === 0) continue;
28
+ const idx = line.indexOf("=");
29
+ if (idx <= 0) continue;
30
+ const key = line.slice(0, idx);
31
+ // cmd.exe prints internal per-drive cwd entries like `=C:=C:\...`.
32
+ if (key.startsWith("=")) continue;
33
+ next[key] = line.slice(idx + 1);
34
+ }
35
+ return next;
36
+ }
37
+
38
+ function runBatchAndGetEnv(batchFile, batchArgs, env) {
39
+ const args = Array.isArray(batchArgs) ? batchArgs : [];
40
+
41
+ // Avoid nested quoting issues between Node/Windows argument escaping and cmd.exe parsing
42
+ // by writing a temporary .cmd file. This keeps the `call "C:\\path with spaces\\..."` intact.
43
+ const scriptPath = join(
44
+ tmpdir(),
45
+ `rezi-ui-native-msvc-env-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.cmd`,
46
+ );
47
+ const script = `@echo off\r\ncall "${batchFile}" ${args.join(" ")}\r\nset\r\n`;
48
+ writeFileSync(scriptPath, script, { encoding: "utf8" });
49
+
50
+ try {
51
+ const out = execFileSync("cmd.exe", ["/d", "/s", "/c", scriptPath], { encoding: "utf8", env });
52
+ return parseCmdSetOutput(out);
53
+ } finally {
54
+ rmSync(scriptPath, { force: true });
55
+ }
56
+ }
57
+
58
+ function findVswhere() {
59
+ const pf86 = process.env["ProgramFiles(x86)"];
60
+ const pf = process.env.ProgramFiles;
61
+ const candidates = [
62
+ pf86 ? join(pf86, "Microsoft Visual Studio", "Installer", "vswhere.exe") : null,
63
+ pf ? join(pf, "Microsoft Visual Studio", "Installer", "vswhere.exe") : null,
64
+ ].filter(Boolean);
65
+ for (const p of candidates) {
66
+ if (p && existsSync(p)) return p;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function tryGetVisualStudioInstallPath(env) {
72
+ const vswhere = findVswhere();
73
+ if (!vswhere) return null;
74
+ try {
75
+ const out = execFileSync(
76
+ vswhere,
77
+ [
78
+ "-latest",
79
+ "-products",
80
+ "*",
81
+ "-requires",
82
+ "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
83
+ "-property",
84
+ "installationPath",
85
+ ],
86
+ { encoding: "utf8", env },
87
+ );
88
+ const installPath = out.trim();
89
+ return installPath.length > 0 ? installPath : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function getMsvcArchFromRustTarget(hostTargetTriple) {
96
+ if (typeof hostTargetTriple !== "string") return "x64";
97
+ if (hostTargetTriple.startsWith("x86_64-")) return "x64";
98
+ if (hostTargetTriple.startsWith("i686-")) return "x86";
99
+ if (hostTargetTriple.startsWith("aarch64-")) return "arm64";
100
+ return "x64";
101
+ }
102
+
103
+ function getMsvcHostArch() {
104
+ if (process.arch === "x64") return "x64";
105
+ if (process.arch === "arm64") return "arm64";
106
+ if (process.arch === "ia32") return "x86";
107
+ return "x64";
108
+ }
109
+
110
+ function compareSemverDirs(a, b) {
111
+ // Windows SDK folders look like `10.0.22621.0`. Compare numerically.
112
+ const pa = a.split(".").map((n) => Number(n));
113
+ const pb = b.split(".").map((n) => Number(n));
114
+ const len = Math.max(pa.length, pb.length);
115
+ for (let i = 0; i < len; i++) {
116
+ const da = pa[i] ?? 0;
117
+ const db = pb[i] ?? 0;
118
+ if (da !== db) return da - db;
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ function tryGetLatestWindowsSdkVersion(sdkRoot) {
124
+ const libRoot = join(sdkRoot, "Lib");
125
+ if (!existsSync(libRoot)) return null;
126
+
127
+ try {
128
+ const dirs = readdirSync(libRoot, { withFileTypes: true })
129
+ .filter((d) => d.isDirectory() && /^\d+\.\d+\.\d+\.\d+$/.test(d.name))
130
+ .map((d) => d.name)
131
+ .sort(compareSemverDirs);
132
+ return dirs.length > 0 ? dirs[dirs.length - 1] : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function withWindowsSdkEnv(env, hostTargetTriple) {
139
+ if (process.platform !== "win32") return env;
140
+
141
+ const arch = getMsvcArchFromRustTarget(hostTargetTriple);
142
+ const pf86 = env["ProgramFiles(x86)"] ?? process.env["ProgramFiles(x86)"];
143
+ const sdkRoot =
144
+ typeof pf86 === "string" && pf86.length > 0
145
+ ? join(pf86, "Windows Kits", "10")
146
+ : join("C:\\", "Program Files (x86)", "Windows Kits", "10");
147
+
148
+ const sdkVersion = tryGetLatestWindowsSdkVersion(sdkRoot);
149
+ if (!sdkVersion) return env;
150
+
151
+ const libUcrt = join(sdkRoot, "Lib", sdkVersion, "ucrt", arch);
152
+ const libUm = join(sdkRoot, "Lib", sdkVersion, "um", arch);
153
+ const incRoot = join(sdkRoot, "Include", sdkVersion);
154
+ const incUcrt = join(incRoot, "ucrt");
155
+ const incShared = join(incRoot, "shared");
156
+ const incUm = join(incRoot, "um");
157
+ const incWinrt = join(incRoot, "winrt");
158
+ const incCppWinrt = join(incRoot, "cppwinrt");
159
+ const binVersioned = join(sdkRoot, "Bin", sdkVersion, arch);
160
+ const binFallback = join(sdkRoot, "Bin", arch);
161
+
162
+ const next = { ...env };
163
+
164
+ // SDK variables are not strictly required for linking, but setting them helps
165
+ // downstream tools (rc.exe, mt.exe, and various build scripts) behave.
166
+ if (typeof next.WindowsSdkDir !== "string" || next.WindowsSdkDir.length === 0) {
167
+ next.WindowsSdkDir = `${sdkRoot}\\`;
168
+ }
169
+ if (typeof next.WindowsSDKVersion !== "string" || next.WindowsSDKVersion.length === 0) {
170
+ next.WindowsSDKVersion = `${sdkVersion}\\`;
171
+ }
172
+
173
+ const addTo = (key, paths) => {
174
+ const current = typeof next[key] === "string" ? next[key] : "";
175
+ const parts = current.length > 0 ? splitPathList(current, ";") : [];
176
+ const seen = new Set(parts.map((p) => p.toLowerCase()));
177
+ for (const p of paths) {
178
+ if (!p || !existsSync(p)) continue;
179
+ const lower = p.toLowerCase();
180
+ if (seen.has(lower)) continue;
181
+ parts.push(p);
182
+ seen.add(lower);
183
+ }
184
+ next[key] = parts.join(";");
185
+ };
186
+
187
+ addTo("LIB", [libUcrt, libUm]);
188
+ addTo("INCLUDE", [incUcrt, incShared, incUm, incWinrt, incCppWinrt]);
189
+ addTo("PATH", [binVersioned, binFallback]);
190
+
191
+ return next;
192
+ }
193
+
194
+ function withMsvcDevEnv(env, hostTargetTriple) {
195
+ if (process.platform !== "win32") return env;
196
+
197
+ // If the environment already has the MSVC/SDK lib paths, don't mutate it.
198
+ if (envHasLib(env, "msvcrt.lib") && envHasLib(env, "kernel32.lib")) return env;
199
+
200
+ const vsInstallPath = tryGetVisualStudioInstallPath(env);
201
+ if (!vsInstallPath) return env;
202
+
203
+ const vsDevCmd = join(vsInstallPath, "Common7", "Tools", "VsDevCmd.bat");
204
+ const vcvars64 = join(vsInstallPath, "VC", "Auxiliary", "Build", "vcvars64.bat");
205
+ const batchFile = existsSync(vsDevCmd) ? vsDevCmd : existsSync(vcvars64) ? vcvars64 : null;
206
+ if (!batchFile) return env;
207
+
208
+ const targetArch = getMsvcArchFromRustTarget(hostTargetTriple);
209
+ const hostArch = getMsvcHostArch();
210
+
211
+ try {
212
+ const batchArgs = batchFile.toLowerCase().endsWith("vsdevcmd.bat")
213
+ ? ["-no_logo", `-arch=${targetArch}`, `-host_arch=${hostArch}`]
214
+ : [];
215
+ const next = runBatchAndGetEnv(batchFile, batchArgs, env);
216
+ return envHasLib(next, "msvcrt.lib") ? next : env;
217
+ } catch {
218
+ return env;
219
+ }
220
+ }
221
+
222
+ function spawnNpm(args, options) {
223
+ // Prefer the npm CLI path provided by npm itself when running under `npm run`.
224
+ // This avoids shell differences on Windows (cmd.exe vs bash) and PATH issues.
225
+ const npmExecPath = process.env.npm_execpath;
226
+ if (npmExecPath && existsSync(npmExecPath)) {
227
+ return spawnSync(process.execPath, [npmExecPath, ...args], options);
228
+ }
229
+
230
+ const candidates = process.platform === "win32" ? ["npm", "npm.cmd"] : ["npm"];
231
+ let last = null;
232
+ for (const cmd of candidates) {
233
+ const res = spawnSync(cmd, args, options);
234
+ last = res;
235
+ if (!res.error) return res;
236
+ }
237
+ return last;
238
+ }
239
+
240
+ function canRunCargo(env) {
241
+ try {
242
+ execFileSync("cargo", ["--version"], { stdio: "ignore", env });
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ function getCargoExePath(env) {
250
+ const cargoHome =
251
+ typeof env.CARGO_HOME === "string" && env.CARGO_HOME.length > 0
252
+ ? env.CARGO_HOME
253
+ : typeof env.USERPROFILE === "string" && env.USERPROFILE.length > 0
254
+ ? join(env.USERPROFILE, ".cargo")
255
+ : null;
256
+ if (!cargoHome) return null;
257
+ const cargoBin = join(cargoHome, "bin");
258
+ const cargoExe =
259
+ process.platform === "win32" ? join(cargoBin, "cargo.exe") : join(cargoBin, "cargo");
260
+ return existsSync(cargoExe) ? cargoExe : null;
261
+ }
262
+
263
+ function ensureCargoOnPath(env) {
264
+ if (canRunCargo(env)) return env;
265
+
266
+ const cargoExe = getCargoExePath(env);
267
+ if (!cargoExe) {
268
+ const hint =
269
+ process.platform === "win32"
270
+ ? [
271
+ "@rezi-ui/native: cargo not found (Rust toolchain missing).",
272
+ "",
273
+ "Install Rust (includes cargo) via rustup: https://rustup.rs/",
274
+ "Then reopen your terminal and verify:",
275
+ " cargo --version",
276
+ " rustc --version",
277
+ ].join("\n")
278
+ : [
279
+ "@rezi-ui/native: cargo not found (Rust toolchain missing).",
280
+ "",
281
+ "Install Rust via rustup: https://rustup.rs/",
282
+ "Then verify: cargo --version",
283
+ ].join("\n");
284
+ process.stderr.write(`${hint}\n`);
285
+ process.exit(1);
286
+ }
287
+
288
+ const next = { ...env };
289
+ const cargoBin = join(cargoExe, "..");
290
+ const currentPath = typeof next.PATH === "string" ? next.PATH : "";
291
+ next.PATH = `${cargoBin}${delimiter}${currentPath}`;
292
+ if (canRunCargo(next)) return next;
293
+
294
+ process.stderr.write(
295
+ `${[
296
+ "@rezi-ui/native: cargo exists on disk but still isn't runnable from this npm script environment.",
297
+ "",
298
+ `Found cargo at: ${cargoExe}`,
299
+ "",
300
+ "Try running the build from a fresh terminal, or set PATH so it includes your Rust bin directory.",
301
+ "On Windows with rustup, that's usually:",
302
+ ` ${join(String(env.USERPROFILE ?? "C:\\Users\\<you>"), ".cargo", "bin")}`,
303
+ ].join("\n")}\n`,
304
+ );
305
+ process.exit(1);
306
+ }
307
+
308
+ function withRustToolchainOnPath(env) {
309
+ const next = { ...env };
310
+ const currentPath = typeof next.PATH === "string" ? next.PATH : "";
311
+
312
+ // When npm is configured to run scripts via Git Bash on Windows, PATH may not
313
+ // include the Rust toolchain even if it exists on disk. The napi CLI shells
314
+ // out to `cargo`, so ensure it's discoverable.
315
+ const cargoHome =
316
+ typeof next.CARGO_HOME === "string" && next.CARGO_HOME.length > 0
317
+ ? next.CARGO_HOME
318
+ : typeof next.USERPROFILE === "string" && next.USERPROFILE.length > 0
319
+ ? join(next.USERPROFILE, ".cargo")
320
+ : null;
321
+
322
+ if (cargoHome) {
323
+ const cargoBin = join(cargoHome, "bin");
324
+ const cargoExe =
325
+ process.platform === "win32" ? join(cargoBin, "cargo.exe") : join(cargoBin, "cargo");
326
+ if (existsSync(cargoExe) && !currentPath.toLowerCase().includes(cargoBin.toLowerCase())) {
327
+ next.PATH = `${cargoBin}${delimiter}${currentPath}`;
328
+ }
329
+ }
330
+
331
+ // Ensure `napi` can find cargo even if it shells out through cmd.exe with a
332
+ // different PATH resolution behavior.
333
+ if (typeof next.CARGO !== "string" || next.CARGO.length === 0) {
334
+ const cargoExe = getCargoExePath(next);
335
+ if (cargoExe) next.CARGO = cargoExe;
336
+ }
337
+
338
+ return next;
339
+ }
340
+
341
+ function buildWithCargoDirectly(env, host) {
342
+ const cargoExe = getCargoExePath(env) ?? "cargo";
343
+ try {
344
+ execFileSync(cargoExe, ["build", "--release", "--target", host], { stdio: "inherit", env });
345
+ } catch (err) {
346
+ if (process.platform === "win32") {
347
+ process.stderr.write(
348
+ `${[
349
+ "",
350
+ "@rezi-ui/native: cargo build failed on Windows.",
351
+ "If you see linker errors like `LNK1104: cannot open file 'msvcrt.lib'` or missing headers like `stdint.h`,",
352
+ "install Visual Studio Build Tools (MSVC v143 + Windows 10/11 SDK) and run the build from a VS Developer shell.",
353
+ "",
354
+ ].join("\n")}\n`,
355
+ );
356
+ }
357
+ throw err;
358
+ }
359
+
360
+ const crateName = "rezi_ui_native";
361
+ const targetDir = join(process.cwd(), "target", host, "release");
362
+ const built =
363
+ process.platform === "win32"
364
+ ? join(targetDir, `${crateName}.dll`)
365
+ : process.platform === "darwin"
366
+ ? join(targetDir, `lib${crateName}.dylib`)
367
+ : join(targetDir, `lib${crateName}.so`);
368
+
369
+ if (!existsSync(built)) {
370
+ throw new Error(`@rezi-ui/native: cargo build succeeded but output was not found: ${built}`);
371
+ }
372
+
373
+ // Node loads addons as .node (they are native shared libraries under the hood).
374
+ // Keep both filenames as candidates for the JS loader.
375
+ copyFileSync(built, join(process.cwd(), "rezi_ui_native.node"));
376
+ copyFileSync(built, join(process.cwd(), "index.node"));
377
+ }
378
+
379
+ function getHostTargetTriple() {
380
+ let out;
381
+ try {
382
+ out = execFileSync("rustc", ["-vV"], { encoding: "utf8" });
383
+ } catch (err) {
384
+ const hint =
385
+ process.platform === "win32"
386
+ ? "Install Rust from https://rustup.rs/ (or ensure `rustc.exe` is on PATH)."
387
+ : "Install Rust via rustup (https://rustup.rs/) and ensure `rustc` is on PATH.";
388
+ const detail = err instanceof Error ? err.message : String(err);
389
+ throw new Error(`@rezi-ui/native: rustc not found or not runnable.\n${hint}\n\n${detail}`);
390
+ }
391
+ const line = out
392
+ .split("\n")
393
+ .map((l) => l.trim())
394
+ .find((l) => l.startsWith("host: "));
395
+ if (!line) throw new Error("Failed to determine Rust host triple from `rustc -vV`");
396
+ return line.slice("host: ".length).trim();
397
+ }
398
+
399
+ let host;
400
+ try {
401
+ host = getHostTargetTriple();
402
+ } catch (err) {
403
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
404
+ process.exit(1);
405
+ }
406
+
407
+ const scriptEnv = ensureCargoOnPath(
408
+ withWindowsSdkEnv(withMsvcDevEnv(withRustToolchainOnPath(process.env), host), host),
409
+ );
410
+
411
+ // @napi-rs/cli parses Cargo.toml by running `cargo metadata` through cmd.exe on Windows.
412
+ // Some environments can run `cargo` fine from PowerShell, but cmd.exe fails to resolve it.
413
+ // Use a direct cargo build path on Windows to avoid that failure mode.
414
+ if (process.platform === "win32") {
415
+ try {
416
+ buildWithCargoDirectly(scriptEnv, host);
417
+ } catch (err) {
418
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
419
+ process.exit(1);
420
+ }
421
+ } else {
422
+ const res = spawnNpm(
423
+ ["exec", "--", "napi", "build", "--platform", "--release", "--target", host, "--js", "false"],
424
+ {
425
+ stdio: "inherit",
426
+ env: scriptEnv,
427
+ },
428
+ );
429
+
430
+ if (res?.error) {
431
+ const detail = res.error instanceof Error ? res.error.message : String(res.error);
432
+ process.stderr.write(`@rezi-ui/native: failed to invoke npm.\n\n${detail}\n`);
433
+ process.exit(1);
434
+ }
435
+
436
+ if (res.status !== 0) {
437
+ process.exit(res.status ?? 1);
438
+ }
439
+ }
440
+
441
+ if (existsSync("./index.d.ts")) {
442
+ const biomeArgs = ["format", "index.d.ts", "--write"];
443
+
444
+ // On Windows, npm may be configured to run scripts via Git Bash, which can end up
445
+ // selecting the POSIX `.bin/biome` shim that relies on `node` being resolvable via
446
+ // a POSIX-style PATH. Run the Biome entrypoint with Node directly to avoid that.
447
+ const fmt =
448
+ process.platform === "win32"
449
+ ? (() => {
450
+ const initCwd = process.env.INIT_CWD;
451
+ const roots = [
452
+ typeof initCwd === "string" && initCwd.length > 0 ? initCwd : null,
453
+ join(process.cwd(), "..", ".."),
454
+ ].filter(Boolean);
455
+
456
+ for (const root of roots) {
457
+ const biomeEntrypoint = join(root, "node_modules", "@biomejs", "biome", "bin", "biome");
458
+ if (existsSync(biomeEntrypoint)) {
459
+ return spawnSync(process.execPath, [biomeEntrypoint, ...biomeArgs], {
460
+ stdio: "inherit",
461
+ env: scriptEnv,
462
+ });
463
+ }
464
+ }
465
+
466
+ // Fall back to `npm exec` if the entrypoint isn't where we expect.
467
+ return spawnNpm(["exec", "--", "biome", ...biomeArgs], {
468
+ stdio: "inherit",
469
+ env: scriptEnv,
470
+ });
471
+ })()
472
+ : spawnNpm(["exec", "--", "biome", ...biomeArgs], {
473
+ stdio: "inherit",
474
+ env: scriptEnv,
475
+ });
476
+ if (fmt?.error) {
477
+ const detail = fmt.error instanceof Error ? fmt.error.message : String(fmt.error);
478
+ process.stderr.write(
479
+ `@rezi-ui/native: warning: failed to invoke npm for formatting index.d.ts (continuing).\n\n${detail}\n`,
480
+ );
481
+ }
482
+ if (fmt.status !== 0) {
483
+ process.stderr.write(
484
+ `@rezi-ui/native: warning: biome format returned non-zero status (${fmt.status ?? "unknown"}); continuing.\n`,
485
+ );
486
+ }
487
+ }
@@ -0,0 +1,18 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { enginePostUserEvent, enginePresent } from "../index.js";
3
+
4
+ const { engineId } = workerData;
5
+
6
+ if (!parentPort) {
7
+ throw new Error("smoke-worker: missing parentPort");
8
+ }
9
+
10
+ const res1 = enginePresent(engineId);
11
+ const res2 = enginePostUserEvent(engineId, 123, new Uint8Array([1, 2, 3]));
12
+ parentPort.postMessage({ phase: "alive", present: res1, postUserEvent: res2 });
13
+
14
+ parentPort.on("message", (msg) => {
15
+ if (msg?.type !== "afterDestroy") return;
16
+ const res = enginePostUserEvent(engineId, 456, new Uint8Array([9]));
17
+ parentPort.postMessage({ phase: "destroyed", postUserEvent: res });
18
+ });
@@ -0,0 +1,82 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { engineCreate, engineDestroy, enginePresent } from "../index.js";
3
+
4
+ const ZR_ERR_INVALID_ARGUMENT = -1;
5
+ const ZR_ERR_PLATFORM = -6;
6
+
7
+ function assert(cond, msg) {
8
+ if (!cond) throw new Error(msg);
9
+ }
10
+
11
+ // Unknown / stale id behavior (result-returning fns).
12
+ assert(
13
+ enginePresent(0) === ZR_ERR_INVALID_ARGUMENT,
14
+ "enginePresent(0) must return ZR_ERR_INVALID_ARGUMENT",
15
+ );
16
+ assert(
17
+ enginePresent(0x7fff_fffe) === ZR_ERR_INVALID_ARGUMENT,
18
+ "enginePresent(unknown) must return ZR_ERR_INVALID_ARGUMENT",
19
+ );
20
+
21
+ const engineId = engineCreate({});
22
+
23
+ assert(typeof engineId === "number", "engineCreate must return a number");
24
+ if (engineId === ZR_ERR_PLATFORM && !(process.stdout.isTTY && process.stdin.isTTY)) {
25
+ process.stdout.write("native-smoke: SKIP engineCreate() (no TTY / platform init unavailable)\n");
26
+ process.exit(0);
27
+ }
28
+ assert(engineId > 0, `engineCreate must return a non-zero engineId, got: ${engineId}`);
29
+
30
+ const worker = new Worker(new URL("./smoke-worker.mjs", import.meta.url), {
31
+ workerData: { engineId },
32
+ type: "module",
33
+ });
34
+
35
+ const alive = await new Promise((resolve, reject) => {
36
+ const onExit = (code) => reject(new Error(`worker exited with ${code}`));
37
+ const onError = (err) => reject(err);
38
+ const onMessage = (msg) => {
39
+ worker.off("exit", onExit);
40
+ worker.off("error", onError);
41
+ resolve(msg);
42
+ };
43
+ worker.once("exit", onExit);
44
+ worker.once("error", onError);
45
+ worker.once("message", onMessage);
46
+ });
47
+
48
+ assert(alive.phase === "alive", "worker must send alive phase");
49
+ assert(
50
+ alive.present === ZR_ERR_INVALID_ARGUMENT,
51
+ `wrong-thread enginePresent must return ZR_ERR_INVALID_ARGUMENT, got: ${alive.present}`,
52
+ );
53
+ assert(
54
+ alive.postUserEvent === 0,
55
+ `enginePostUserEvent must succeed cross-thread while alive (ZR_OK), got: ${alive.postUserEvent}`,
56
+ );
57
+
58
+ engineDestroy(engineId);
59
+ engineDestroy(engineId); // idempotent
60
+
61
+ worker.postMessage({ type: "afterDestroy" });
62
+
63
+ const destroyed = await new Promise((resolve, reject) => {
64
+ const onExit = (code) => reject(new Error(`worker exited with ${code}`));
65
+ const onError = (err) => reject(err);
66
+ const onMessage = (msg) => {
67
+ worker.off("exit", onExit);
68
+ worker.off("error", onError);
69
+ resolve(msg);
70
+ };
71
+ worker.once("exit", onExit);
72
+ worker.once("error", onError);
73
+ worker.once("message", onMessage);
74
+ });
75
+
76
+ assert(destroyed.phase === "destroyed", "worker must send destroyed phase");
77
+ assert(
78
+ destroyed.postUserEvent === ZR_ERR_INVALID_ARGUMENT,
79
+ `postUserEvent after destroy must return ZR_ERR_INVALID_ARGUMENT, got: ${destroyed.postUserEvent}`,
80
+ );
81
+
82
+ await worker.terminate();