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<string, unknown>`. - Reworked enum generation to replace JS between markers in `native/index.js` and removed `native-index.template.js` usage.
This commit is contained in:
@@ -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/<filename>.node` to `versionedDir/<filename>.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
|
||||
// `<sym> 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 ---
|
||||
// --- end generated native exports ---
|
||||
|
||||
+2
@@ -45,3 +45,5 @@ export interface ResolveLoaderCandidatesInput {
|
||||
}
|
||||
|
||||
export function resolveLoaderCandidates(input: ResolveLoaderCandidatesInput): string[];
|
||||
|
||||
export function loadNative(): Record<string, unknown>;
|
||||
|
||||
@@ -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.<platform>-<arch>*.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.<platform>-<arch>*.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/<filename>.node` to `versionedDir/<filename>.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 `<sym> 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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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/<filename>.node` to `versionedDir/<filename>.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
|
||||
// `<sym> 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.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user