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:
can1357
2026-05-14 23:39:06 +02:00
parent dac28f66e4
commit 0462da9c69
5 changed files with 352 additions and 586 deletions
+11 -286
View File
@@ -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
View File
@@ -45,3 +45,5 @@ export interface ResolveLoaderCandidatesInput {
}
export function resolveLoaderCandidates(input: ResolveLoaderCandidatesInput): string[];
export function loadNative(): Record<string, unknown>;
+324 -8
View File
@@ -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)}`,
);
}
+15 -3
View File
@@ -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.",
);
}