182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
/**
|
|
* loadNgSpiceForNode — runtime loader for the vendored ngspice
|
|
* emscripten module in a Node (Vitest) process.
|
|
*
|
|
* The vendored `ngspice-lib.js` is an emscripten singleton build
|
|
* (not MODULARIZE), so the obvious `require('./ngspice-lib.js')`
|
|
* pattern doesn't pass a config to the Module factory. The CJS
|
|
* module evaluates `var Module = typeof Module != 'undefined' ?
|
|
* Module : {}` in its own scope, which can't see
|
|
* `globalThis.Module`.
|
|
*
|
|
* Workaround: read the source as text, wrap with a hoisted
|
|
* `var Module = config;` so the emscripten guard finds Module
|
|
* already defined (var hoisting + non-redeclaration semantics make
|
|
* the two `var Module` lines refer to the same variable), and
|
|
* evaluate via `new Function`. Result: our `config` survives and
|
|
* `locateFile` + callbacks work as expected.
|
|
*
|
|
* Only used in Node test contexts. The browser path
|
|
* (`NgSpiceWorkerAdapter`) goes through the Web Worker which evaluates
|
|
* the lib in its own scope.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
/**
|
|
* Subset of the emscripten Module surface we use. The vendored
|
|
* build adds plenty more, but we only depend on these.
|
|
*/
|
|
export interface NgSpiceEmscriptenModule {
|
|
cwrap: (
|
|
name: string,
|
|
returnType: string | null,
|
|
argTypes: readonly string[],
|
|
) => (...args: unknown[]) => unknown;
|
|
addFunction: (fn: (...args: unknown[]) => unknown, signature: string) => number;
|
|
removeFunction: (ptr: number) => void;
|
|
UTF8ToString: (ptr: number) => string;
|
|
stringToUTF8: (str: string, ptr: number, max: number) => void;
|
|
lengthBytesUTF8: (str: string) => number;
|
|
_malloc: (bytes: number) => number;
|
|
_free: (ptr: number) => void;
|
|
_velxio_heap8?: Int8Array;
|
|
_velxio_heap32?: Int32Array;
|
|
_velxio_heapu32?: Uint32Array;
|
|
_velxio_heapf64?: Float64Array;
|
|
/**
|
|
* Note: the vendored build does NOT include `FS` in
|
|
* EXPORTED_RUNTIME_METHODS, so `Module.FS` triggers an abort
|
|
* accessor on this build. We expose the closure-captured locals
|
|
* via the `_velxio_*` namespace instead — see the wrapper in
|
|
* loadNgSpiceForNode.
|
|
*/
|
|
_velxio_fs?: {
|
|
writeFile: (path: string, data: Uint8Array | string) => void;
|
|
mkdir: (path: string) => void;
|
|
analyzePath: (path: string) => { exists: boolean };
|
|
};
|
|
_velxio_env?: Record<string, string>;
|
|
noInitialRun: boolean;
|
|
print?: (text: string) => void;
|
|
printErr?: (text: string) => void;
|
|
locateFile?: (path: string) => string;
|
|
onRuntimeInitialized?: () => void;
|
|
}
|
|
|
|
export interface LoadNgSpiceConfig {
|
|
/** Directory containing ngspice-lib.{js,wasm} + the .cm files. */
|
|
wasmDir: string;
|
|
/** Optional stdout sink (one line at a time). */
|
|
onStdout?: (text: string) => void;
|
|
/** Optional stderr sink. */
|
|
onStderr?: (text: string) => void;
|
|
}
|
|
|
|
function defaultWasmDir(): string {
|
|
// Resolve relative to this file's package location:
|
|
// frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts
|
|
// → frontend/public/wasm/ngspice-interactive
|
|
const here = fileURLToPath(import.meta.url);
|
|
return path.resolve(here, '../../../../../../public/wasm/ngspice-interactive');
|
|
}
|
|
|
|
let cachedModule: NgSpiceEmscriptenModule | null = null;
|
|
let cachedInit: Promise<NgSpiceEmscriptenModule> | null = null;
|
|
|
|
/**
|
|
* Load the ngspice WASM module in the current Node process. Returns
|
|
* the initialised Module on success.
|
|
*
|
|
* Caches the singleton — calling twice returns the same Module.
|
|
* emscripten singleton builds can't be re-initialised within a
|
|
* process, so dispose() is best-effort (resets ngspice state via
|
|
* `ngSpice_Reset`, doesn't free the WASM).
|
|
*/
|
|
export function loadNgSpiceForNode(
|
|
config: Partial<LoadNgSpiceConfig> = {},
|
|
): Promise<NgSpiceEmscriptenModule> {
|
|
if (cachedInit) return cachedInit;
|
|
|
|
const wasmDir = config.wasmDir ?? defaultWasmDir();
|
|
const libPath = path.join(wasmDir, 'ngspice-lib.js');
|
|
|
|
const moduleConfig: Partial<NgSpiceEmscriptenModule> = {
|
|
noInitialRun: true,
|
|
locateFile: (filename) => {
|
|
// Resolve all asset files (.wasm, .cm, etc.) relative to wasmDir.
|
|
return path.join(wasmDir, filename);
|
|
},
|
|
print: (text: string) => config.onStdout?.(text),
|
|
printErr: (text: string) => config.onStderr?.(text),
|
|
};
|
|
|
|
const src = readFileSync(libPath, 'utf8');
|
|
// Wrapping strategy:
|
|
// 1. Hoisted `var Module = config` so the emscripten body's
|
|
// `var Module = typeof Module != 'undefined' ? Module : {}`
|
|
// finds Module already defined and keeps our config.
|
|
// 2. After the emscripten body runs, FS / HEAP* are bound to
|
|
// `var` declarations inside the same closure. We re-wire
|
|
// `Module.onRuntimeInitialized` to ALSO copy those locals
|
|
// onto Module so external code can read them via
|
|
// `Module.FS`, `Module.HEAPU32`, etc. The build doesn't
|
|
// include FS in EXPORTED_RUNTIME_METHODS so this is the
|
|
// only path that doesn't require recompiling.
|
|
const wrapped = `
|
|
var Module = config;
|
|
${src}
|
|
var __velxioOriginalInit = Module.onRuntimeInitialized;
|
|
Module.onRuntimeInitialized = function () {
|
|
// Build does not export FS / HEAP* in EXPORTED_RUNTIME_METHODS;
|
|
// those keys on Module trigger abort accessors. Assign to the
|
|
// _velxio_ namespace instead and the NgSpiceNodeAdapter reads
|
|
// from there.
|
|
if (typeof FS !== 'undefined') Module._velxio_fs = FS;
|
|
if (typeof HEAP8 !== 'undefined') Module._velxio_heap8 = HEAP8;
|
|
if (typeof HEAP32 !== 'undefined') Module._velxio_heap32 = HEAP32;
|
|
if (typeof HEAPU32 !== 'undefined') Module._velxio_heapu32 = HEAPU32;
|
|
if (typeof HEAPF64 !== 'undefined') Module._velxio_heapf64 = HEAPF64;
|
|
if (typeof ENV !== 'undefined') Module._velxio_env = ENV;
|
|
if (__velxioOriginalInit) __velxioOriginalInit();
|
|
};
|
|
return Module;
|
|
`;
|
|
const factory = new Function('config', 'require', '__dirname', '__filename', wrapped) as (
|
|
config: unknown,
|
|
req: typeof require,
|
|
dirname: string,
|
|
filename: string,
|
|
) => NgSpiceEmscriptenModule;
|
|
|
|
const req = createRequire(import.meta.url);
|
|
|
|
cachedInit = new Promise<NgSpiceEmscriptenModule>((resolve, reject) => {
|
|
moduleConfig.onRuntimeInitialized = () => {
|
|
cachedModule = moduleConfig as NgSpiceEmscriptenModule;
|
|
resolve(cachedModule);
|
|
};
|
|
try {
|
|
factory(moduleConfig, req, wasmDir, libPath);
|
|
} catch (err) {
|
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
setTimeout(() => reject(new Error('ngspice WASM init timeout (30s)')), 30_000);
|
|
});
|
|
|
|
return cachedInit;
|
|
}
|
|
|
|
/**
|
|
* Reset the cached module reference (NOT free the WASM — that's not
|
|
* supported by emscripten singletons). Subsequent `loadNgSpiceForNode`
|
|
* calls will re-init — which only works if the previous Module was
|
|
* reset via `ngSpice_Reset`. Best-effort.
|
|
*/
|
|
export function __resetNgSpiceForTests(): void {
|
|
cachedModule = null;
|
|
cachedInit = null;
|
|
}
|