From 0462da9c696b37b958426f5df54a5032aa33e43f Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 14 May 2026 23:39:06 +0200 Subject: [PATCH] refactor(natives): reorganized native loading with loadNative in index - Extracted and centralized addon loading in `native/index.js`, replacing inline helper logic with `loadNative()`. - Added `loader-state` context initialization, cache-path resolution, and variant detection for native candidate loading. - Added `loadNative()` signature to `loader-state.d.ts` returning `Record`. - Reworked enum generation to replace JS between markers in `native/index.js` and removed `native-index.template.js` usage. --- packages/natives/native/index.js | 297 +--------------- packages/natives/native/loader-state.d.ts | 2 + packages/natives/native/loader-state.js | 332 +++++++++++++++++- packages/natives/scripts/gen-enums.ts | 18 +- .../natives/scripts/native-index.template.js | 289 --------------- 5 files changed, 352 insertions(+), 586 deletions(-) delete mode 100644 packages/natives/scripts/native-index.template.js diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 36e4f66ee..381206cda 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -1,293 +1,18 @@ -import * as childProcess from "node:child_process"; -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as os from "node:os"; -import * as path from "node:path"; -import packageJson from "../package.json" with { type: "json" }; -import { embeddedAddon } from "./embedded-addon.js"; -import { - detectCompiledBinary, - getAddonFilenames, - resolveLoaderCandidates, - shouldStageNodeModulesAddon, -} from "./loader-state.js"; +import { loadNative } from "./loader-state.js"; /** - * Native addon loader and bindings. + * Native addon entrypoint. * - * Types are auto-generated by napi-rs in `native/index.d.ts`. + * Hand-written surface area: one `loadNative()` call. Everything else in + * this file (the surface-area `export const` block between MARKER_START + * and MARKER_END) is rewritten by `packages/natives/scripts/gen-enums.ts` + * after `napi build` regenerates `index.d.ts`. + * + * Loader logic lives in `./loader-state.js` so the pure helpers stay + * unit-testable without triggering AVX2 detection or filesystem probes on + * import. Types are auto-generated by napi-rs in `./index.d.ts`. */ -function getNativesDir() { - const xdgDataHome = process.env.XDG_DATA_HOME; - if (xdgDataHome && fs.existsSync(path.join(xdgDataHome, "omp"))) { - return path.join(xdgDataHome, "omp", "natives"); - } - return path.join(os.homedir(), ".omp", "natives"); -} - -const require_ = createRequire(import.meta.url); -const platformTag = `${process.platform}-${process.arch}`; -const packageVersion = packageJson.version; -const nativeDir = path.join(import.meta.dir, "..", "native"); -const execDir = path.dirname(process.execPath); -const versionedDir = path.join(getNativesDir(), packageVersion); -const userDataDir = - process.platform === "win32" - ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") - : path.join(os.homedir(), ".local", "bin"); - -const isCompiledBinary = detectCompiledBinary({ - embeddedAddon, - env: process.env, - importMetaUrl: import.meta.url, -}); -const stageFromNodeModules = shouldStageNodeModulesAddon({ - platform: process.platform, - isCompiledBinary, - nativeDir, -}); -const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; - -function getVariantOverride() { - const value = process.env.PI_NATIVE_VARIANT; - if (!value) return null; - if (value === "modern" || value === "baseline") return value; - return null; -} - -function detectAvx2Support() { - if (process.arch !== "x64") { - return false; - } - - if (process.platform === "linux") { - try { - const cpuInfo = fs.readFileSync("/proc/cpuinfo", "utf8"); - return /\bavx2\b/i.test(cpuInfo); - } catch { - return false; - } - } - - if (process.platform === "darwin") { - const leaf7 = runCommand("sysctl", ["-n", "machdep.cpu.leaf7_features"]); - if (leaf7 && /\bAVX2\b/i.test(leaf7)) { - return true; - } - const features = runCommand("sysctl", ["-n", "machdep.cpu.features"]); - return Boolean(features && /\bAVX2\b/i.test(features)); - } - - if (process.platform === "win32") { - const output = runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", - ]); - return output && output.toLowerCase() === "true"; - } - - return false; -} - -function resolveCpuVariant(override) { - if (process.arch !== "x64") return null; - if (override) return override; - return detectAvx2Support() ? "modern" : "baseline"; -} - -const variantOverride = getVariantOverride(); -const selectedVariant = resolveCpuVariant(variantOverride); -const addonFilenames = getAddonFilenames({ tag: platformTag, arch: process.arch, variant: selectedVariant }); -const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; - -const dedupedCandidates = resolveLoaderCandidates({ - addonFilenames, - isCompiledBinary, - stageFromNodeModules, - nativeDir, - execDir, - versionedDir, - userDataDir, -}); - -function runCommand(command, args) { - try { - const result = childProcess.spawnSync(command, args, { encoding: "utf-8" }); - if (result.error) return null; - if (result.status !== 0) return null; - return (result.stdout || "").trim(); - } catch { - return null; - } -} - -function selectEmbeddedAddonFile() { - if (!embeddedAddon) return null; - const defaultFile = embeddedAddon.files.find(file => file.variant === "default") || null; - if (process.arch !== "x64") return defaultFile || embeddedAddon.files[0] || null; - if (selectedVariant === "modern") { - return ( - embeddedAddon.files.find(file => file.variant === "modern") || - embeddedAddon.files.find(file => file.variant === "baseline") || - null - ); - } - return embeddedAddon.files.find(file => file.variant === "baseline") || null; -} - -function maybeExtractEmbeddedAddon(errors) { - if (!isCompiledBinary || !embeddedAddon) return null; - if (embeddedAddon.platformTag !== platformTag || embeddedAddon.version !== packageVersion) return null; - - const selectedEmbeddedFile = selectEmbeddedAddonFile(); - if (!selectedEmbeddedFile) return null; - const targetPath = path.join(versionedDir, selectedEmbeddedFile.filename); - - try { - fs.mkdirSync(versionedDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon dir: ${message}`); - return null; - } - - if (fs.existsSync(targetPath)) { - return targetPath; - } - - try { - const buffer = fs.readFileSync(selectedEmbeddedFile.filePath); - fs.writeFileSync(targetPath, buffer); - return targetPath; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon write (${selectedEmbeddedFile.filename}): ${message}`); - return null; - } -} - -/** - * Mirror `nativeDir/.node` to `versionedDir/.node` on Windows - * installs so the running process keeps its OS-level handle on a versioned - * cache path, never on the `node_modules` copy that bun must overwrite on - * update. No-op on non-Windows, in workspace dev, and for compiled binaries — - * see `shouldStageNodeModulesAddon` for the gating rules. - */ -function maybeStageNodeModulesAddon(errors) { - if (!stageFromNodeModules) return null; - - let stagedPath = null; - for (const filename of addonFilenames) { - const sourcePath = path.join(nativeDir, filename); - const targetPath = path.join(versionedDir, filename); - - if (fs.existsSync(targetPath)) { - stagedPath = stagedPath || targetPath; - continue; - } - if (!fs.existsSync(sourcePath)) continue; - - try { - fs.mkdirSync(versionedDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`staged addon dir: ${message}`); - continue; - } - - try { - // `copyFileSync` is atomic on Windows (CopyFileW) and avoids holding - // two large buffers in JS for the read/write dance. - fs.copyFileSync(sourcePath, targetPath); - stagedPath = stagedPath || targetPath; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`staged addon copy (${filename}): ${message}`); - } - } - return stagedPath; -} - -function loadNative() { - const errors = []; - const embeddedCandidate = maybeExtractEmbeddedAddon(errors); - const stagedCandidate = embeddedCandidate ? null : maybeStageNodeModulesAddon(errors); - const prepended = [embeddedCandidate, stagedCandidate].filter(c => typeof c === "string"); - const runtimeCandidates = prepended.length > 0 ? [...prepended, ...dedupedCandidates] : dedupedCandidates; - for (const candidate of runtimeCandidates) { - try { - const bindings = require_(candidate); - validateLoadedBindings(bindings, candidate); - return bindings; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`${candidate}: ${message}`); - } - } - // Check if this is an unsupported platform - if (!SUPPORTED_PLATFORMS.includes(platformTag)) { - throw new Error( - `Unsupported platform: ${platformTag}\n` + - `Supported platforms: ${SUPPORTED_PLATFORMS.join(", ")}\n` + - "If you need support for this platform, please open an issue.", - ); - } - const details = errors.map(error => `- ${error}`).join("\n"); - let helpMessage; - if (isCompiledBinary) { - const expectedPaths = addonFilenames.map(filename => ` ${path.join(versionedDir, filename)}`).join("\n"); - const downloadHints = addonFilenames - .map(filename => { - const downloadUrl = `https://github.com/can1357/oh-my-pi/releases/latest/download/${filename}`; - const targetPath = path.join(versionedDir, filename); - return ` curl -fsSL "${downloadUrl}" -o "${targetPath}"`; - }) - .join("\n"); - helpMessage = - `The compiled binary should extract one of:\n${expectedPaths}\n\n` + - `If missing, delete ${versionedDir} and re-run, or download manually:\n${downloadHints}`; - } else { - helpMessage = - "If installed via npm/bun, try reinstalling: bun install @oh-my-pi/pi-natives\n" + - "If developing locally, build with: bun --cwd=packages/natives run build\n" + - "Optional x64 variants: TARGET_VARIANT=baseline|modern bun --cwd=packages/natives run build"; - } - - throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); -} - -// Version sentinel emitted by the Rust addon under a `js_name` that encodes -// the package version (`__piNativesV{major}_{minor}_{patch}`). -// `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in -// lock-step with the version, so a `.node` from a different release physically -// cannot expose the symbol this loader is looking for. That turns the silent -// ` is not a function` crash from a Windows locked-file update into an -// actionable load-time error. -const VERSION_SENTINEL_EXPORT = `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`; - -// In workspace dev (running out of `packages/natives/native/` rather than a -// `node_modules` install or a compiled bundle) the local `.node` only gains -// the renamed sentinel after `bun --cwd=packages/natives run build`. Skip -// validation there so a stale post-pull dev tree boots while the rebuild -// completes; install and compiled-binary paths still validate. -const isWorkspaceLoad = - !isCompiledBinary && - !nativeDir.includes("\\node_modules\\") && - !nativeDir.includes("/node_modules/"); - -function validateLoadedBindings(bindings, candidate) { - if (isWorkspaceLoad) return; - if (typeof bindings[VERSION_SENTINEL_EXPORT] === "function") return; - throw new Error( - `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${packageVersion} ` + - `version sentinel \`${VERSION_SENTINEL_EXPORT}\`. The .node file on disk is from a different ` + - "release than this loader — reinstall to re-sync.", - ); -} - const nativeBindings = loadNative(); // --- generated native exports (do not edit) --- // classes @@ -395,4 +120,4 @@ export const ProcessStatus = { Running: "running", Exited: "exited", }; -// --- end generated native exports --- \ No newline at end of file +// --- end generated native exports --- diff --git a/packages/natives/native/loader-state.d.ts b/packages/natives/native/loader-state.d.ts index 433fcd8b9..819bcc6b2 100644 --- a/packages/natives/native/loader-state.d.ts +++ b/packages/natives/native/loader-state.d.ts @@ -45,3 +45,5 @@ export interface ResolveLoaderCandidatesInput { } export function resolveLoaderCandidates(input: ResolveLoaderCandidatesInput): string[]; + +export function loadNative(): Record; diff --git a/packages/natives/native/loader-state.js b/packages/natives/native/loader-state.js index f188b9a5e..0fb3a07dd 100644 --- a/packages/natives/native/loader-state.js +++ b/packages/natives/native/loader-state.js @@ -1,12 +1,25 @@ +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; import * as path from "node:path"; +import packageJson from "../package.json" with { type: "json" }; +import { embeddedAddon } from "./embedded-addon.js"; /** - * Pure helpers used by `./index.js` to decide whether the loader is running - * inside a Bun-compiled standalone binary, and to compute the ordered list of - * candidate paths the loader probes for `pi_natives.-*.node`. + * Native addon loader for `@oh-my-pi/pi-natives`. * - * Kept as a separate ESM module so the logic can be unit-tested without - * triggering the side-effectful `loadNative()` call in `index.js`. + * Owns every step between "Node imports `native/index.js`" and "the right + * `pi_natives.-*.node` is required, validated, and returned": + * platform/variant detection, candidate-path resolution, on-disk staging from + * `node_modules` (Windows update safety), embedded-addon extraction (Bun + * standalone binaries), version-sentinel validation, and the aggregated error + * surface for diagnostic-friendly failures. + * + * `native/index.js` is reduced to one `loadNative()` call plus the generated + * surface-area exports between `MARKER_START`/`MARKER_END` (rewritten by + * `scripts/gen-enums.ts`); everything else lives here so the pure helpers stay + * unit-testable without triggering the side-effectful module-load path. * * Background (issue #823): `bun build --compile --define PI_COMPILED=true` * substitutes the bare identifier `PI_COMPILED`, NOT `process.env.PI_COMPILED`, @@ -17,6 +30,19 @@ import * as path from "node:path"; * post-build `--reset` stub) is the authoritative compiled-mode signal. */ +const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; + +function getNativesDir() { + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome && fs.existsSync(path.join(xdgDataHome, "omp"))) { + return path.join(xdgDataHome, "omp", "natives"); + } + return path.join(os.homedir(), ".omp", "natives"); +} + +// ========================================================================= +// Pure helpers — re-exported for unit tests in `packages/natives/test/`. +// ========================================================================= /** * @param {{ @@ -110,9 +136,7 @@ export function resolveLoaderCandidates({ path.join(versionedDir, filename), path.join(userDataDir, filename), ]); - const stagedCandidates = stageFromNodeModules - ? addonFilenames.map(filename => path.join(versionedDir, filename)) - : []; + const stagedCandidates = stageFromNodeModules ? addonFilenames.map(filename => path.join(versionedDir, filename)) : []; let releaseCandidates; if (isCompiledBinary) { releaseCandidates = [...compiledCandidates, ...baseReleaseCandidates]; @@ -124,3 +148,295 @@ export function resolveLoaderCandidates({ return [...new Set(releaseCandidates)]; } +// ========================================================================= +// Side-effectful loader. Everything below runs only when `loadNative()` is +// called from `native/index.js` — tests that only import the pure helpers +// above pay nothing for variant detection, subprocess spawns, or fs probes. +// ========================================================================= + +function runCommand(command, args) { + try { + const result = childProcess.spawnSync(command, args, { encoding: "utf-8" }); + if (result.error) return null; + if (result.status !== 0) return null; + return (result.stdout || "").trim(); + } catch { + return null; + } +} + +function getVariantOverride() { + const value = process.env.PI_NATIVE_VARIANT; + if (!value) return null; + if (value === "modern" || value === "baseline") return value; + return null; +} + +function detectAvx2Support() { + if (process.arch !== "x64") { + return false; + } + + if (process.platform === "linux") { + try { + const cpuInfo = fs.readFileSync("/proc/cpuinfo", "utf8"); + return /\bavx2\b/i.test(cpuInfo); + } catch { + return false; + } + } + + if (process.platform === "darwin") { + const leaf7 = runCommand("sysctl", ["-n", "machdep.cpu.leaf7_features"]); + if (leaf7 && /\bAVX2\b/i.test(leaf7)) { + return true; + } + const features = runCommand("sysctl", ["-n", "machdep.cpu.features"]); + return Boolean(features && /\bAVX2\b/i.test(features)); + } + + if (process.platform === "win32") { + const output = runCommand("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", + ]); + return output && output.toLowerCase() === "true"; + } + + return false; +} + +function resolveCpuVariant(override) { + if (process.arch !== "x64") return null; + if (override) return override; + return detectAvx2Support() ? "modern" : "baseline"; +} + +function selectEmbeddedAddonFile(selectedVariant) { + if (!embeddedAddon) return null; + const defaultFile = embeddedAddon.files.find(file => file.variant === "default") || null; + if (process.arch !== "x64") return defaultFile || embeddedAddon.files[0] || null; + if (selectedVariant === "modern") { + return ( + embeddedAddon.files.find(file => file.variant === "modern") || + embeddedAddon.files.find(file => file.variant === "baseline") || + null + ); + } + return embeddedAddon.files.find(file => file.variant === "baseline") || null; +} + +function maybeExtractEmbeddedAddon(ctx, errors) { + if (!ctx.isCompiledBinary || !embeddedAddon) return null; + if (embeddedAddon.platformTag !== ctx.platformTag || embeddedAddon.version !== ctx.packageVersion) return null; + + const selectedEmbeddedFile = selectEmbeddedAddonFile(ctx.selectedVariant); + if (!selectedEmbeddedFile) return null; + const targetPath = path.join(ctx.versionedDir, selectedEmbeddedFile.filename); + + try { + fs.mkdirSync(ctx.versionedDir, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`embedded addon dir: ${message}`); + return null; + } + + if (fs.existsSync(targetPath)) { + return targetPath; + } + + try { + const buffer = fs.readFileSync(selectedEmbeddedFile.filePath); + fs.writeFileSync(targetPath, buffer); + return targetPath; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`embedded addon write (${selectedEmbeddedFile.filename}): ${message}`); + return null; + } +} + +/** + * Mirror `nativeDir/.node` to `versionedDir/.node` on Windows + * installs so the running process keeps its OS-level handle on a versioned + * cache path, never on the `node_modules` copy that bun must overwrite on + * update. No-op on non-Windows, in workspace dev, and for compiled binaries — + * see `shouldStageNodeModulesAddon` for the gating rules. + */ +function maybeStageNodeModulesAddon(ctx, errors) { + if (!ctx.stageFromNodeModules) return null; + + let stagedPath = null; + for (const filename of ctx.addonFilenames) { + const sourcePath = path.join(ctx.nativeDir, filename); + const targetPath = path.join(ctx.versionedDir, filename); + + if (fs.existsSync(targetPath)) { + stagedPath = stagedPath || targetPath; + continue; + } + if (!fs.existsSync(sourcePath)) continue; + + try { + fs.mkdirSync(ctx.versionedDir, { recursive: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon dir: ${message}`); + continue; + } + + try { + // `copyFileSync` is atomic on Windows (CopyFileW) and avoids holding + // two large buffers in JS for the read/write dance. + fs.copyFileSync(sourcePath, targetPath); + stagedPath = stagedPath || targetPath; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`staged addon copy (${filename}): ${message}`); + } + } + return stagedPath; +} + +function validateLoadedBindings(ctx, bindings, candidate) { + // In workspace dev (running out of `packages/natives/native/` rather than a + // `node_modules` install or a compiled bundle) the local `.node` only gains + // the renamed sentinel after `bun --cwd=packages/natives run build`. Skip + // validation there so a stale post-pull dev tree boots while the rebuild + // completes; install and compiled-binary paths still validate. + if (ctx.isWorkspaceLoad) return; + if (typeof bindings[ctx.versionSentinelExport] === "function") return; + throw new Error( + `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${ctx.packageVersion} ` + + `version sentinel \`${ctx.versionSentinelExport}\`. The .node file on disk is from a different ` + + "release than this loader — reinstall to re-sync.", + ); +} + +function buildHelpMessage(ctx) { + if (ctx.isCompiledBinary) { + const expectedPaths = ctx.addonFilenames.map(filename => ` ${path.join(ctx.versionedDir, filename)}`).join("\n"); + const downloadHints = ctx.addonFilenames + .map(filename => { + const downloadUrl = `https://github.com/can1357/oh-my-pi/releases/latest/download/${filename}`; + const targetPath = path.join(ctx.versionedDir, filename); + return ` curl -fsSL "${downloadUrl}" -o "${targetPath}"`; + }) + .join("\n"); + return ( + `The compiled binary should extract one of:\n${expectedPaths}\n\n` + + `If missing, delete ${ctx.versionedDir} and re-run, or download manually:\n${downloadHints}` + ); + } + return ( + "If installed via npm/bun, try reinstalling: bun install @oh-my-pi/pi-natives\n" + + "If developing locally, build with: bun --cwd=packages/natives run build\n" + + "Optional x64 variants: TARGET_VARIANT=baseline|modern bun --cwd=packages/natives run build" + ); +} + +/** + * Initialize the loader context: resolves every path, variant, and policy + * decision once so the inner load loop stays a pure require/validate pipeline. + * Called from `loadNative()` rather than at module scope so importing pure + * helpers from this file doesn't trigger AVX2 detection or filesystem probes. + */ +function initLoaderContext() { + const platformTag = `${process.platform}-${process.arch}`; + const packageVersion = packageJson.version; + const nativeDir = path.join(import.meta.dir, "..", "native"); + const execDir = path.dirname(process.execPath); + const versionedDir = path.join(getNativesDir(), packageVersion); + const userDataDir = + process.platform === "win32" + ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") + : path.join(os.homedir(), ".local", "bin"); + + const isCompiledBinary = detectCompiledBinary({ + embeddedAddon, + env: process.env, + importMetaUrl: import.meta.url, + }); + const stageFromNodeModules = shouldStageNodeModulesAddon({ + platform: process.platform, + isCompiledBinary, + nativeDir, + }); + + const selectedVariant = resolveCpuVariant(getVariantOverride()); + const addonFilenames = getAddonFilenames({ tag: platformTag, arch: process.arch, variant: selectedVariant }); + const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; + + const candidates = resolveLoaderCandidates({ + addonFilenames, + isCompiledBinary, + stageFromNodeModules, + nativeDir, + execDir, + versionedDir, + userDataDir, + }); + + // Version sentinel emitted by the Rust addon under a `js_name` that encodes + // the package version (`__piNativesV{major}_{minor}_{patch}`). + // `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in + // lock-step with the version, so a `.node` from a different release + // physically cannot expose the symbol this loader is looking for. That + // turns the silent ` is not a function` crash from a Windows + // locked-file update into an actionable load-time error. + const versionSentinelExport = `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`; + const isWorkspaceLoad = + !isCompiledBinary && !nativeDir.includes("\\node_modules\\") && !nativeDir.includes("/node_modules/"); + + return { + platformTag, + packageVersion, + nativeDir, + versionedDir, + isCompiledBinary, + stageFromNodeModules, + selectedVariant, + addonFilenames, + addonLabel, + candidates, + versionSentinelExport, + isWorkspaceLoad, + }; +} + +export function loadNative() { + const ctx = initLoaderContext(); + const require_ = createRequire(import.meta.url); + + const errors = []; + const embeddedCandidate = maybeExtractEmbeddedAddon(ctx, errors); + const stagedCandidate = embeddedCandidate ? null : maybeStageNodeModulesAddon(ctx, errors); + const prepended = [embeddedCandidate, stagedCandidate].filter(c => typeof c === "string"); + const runtimeCandidates = prepended.length > 0 ? [...prepended, ...ctx.candidates] : ctx.candidates; + + for (const candidate of runtimeCandidates) { + try { + const bindings = require_(candidate); + validateLoadedBindings(ctx, bindings, candidate); + return bindings; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`${candidate}: ${message}`); + } + } + + if (!SUPPORTED_PLATFORMS.includes(ctx.platformTag)) { + throw new Error( + `Unsupported platform: ${ctx.platformTag}\n` + + `Supported platforms: ${SUPPORTED_PLATFORMS.join(", ")}\n` + + "If you need support for this platform, please open an issue.", + ); + } + const details = errors.map(error => `- ${error}`).join("\n"); + throw new Error( + `Failed to load pi_natives native addon for ${ctx.addonLabel}.\n\nTried:\n${details}\n\n${buildHelpMessage(ctx)}`, + ); +} diff --git a/packages/natives/scripts/gen-enums.ts b/packages/natives/scripts/gen-enums.ts index 068dff828..98ab521d2 100644 --- a/packages/natives/scripts/gen-enums.ts +++ b/packages/natives/scripts/gen-enums.ts @@ -19,7 +19,6 @@ import * as path from "node:path"; const nativeDir = path.resolve(import.meta.dir, "../native"); const dtsPath = path.join(nativeDir, "index.d.ts"); const jsPath = path.join(nativeDir, "index.js"); -const templatePath = path.join(import.meta.dir, "native-index.template.js"); const MARKER_START = "// --- generated native exports (do not edit) ---"; const MARKER_END = "// --- end generated native exports ---"; @@ -111,9 +110,22 @@ function buildGeneratedBlock(dts: string): string { export async function generateEnumExports(): Promise { const dts = await Bun.file(dtsPath).text(); - const template = await Bun.file(templatePath).text(); + const existing = await Bun.file(jsPath).text(); const generatedBlock = buildGeneratedBlock(dts); - const js = [template, `const nativeBindings = loadNative();`, generatedBlock].join("\n"); + + // Patch the generated block in place. `native/index.js` is the hand-edited + // loader; only the block between MARKER_START and MARKER_END is owned by + // this script. The markers are committed to disk so the patch is purely + // content replacement — no scaffold, no template file. + const blockStart = existing.indexOf(MARKER_START); + const blockEnd = existing.indexOf(MARKER_END); + if (blockStart === -1 || blockEnd === -1 || blockEnd < blockStart) { + throw new Error( + `gen-enums: ${jsPath} is missing the generated marker block. ` + + `Add\n\n${MARKER_START}\n${MARKER_END}\n\nplaceholders before running.`, + ); + } + const js = existing.slice(0, blockStart) + generatedBlock + existing.slice(blockEnd + MARKER_END.length); await Bun.write(jsPath, js); diff --git a/packages/natives/scripts/native-index.template.js b/packages/natives/scripts/native-index.template.js deleted file mode 100644 index 1d16153e1..000000000 --- a/packages/natives/scripts/native-index.template.js +++ /dev/null @@ -1,289 +0,0 @@ -import * as childProcess from "node:child_process"; -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as os from "node:os"; -import * as path from "node:path"; -import packageJson from "../package.json" with { type: "json" }; -import { embeddedAddon } from "./embedded-addon.js"; -import { - detectCompiledBinary, - getAddonFilenames, - resolveLoaderCandidates, - shouldStageNodeModulesAddon, -} from "./loader-state.js"; - -/** - * Native addon loader and bindings. - * - * Types are auto-generated by napi-rs in `native/index.d.ts`. - */ - -function getNativesDir() { - const xdgDataHome = process.env.XDG_DATA_HOME; - if (xdgDataHome && fs.existsSync(path.join(xdgDataHome, "omp"))) { - return path.join(xdgDataHome, "omp", "natives"); - } - return path.join(os.homedir(), ".omp", "natives"); -} - -const require_ = createRequire(import.meta.url); -const platformTag = `${process.platform}-${process.arch}`; -const packageVersion = packageJson.version; -const nativeDir = path.join(import.meta.dir, "..", "native"); -const execDir = path.dirname(process.execPath); -const versionedDir = path.join(getNativesDir(), packageVersion); -const userDataDir = - process.platform === "win32" - ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") - : path.join(os.homedir(), ".local", "bin"); - -const isCompiledBinary = detectCompiledBinary({ - embeddedAddon, - env: process.env, - importMetaUrl: import.meta.url, -}); -const stageFromNodeModules = shouldStageNodeModulesAddon({ - platform: process.platform, - isCompiledBinary, - nativeDir, -}); -const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; - -function getVariantOverride() { - const value = process.env.PI_NATIVE_VARIANT; - if (!value) return null; - if (value === "modern" || value === "baseline") return value; - return null; -} - -function detectAvx2Support() { - if (process.arch !== "x64") { - return false; - } - - if (process.platform === "linux") { - try { - const cpuInfo = fs.readFileSync("/proc/cpuinfo", "utf8"); - return /\bavx2\b/i.test(cpuInfo); - } catch { - return false; - } - } - - if (process.platform === "darwin") { - const leaf7 = runCommand("sysctl", ["-n", "machdep.cpu.leaf7_features"]); - if (leaf7 && /\bAVX2\b/i.test(leaf7)) { - return true; - } - const features = runCommand("sysctl", ["-n", "machdep.cpu.features"]); - return Boolean(features && /\bAVX2\b/i.test(features)); - } - - if (process.platform === "win32") { - const output = runCommand("powershell.exe", [ - "-NoProfile", - "-NonInteractive", - "-Command", - "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", - ]); - return output && output.toLowerCase() === "true"; - } - - return false; -} - -function resolveCpuVariant(override) { - if (process.arch !== "x64") return null; - if (override) return override; - return detectAvx2Support() ? "modern" : "baseline"; -} - -const variantOverride = getVariantOverride(); -const selectedVariant = resolveCpuVariant(variantOverride); -const addonFilenames = getAddonFilenames({ tag: platformTag, arch: process.arch, variant: selectedVariant }); -const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; - -const dedupedCandidates = resolveLoaderCandidates({ - addonFilenames, - isCompiledBinary, - stageFromNodeModules, - nativeDir, - execDir, - versionedDir, - userDataDir, -}); - -function runCommand(command, args) { - try { - const result = childProcess.spawnSync(command, args, { encoding: "utf-8" }); - if (result.error) return null; - if (result.status !== 0) return null; - return (result.stdout || "").trim(); - } catch { - return null; - } -} - -function selectEmbeddedAddonFile() { - if (!embeddedAddon) return null; - const defaultFile = embeddedAddon.files.find(file => file.variant === "default") || null; - if (process.arch !== "x64") return defaultFile || embeddedAddon.files[0] || null; - if (selectedVariant === "modern") { - return ( - embeddedAddon.files.find(file => file.variant === "modern") || - embeddedAddon.files.find(file => file.variant === "baseline") || - null - ); - } - return embeddedAddon.files.find(file => file.variant === "baseline") || null; -} - -function maybeExtractEmbeddedAddon(errors) { - if (!isCompiledBinary || !embeddedAddon) return null; - if (embeddedAddon.platformTag !== platformTag || embeddedAddon.version !== packageVersion) return null; - - const selectedEmbeddedFile = selectEmbeddedAddonFile(); - if (!selectedEmbeddedFile) return null; - const targetPath = path.join(versionedDir, selectedEmbeddedFile.filename); - - try { - fs.mkdirSync(versionedDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon dir: ${message}`); - return null; - } - - if (fs.existsSync(targetPath)) { - return targetPath; - } - - try { - const buffer = fs.readFileSync(selectedEmbeddedFile.filePath); - fs.writeFileSync(targetPath, buffer); - return targetPath; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon write (${selectedEmbeddedFile.filename}): ${message}`); - return null; - } -} - -/** - * Mirror `nativeDir/.node` to `versionedDir/.node` on Windows - * installs so the running process keeps its OS-level handle on a versioned - * cache path, never on the `node_modules` copy that bun must overwrite on - * update. No-op on non-Windows, in workspace dev, and for compiled binaries — - * see `shouldStageNodeModulesAddon` for the gating rules. - */ -function maybeStageNodeModulesAddon(errors) { - if (!stageFromNodeModules) return null; - - let stagedPath = null; - for (const filename of addonFilenames) { - const sourcePath = path.join(nativeDir, filename); - const targetPath = path.join(versionedDir, filename); - - if (fs.existsSync(targetPath)) { - stagedPath = stagedPath || targetPath; - continue; - } - if (!fs.existsSync(sourcePath)) continue; - - try { - fs.mkdirSync(versionedDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`staged addon dir: ${message}`); - continue; - } - - try { - // `copyFileSync` is atomic on Windows (CopyFileW) and avoids holding - // two large buffers in JS for the read/write dance. - fs.copyFileSync(sourcePath, targetPath); - stagedPath = stagedPath || targetPath; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`staged addon copy (${filename}): ${message}`); - } - } - return stagedPath; -} - -function loadNative() { - const errors = []; - const embeddedCandidate = maybeExtractEmbeddedAddon(errors); - const stagedCandidate = embeddedCandidate ? null : maybeStageNodeModulesAddon(errors); - const prepended = [embeddedCandidate, stagedCandidate].filter(c => typeof c === "string"); - const runtimeCandidates = prepended.length > 0 ? [...prepended, ...dedupedCandidates] : dedupedCandidates; - for (const candidate of runtimeCandidates) { - try { - const bindings = require_(candidate); - validateLoadedBindings(bindings, candidate); - return bindings; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - errors.push(`${candidate}: ${message}`); - } - } - // Check if this is an unsupported platform - if (!SUPPORTED_PLATFORMS.includes(platformTag)) { - throw new Error( - `Unsupported platform: ${platformTag}\n` + - `Supported platforms: ${SUPPORTED_PLATFORMS.join(", ")}\n` + - "If you need support for this platform, please open an issue.", - ); - } - const details = errors.map(error => `- ${error}`).join("\n"); - let helpMessage; - if (isCompiledBinary) { - const expectedPaths = addonFilenames.map(filename => ` ${path.join(versionedDir, filename)}`).join("\n"); - const downloadHints = addonFilenames - .map(filename => { - const downloadUrl = `https://github.com/can1357/oh-my-pi/releases/latest/download/${filename}`; - const targetPath = path.join(versionedDir, filename); - return ` curl -fsSL "${downloadUrl}" -o "${targetPath}"`; - }) - .join("\n"); - helpMessage = - `The compiled binary should extract one of:\n${expectedPaths}\n\n` + - `If missing, delete ${versionedDir} and re-run, or download manually:\n${downloadHints}`; - } else { - helpMessage = - "If installed via npm/bun, try reinstalling: bun install @oh-my-pi/pi-natives\n" + - "If developing locally, build with: bun --cwd=packages/natives run build\n" + - "Optional x64 variants: TARGET_VARIANT=baseline|modern bun --cwd=packages/natives run build"; - } - - throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); -} - -// Version sentinel emitted by the Rust addon under a `js_name` that encodes -// the package version (`__piNativesV{major}_{minor}_{patch}`). -// `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in -// lock-step with the version, so a `.node` from a different release physically -// cannot expose the symbol this loader is looking for. That turns the silent -// ` is not a function` crash from a Windows locked-file update into an -// actionable load-time error. -const VERSION_SENTINEL_EXPORT = `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`; - -// In workspace dev (running out of `packages/natives/native/` rather than a -// `node_modules` install or a compiled bundle) the local `.node` only gains -// the renamed sentinel after `bun --cwd=packages/natives run build`. Skip -// validation there so a stale post-pull dev tree boots while the rebuild -// completes; install and compiled-binary paths still validate. -const isWorkspaceLoad = - !isCompiledBinary && - !nativeDir.includes("\\node_modules\\") && - !nativeDir.includes("/node_modules/"); - -function validateLoadedBindings(bindings, candidate) { - if (isWorkspaceLoad) return; - if (typeof bindings[VERSION_SENTINEL_EXPORT] === "function") return; - throw new Error( - `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${packageVersion} ` + - `version sentinel \`${VERSION_SENTINEL_EXPORT}\`. The .node file on disk is from a different ` + - "release than this loader — reinstall to re-sync.", - ); -}