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 { loadNative } from "./loader-state.js";
|
||||||
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.
|
* 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();
|
const nativeBindings = loadNative();
|
||||||
// --- generated native exports (do not edit) ---
|
// --- generated native exports (do not edit) ---
|
||||||
// classes
|
// classes
|
||||||
@@ -395,4 +120,4 @@ export const ProcessStatus = {
|
|||||||
Running: "running",
|
Running: "running",
|
||||||
Exited: "exited",
|
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 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 * 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
|
* Native addon loader for `@oh-my-pi/pi-natives`.
|
||||||
* inside a Bun-compiled standalone binary, and to compute the ordered list of
|
|
||||||
* candidate paths the loader probes for `pi_natives.<platform>-<arch>*.node`.
|
|
||||||
*
|
*
|
||||||
* Kept as a separate ESM module so the logic can be unit-tested without
|
* Owns every step between "Node imports `native/index.js`" and "the right
|
||||||
* triggering the side-effectful `loadNative()` call in `index.js`.
|
* `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`
|
* Background (issue #823): `bun build --compile --define PI_COMPILED=true`
|
||||||
* substitutes the bare identifier `PI_COMPILED`, NOT `process.env.PI_COMPILED`,
|
* 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.
|
* 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 {{
|
* @param {{
|
||||||
@@ -110,9 +136,7 @@ export function resolveLoaderCandidates({
|
|||||||
path.join(versionedDir, filename),
|
path.join(versionedDir, filename),
|
||||||
path.join(userDataDir, filename),
|
path.join(userDataDir, filename),
|
||||||
]);
|
]);
|
||||||
const stagedCandidates = stageFromNodeModules
|
const stagedCandidates = stageFromNodeModules ? addonFilenames.map(filename => path.join(versionedDir, filename)) : [];
|
||||||
? addonFilenames.map(filename => path.join(versionedDir, filename))
|
|
||||||
: [];
|
|
||||||
let releaseCandidates;
|
let releaseCandidates;
|
||||||
if (isCompiledBinary) {
|
if (isCompiledBinary) {
|
||||||
releaseCandidates = [...compiledCandidates, ...baseReleaseCandidates];
|
releaseCandidates = [...compiledCandidates, ...baseReleaseCandidates];
|
||||||
@@ -124,3 +148,295 @@ export function resolveLoaderCandidates({
|
|||||||
return [...new Set(releaseCandidates)];
|
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 nativeDir = path.resolve(import.meta.dir, "../native");
|
||||||
const dtsPath = path.join(nativeDir, "index.d.ts");
|
const dtsPath = path.join(nativeDir, "index.d.ts");
|
||||||
const jsPath = path.join(nativeDir, "index.js");
|
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_START = "// --- generated native exports (do not edit) ---";
|
||||||
const MARKER_END = "// --- end generated native exports ---";
|
const MARKER_END = "// --- end generated native exports ---";
|
||||||
@@ -111,9 +110,22 @@ function buildGeneratedBlock(dts: string): string {
|
|||||||
|
|
||||||
export async function generateEnumExports(): Promise<void> {
|
export async function generateEnumExports(): Promise<void> {
|
||||||
const dts = await Bun.file(dtsPath).text();
|
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 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);
|
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