singularity-forge/packages/native/src/native.ts
Tom Boucher 97d589c200 fix: graceful fallback when native addon is unavailable on unsupported platforms (#1225)
* fix: graceful fallback when native addon is unavailable on unsupported platforms

On platforms without a pre-built native binary (e.g., win32-arm64),
the native loader threw at import time, crashing the entire application.

Now returns a Proxy object that:
- Allows the import to succeed (no startup crash)
- Throws per-function when a native function is actually called
- Individual consumers (GSD parser bridge, fuzzy find, autocomplete)
  already wrap native calls in try/catch and fall back to JS
  implementations

Also exports nativeAvailable boolean for consumers that want to check
upfront. Prints a one-line warning to stderr on startup.

GSD is now fully functional on unsupported platforms — just slower for
file parsing, grep, and fuzzy search.

Fixes #1223

* fix: remove __nativeUnavailable from exported type to fix TS2352 cast errors

The __nativeUnavailable boolean property on the native type caused
TS2352 errors in consumers that cast native as Record<string, Function>
(ast/index.ts, diff/index.ts, gsd-parser/index.ts).

Replaced with a module-level _loadedSuccessfully flag and exported
nativeAvailable boolean. The proxy no longer needs a sentinel property.
2026-03-18 13:28:01 -06:00

156 lines
6.1 KiB
TypeScript

/**
* Native addon loader.
*
* Locates and loads the compiled Rust N-API addon (`.node` file).
* Resolution order:
* 1. @gsd-build/engine-{platform} npm optional dependency (production install)
* 2. native/addon/gsd_engine.{platform}.node (local release build)
* 3. native/addon/gsd_engine.dev.node (local debug build)
*/
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`;
/** Map Node.js platform/arch to the npm package suffix */
const platformPackageMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64-gnu",
"linux-arm64": "linux-arm64-gnu",
"win32-x64": "win32-x64-msvc",
};
let _loadedSuccessfully = false;
function loadNative(): Record<string, unknown> {
const errors: string[] = [];
// 1. Try the platform-specific npm optional dependency
const packageSuffix = platformPackageMap[platformTag];
if (packageSuffix) {
try {
_loadedSuccessfully = true; return require(`@gsd-build/engine-${packageSuffix}`) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`@gsd-build/engine-${packageSuffix}: ${message}`);
}
}
// 2. Try local release build (native/addon/gsd_engine.{platform}.node)
const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`);
try {
_loadedSuccessfully = true; return require(releasePath) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${releasePath}: ${message}`);
}
// 3. Try local dev build (native/addon/gsd_engine.dev.node)
const devPath = path.join(addonDir, "gsd_engine.dev.node");
try {
_loadedSuccessfully = true; return require(devPath) as Record<string, unknown>;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`${devPath}: ${message}`);
}
const details = errors.map((e) => ` - ${e}`).join("\n");
const supportedPlatforms = Object.keys(platformPackageMap);
// Graceful fallback: on unsupported platforms (e.g., win32-arm64), return a
// proxy that throws on individual function calls rather than crashing the
// entire import chain at startup (#1223). Consumers with JS fallbacks
// (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully.
process.stderr.write(
`[gsd] Native addon not available for ${platformTag}. Falling back to JS implementations (slower).\n` +
` Supported native platforms: ${supportedPlatforms.join(", ")}\n`,
);
return new Proxy({} as Record<string, unknown>, {
get(_target, prop) {
return (..._args: unknown[]) => {
throw new Error(`Native function '${String(prop)}' is not available on ${platformTag}`);
};
},
});
}
export const native = loadNative() as {
search: (content: Buffer | Uint8Array, options: unknown) => unknown;
grep: (options: unknown) => unknown;
killTree: (pid: number, signal: number) => number;
listDescendants: (pid: number) => number[];
processGroupId: (pid: number) => number | null;
killProcessGroup: (pgid: number, signal: number) => boolean;
glob: (
options: unknown,
onMatch?: ((match: unknown) => void) | undefined | null,
) => Promise<unknown>;
invalidateFsScanCache: (path?: string) => void;
highlightCode: (code: string, lang: string | null, colors: unknown) => unknown;
supportsLanguage: (lang: string) => unknown;
getSupportedLanguages: () => unknown;
copyToClipboard: (text: string) => void;
readTextFromClipboard: () => string | null;
readImageFromClipboard: () => Promise<unknown>;
astGrep: (options: unknown) => unknown;
astEdit: (options: unknown) => unknown;
htmlToMarkdown: (html: string, options: unknown) => unknown;
wrapTextWithAnsi: (text: string, width: number, tabWidth?: number) => string[];
truncateToWidth: (
text: string,
maxWidth: number,
ellipsisKind: number,
pad: boolean,
tabWidth?: number,
) => string;
sliceWithWidth: (
line: string,
startCol: number,
length: number,
strict: boolean,
tabWidth?: number,
) => unknown;
extractSegments: (
line: string,
beforeEnd: number,
afterStart: number,
afterLen: number,
strictAfter: boolean,
tabWidth?: number,
) => unknown;
sanitizeText: (text: string) => string;
visibleWidth: (text: string, tabWidth?: number) => number;
fuzzyFind: (options: unknown) => unknown;
normalizeForFuzzyMatch: (text: string) => string;
fuzzyFindText: (content: string, oldText: string) => unknown;
generateDiff: (oldContent: string, newContent: string, contextLines?: number) => unknown;
NativeImage: unknown;
ttsrCompileRules: (rules: unknown[]) => number;
ttsrCheckBuffer: (handle: number, buffer: string) => string[];
ttsrFreeRules: (handle: number) => void;
processStreamChunk: (chunk: Buffer, state?: unknown) => unknown;
stripAnsiNative: (text: string) => string;
sanitizeBinaryOutputNative: (text: string) => string;
parseFrontmatter: (content: string) => unknown;
extractSection: (content: string, heading: string, level?: number) => unknown;
extractAllSections: (content: string, level?: number) => string;
batchParseGsdFiles: (directory: string) => unknown;
parseRoadmapFile: (content: string) => unknown;
truncateTail: (text: string, maxBytes: number) => unknown;
truncateHead: (text: string, maxBytes: number) => unknown;
truncateOutput: (text: string, maxBytes: number, mode?: string) => unknown;
parseJson: (text: string) => unknown;
parsePartialJson: (text: string) => unknown;
parseStreamingJson: (text: string) => unknown;
xxHash32: (input: string, seed: number) => number;
};
/** True when the native addon loaded successfully. False on unsupported platforms. */
export const nativeAvailable = _loadedSuccessfully;