1
0
Fork 0

Parse callbacks

This commit is contained in:
Pabloader 2026-05-21 16:04:25 +00:00
parent a2bec8a4cc
commit 09aa409389
5 changed files with 184 additions and 52 deletions

View File

@ -1,11 +1,67 @@
// Extracts JS_EXPORT-marked functions from a C/C++ source via regex + balanced // Extracts JS_EXPORT-marked functions from a C/C++ source via regex + balanced
// paren scanning. Handles function-pointer params, templates, and JS_EXPORT_AS // paren scanning. Handles function-pointer params (inline and typedef'd),
// renames. Limitations: assumes JS_EXPORT markers live in the file itself (not // templates, and JS_EXPORT_AS renames. Limitations: assumes JS_EXPORT markers
// in transitively-included headers); doesn't honor #if conditionals. // and any fn-ptr typedefs they use live in the source file itself, not in
// transitively-included headers; doesn't honor #if conditionals.
export type WasmType = 'i32' | 'i64' | 'f32' | 'f64';
export interface FnSig {
params: WasmType[];
results: WasmType[];
}
export interface ExportInfo { export interface ExportInfo {
name: string; name: string;
paramNames: string[]; paramNames: string[];
// Index → wasm signature, for params that are C function pointers.
fnPtrSigs: Map<number, FnSig>;
}
function cTypeToWasm(cType: string): WasmType {
const t = cType.trim();
if (/[*&]/.test(t) || /\[/.test(t)) return 'i32';
if (/\b(int64_t|uint64_t|long\s+long)\b/.test(t)) return 'i64';
if (/\bdouble\b/.test(t)) return 'f64';
if (/\bfloat\b/.test(t)) return 'f32';
return 'i32';
}
function buildFnSig(retType: string, argList: string): FnSig {
const ret = retType.trim() === 'void' ? null : cTypeToWasm(retType);
const results: WasmType[] = ret ? [ret] : [];
const params: WasmType[] = [];
const trimmed = argList.trim();
if (trimmed && trimmed !== 'void') {
for (const a of splitArgs(trimmed)) {
const typeOnly = a.replace(/\s+\w+\s*$/, '').replace(/\[[^\]]*\]\s*$/, '').trim();
params.push(cTypeToWasm(typeOnly));
}
}
return { params, results };
}
// Parse a single function-pointer-shaped argument like "void (*cb)(int, float)".
// Returns null if `arg` doesn't match the inline fn-ptr pattern.
function parseInlineFnPtrSig(arg: string): FnSig | null {
const m = arg.match(/^([\s\S]+?)\(\s*\*\s*\w*\s*\)\s*\(([\s\S]*)\)\s*$/);
if (!m) return null;
return buildFnSig(m[1], m[2]);
}
// Find all `typedef <ret> (*<name>)(<args>);` declarations and build a name → sig map.
// The retType char class excludes `;{}` so we don't span across nearby struct
// declarations or other statements.
function findFnPtrTypedefs(src: string): Map<string, FnSig> {
const out = new Map<string, FnSig>();
const re = /\btypedef\s+([^;{}]+?)\(\s*\*\s*(\w+)\s*\)\s*\(([^)]*)\)\s*;/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
out.set(m[2], buildFnSig(m[1], m[3]));
}
return out;
} }
function stripComments(src: string): string { function stripComments(src: string): string {
@ -14,23 +70,6 @@ function stripComments(src: string): string {
.replace(/\/\*[\s\S]*?\*\//g, ''); .replace(/\/\*[\s\S]*?\*\//g, '');
} }
// Pull the parameter identifier out of a single argument declaration.
// Recognizes:
// - function pointer: void (*cb)(int) → cb
// - array: float arr[4] → arr
// - default value: int x = 5 → x
// - plain: const char* name → name
function extractParamName(arg: string): string {
const noDefault = arg.replace(/\s*=\s*[\s\S]*$/, '').trim();
if (!noDefault) return '';
const fnPtr = noDefault.match(/\(\s*\*\s*(\w+)\s*\)/);
if (fnPtr) return fnPtr[1];
const m = noDefault.match(/(\w+)\s*(?:\[[^\]]*\])?\s*$/);
return m ? m[1] : '';
}
function splitArgs(argsStr: string): string[] { function splitArgs(argsStr: string): string[] {
const trimmed = argsStr.trim(); const trimmed = argsStr.trim();
if (!trimmed || trimmed === 'void') return []; if (!trimmed || trimmed === 'void') return [];
@ -51,8 +90,6 @@ function splitArgs(argsStr: string): string[] {
return parts; return parts;
} }
// Find the matching close `)` starting just past an opening `(` at `openIdx`.
// Returns the index of the close paren, or -1 if unmatched.
function matchParen(src: string, openIdx: number): number { function matchParen(src: string, openIdx: number): number {
let depth = 1; let depth = 1;
for (let i = openIdx + 1; i < src.length; i++) { for (let i = openIdx + 1; i < src.length; i++) {
@ -65,11 +102,37 @@ function matchParen(src: string, openIdx: number): number {
return -1; return -1;
} }
// Parse one C parameter declaration, recovering the identifier name and (if any)
// the function-pointer signature it carries — either via an inline `(*name)(...)`
// shape or via a typedef name we've previously collected.
function parseParam(
arg: string,
typedefs: Map<string, FnSig>,
): { name: string; fnSig: FnSig | null } {
const noDefault = arg.replace(/\s*=\s*[\s\S]*$/, '').trim();
if (!noDefault) return { name: '', fnSig: null };
const inlineSig = parseInlineFnPtrSig(noDefault);
if (inlineSig) {
const m = noDefault.match(/\(\s*\*\s*(\w+)\s*\)/);
return { name: m?.[1] ?? '', fnSig: inlineSig };
}
const nameMatch = noDefault.match(/(\w+)\s*(?:\[[^\]]*\])?\s*$/);
const name = nameMatch ? nameMatch[1] : '';
const typeOnly = noDefault.replace(/\s+\w+\s*$/, '').replace(/\[[^\]]*\]\s*$/, '').trim();
const typedefSig = typedefs.get(typeOnly);
if (typedefSig) return { name, fnSig: typedefSig };
return { name, fnSig: null };
}
export function extractExports(source: string): ExportInfo[] { export function extractExports(source: string): ExportInfo[] {
const src = stripComments(source); const src = stripComments(source);
const typedefs = findFnPtrTypedefs(src);
const out: ExportInfo[] = []; const out: ExportInfo[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
// Match either JS_EXPORT or JS_EXPORT_AS as the entry into a declaration.
const re = /\bJS_EXPORT(_AS)?\b/g; const re = /\bJS_EXPORT(_AS)?\b/g;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@ -77,7 +140,6 @@ export function extractExports(source: string): ExportInfo[] {
let pos = m.index + m[0].length; let pos = m.index + m[0].length;
let alias: string | undefined; let alias: string | undefined;
// If the entry marker is JS_EXPORT_AS, consume its (name) argument.
if (m[1]) { if (m[1]) {
const a = /^\s*\(\s*(\w+)\s*\)/.exec(src.slice(pos)); const a = /^\s*\(\s*(\w+)\s*\)/.exec(src.slice(pos));
if (!a) continue; if (!a) continue;
@ -85,8 +147,6 @@ export function extractExports(source: string): ExportInfo[] {
pos += a[0].length; pos += a[0].length;
} }
// Walk forward until the function args `(`. Pick up any additional
// JS_EXPORT / JS_EXPORT_AS attached to the same declaration.
let parenOpen = -1; let parenOpen = -1;
while (pos < src.length) { while (pos < src.length) {
const ch = src[pos]; const ch = src[pos];
@ -123,16 +183,21 @@ export function extractExports(source: string): ExportInfo[] {
const parenClose = matchParen(src, parenOpen); const parenClose = matchParen(src, parenOpen);
if (parenClose === -1) continue; if (parenClose === -1) continue;
const argsStr = src.slice(parenOpen + 1, parenClose); const argParts = splitArgs(src.slice(parenOpen + 1, parenClose));
const paramNames = splitArgs(argsStr).map(a => extractParamName(a)); const paramNames: string[] = [];
const fnPtrSigs = new Map<number, FnSig>();
argParts.forEach((a, i) => {
const { name, fnSig } = parseParam(a, typedefs);
paramNames.push(name);
if (fnSig) fnPtrSigs.set(i, fnSig);
});
const name = alias ?? funcName; const name = alias ?? funcName;
if (!seen.has(name)) { if (!seen.has(name)) {
seen.add(name); seen.add(name);
out.push({ name, paramNames }); out.push({ name, paramNames, fnPtrSigs });
} }
// Skip past the consumed declaration so we don't re-match its markers.
re.lastIndex = parenClose + 1; re.lastIndex = parenClose + 1;
} }

View File

@ -131,7 +131,10 @@ async function compileLibGroup(
return objPaths; return objPaths;
} }
async function instantiate(url: string) { type FnSig = { params: ('i32' | 'i64' | 'f32' | 'f64')[]; results: ('i32' | 'i64' | 'f32' | 'f64')[] };
type FnPtrMeta = Record<string, [number, FnSig][]>;
async function instantiate(url: string, fnPtrs: FnPtrMeta = {}) {
if (typeof WebAssembly.Function !== "function") { if (typeof WebAssembly.Function !== "function") {
const typeCodes = { const typeCodes = {
i32: 0x7f, i32: 0x7f,
@ -242,13 +245,37 @@ async function instantiate(url: string) {
(instance.exports._initialize as (() => void) | undefined)?.(); (instance.exports._initialize as (() => void) | undefined)?.();
const table = instance.exports.__indirect_function_table as WebAssembly.Table | undefined;
const exported: Record<string, unknown> = Object.fromEntries(
Object.entries(instance.exports).filter(([k]) => !k.startsWith('_'))
);
// Wrap exports that take function-pointer args so callers can pass JS functions
// directly. Same JS function reuses the same table slot across calls.
const fnPtrCache = new WeakMap<Function, number>();
for (const [name, ptrs] of Object.entries(fnPtrs)) {
const orig = exported[name];
if (typeof orig !== 'function' || !table) continue;
exported[name] = (...args: unknown[]) => {
for (const [i, sig] of ptrs) {
const v = args[i];
if (typeof v !== 'function') continue;
let idx = fnPtrCache.get(v);
if (idx === undefined) {
const wasmFn = new WebAssembly.Function({ parameters: sig.params, results: sig.results }, v as (...a: number[]) => void);
idx = table.grow(1, wasmFn);
fnPtrCache.set(v, idx);
}
args[i] = idx;
}
return (orig as (...a: unknown[]) => unknown)(...args);
};
}
return { return {
...Object.fromEntries( ...exported,
Object.entries(instance.exports)
.filter(([k]) => !k.startsWith('_'))
),
memory, memory,
table: instance.exports.__indirect_function_table, table,
get data() { return new DataView(memory.buffer); }, get data() { return new DataView(memory.buffer); },
}; };
} }
@ -261,12 +288,7 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
const distDir = path.resolve(import.meta.dir, '..', '..', 'dist'); const distDir = path.resolve(import.meta.dir, '..', '..', 'dist');
const inputBasename = path.basename(args.path); const inputBasename = path.basename(args.path);
let wasmPath = path.resolve(distDir, inputBasename.replace(/\.(c|cpp)$/, '.wasm')); let wasmPath = path.resolve(distDir, inputBasename.replace(/\.(c|cpp)$/, '.wasm'));
let jsContent: string = ` const fnPtrMeta: Record<string, [number, FnSig][]> = {};
${instantiate}
const module = await instantiate(new URL($WASM$));
export default module;
`;
if (args.path.endsWith('.wasm')) { if (args.path.endsWith('.wasm')) {
wasmPath = args.path; wasmPath = args.path;
} else { } else {
@ -333,9 +355,18 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
const allObjs = [inputObjPath, ...libCObjs, ...libCppObjs]; const allObjs = [inputObjPath, ...libCObjs, ...libCppObjs];
if (await needsRebuild(wasmPath, allObjs)) {
const exportInfos = extractExports(await Bun.file(args.path).text()); const exportInfos = extractExports(await Bun.file(args.path).text());
const paramNames = new Map(exportInfos.map(e => [e.name, e.paramNames])); for (const e of exportInfos) {
if (e.fnPtrSigs.size > 0) {
fnPtrMeta[e.name] = [...e.fnPtrSigs.entries()];
}
}
if (await needsRebuild(wasmPath, allObjs)) {
const meta = new Map(exportInfos.map(e => [e.name, {
paramNames: e.paramNames,
fnPtrSigs: e.fnPtrSigs,
}]));
const linkFlags = [ const linkFlags = [
'--strip-all', '--strip-all',
@ -361,7 +392,7 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
} }
const wasmBytes = new Uint8Array(await Bun.file(wasmPath).arrayBuffer()); const wasmBytes = new Uint8Array(await Bun.file(wasmPath).arrayBuffer());
await Bun.write(`${args.path}.d.ts`, renderDts(wasmBytes, paramNames)); await Bun.write(`${args.path}.d.ts`, renderDts(wasmBytes, meta));
} }
} }
@ -369,9 +400,16 @@ const wasmPlugin = ({ production }: WasmLoaderConfig = {}): BunPlugin => {
const wasmBuffer = Buffer.from(wasmContent).toString('base64'); const wasmBuffer = Buffer.from(wasmContent).toString('base64');
const wasmURL = `data:application/wasm;base64,${wasmBuffer}`; const wasmURL = `data:application/wasm;base64,${wasmBuffer}`;
const jsContent = `
${instantiate}
const module = await instantiate(new URL(${JSON.stringify(wasmURL)}), ${JSON.stringify(fnPtrMeta)});
export default module;
`;
return { return {
loader: 'js', loader: 'js',
contents: jsContent.replace(/new URL\([^)]*\)/, `new URL(${JSON.stringify(wasmURL)})`), contents: jsContent,
}; };
}); });
} }

View File

@ -1,8 +1,15 @@
// Parses a wasm binary's type / function / import / export sections to recover // Parses a wasm binary's type / function / import / export sections to recover
// each function export's signature, then renders TypeScript declarations. // each function export's signature, then renders TypeScript declarations.
import type { FnSig, WasmType } from './wasmExports';
type Sig = { params: number[]; results: number[] }; type Sig = { params: number[]; results: number[] };
export interface ExportMeta {
paramNames: string[];
fnPtrSigs: Map<number, FnSig>;
}
const VALUE_TYPE_TS: Record<number, string> = { const VALUE_TYPE_TS: Record<number, string> = {
0x7f: 'number', // i32 0x7f: 'number', // i32
0x7e: 'bigint', // i64 0x7e: 'bigint', // i64
@ -10,6 +17,13 @@ const VALUE_TYPE_TS: Record<number, string> = {
0x7c: 'number', // f64 0x7c: 'number', // f64
}; };
const WASM_STR_TO_TS: Record<WasmType, string> = {
i32: 'number',
i64: 'bigint',
f32: 'number',
f64: 'number',
};
function readULEB128(bytes: Uint8Array, offset: number): [number, number] { function readULEB128(bytes: Uint8Array, offset: number): [number, number] {
let result = 0; let result = 0;
let shift = 0; let shift = 0;
@ -118,9 +132,24 @@ function tsType(valType: number): string {
return VALUE_TYPE_TS[valType] ?? 'unknown'; return VALUE_TYPE_TS[valType] ?? 'unknown';
} }
function tsSignature({ params, results }: Sig, names?: string[]): string { function renderFnPtrType(sig: FnSig): string {
const params = sig.params.map((t, i) => `a${i}: ${WASM_STR_TO_TS[t]}`).join(', ');
const ret = sig.results.length === 0
? 'void'
: sig.results.length === 1
? WASM_STR_TO_TS[sig.results[0]]
: `[${sig.results.map(t => WASM_STR_TO_TS[t]).join(', ')}]`;
return `(${params}) => ${ret}`;
}
function tsSignature({ params, results }: Sig, meta?: ExportMeta): string {
const paramList = params const paramList = params
.map((t, i) => `${names?.[i] || `a${i}`}: ${tsType(t)}`) .map((t, i) => {
const name = meta?.paramNames[i] || `a${i}`;
const fnSig = meta?.fnPtrSigs.get(i);
if (fnSig) return `${name}: (${renderFnPtrType(fnSig)}) | number`;
return `${name}: ${tsType(t)}`;
})
.join(', '); .join(', ');
let ret: string; let ret: string;
if (results.length === 0) ret = 'void'; if (results.length === 0) ret = 'void';
@ -129,7 +158,7 @@ function tsSignature({ params, results }: Sig, names?: string[]): string {
return `(${paramList}) => ${ret}`; return `(${paramList}) => ${ret}`;
} }
export function renderDts(wasmBytes: Uint8Array, paramNames?: Map<string, string[]>): string { export function renderDts(wasmBytes: Uint8Array, meta?: Map<string, ExportMeta>): string {
const sigs = parseWasmExports(wasmBytes); const sigs = parseWasmExports(wasmBytes);
const fns = Array.from(sigs.entries()) const fns = Array.from(sigs.entries())
.filter(([name]) => !name.startsWith('_')) .filter(([name]) => !name.startsWith('_'))
@ -138,7 +167,7 @@ export function renderDts(wasmBytes: Uint8Array, paramNames?: Map<string, string
return [ return [
'// Auto-generated by wasmPlugin. Do not edit.', '// Auto-generated by wasmPlugin. Do not edit.',
'declare const _: {', 'declare const _: {',
...fns.map(([name, sig]) => ` ${name}: ${tsSignature(sig, paramNames?.get(name))};`), ...fns.map(([name, sig]) => ` ${name}: ${tsSignature(sig, meta?.get(name))};`),
' memory: WebAssembly.Memory;', ' memory: WebAssembly.Memory;',
' table: WebAssembly.Table;', ' table: WebAssembly.Table;',
' readonly data: DataView;', ' readonly data: DataView;',

View File

@ -54,8 +54,7 @@ namespace Physics {
} }
export function setCollisionCallback(cb: (a: number, b: number) => void) { export function setCollisionCallback(cb: (a: number, b: number) => void) {
const cbPtr = E.table.grow(1, new WebAssembly.Function({ parameters: ['i32', 'i32'], results: [] }, cb)); E.rigid_body_set_collision_callback(cb);
E.rigid_body_set_collision_callback(cbPtr);
} }
} }

View File

@ -3,4 +3,5 @@ import awoo from './awoo.cpp';
export default function main() { export default function main() {
console.log(awoo) console.log(awoo)
awoo.awoo(); awoo.awoo();
awoo.something(() => console.log('called'));
} }