Skip to content

Commit 31b93ea

Browse files
committed
js exports
1 parent 34bd4fe commit 31b93ea

8 files changed

Lines changed: 272 additions & 34 deletions

File tree

DOCS.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,27 @@ $ ./hello.js
7171
Cacaw, World!
7272
```
7373

74-
To run with Node.js directly, you need the `--experimental-wasm-jspi` option.
74+
You can embed the wasm into a single JS script with `--embed`.
75+
76+
You can run with node directly (prior to v25, you need `--experimental-wasm-jspi`).
7577

7678
```bash
77-
$ node --experimental-wasm-jspi hello.js
79+
$ node hello.js
7880
Cacaw, World!
7981
```
8082

83+
Exported Raven functions are emitted as async JS ones.
84+
85+
```rust
86+
export { add }
87+
fn add(a: JSObject, b: JSObject) { Int32(a) + Int32(b) }
88+
```
89+
90+
```js
91+
import { add } from './math.js'
92+
console.log(await add(2, 3))
93+
```
94+
8195
Profile the compiler:
8296

8397
```bash

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
},
1919
"scripts": {
2020
"prepare": "npm run build",
21-
"build:cli": "esbuild --platform=node --sourcemap --bundle --format=esm --external:commander --outdir=dist/cli src/cli/index.ts src/cli/worker.ts src/cli/exec.ts",
21+
"build:cli": "esbuild --platform=node --sourcemap --bundle --format=esm --external:commander --outdir=dist/cli src/cli/index.ts src/cli/worker.ts src/cli/exec.ts src/cli/lib.ts",
2222
"watch": "npm run build:cli -- --watch",
2323
"start": "npm run build:cli && node --enable-source-maps --experimental-wasm-jspi dist/cli/index.js",
2424
"test": "tsc && npm run build:cli && node --max-old-space-size=8192 --enable-source-maps --experimental-wasm-jspi ./node_modules/uvu/bin.js -r tsx test -i '^(?!.*\\.ts$).*'",

src/backend/wasm.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,15 @@ class BatchEmitter implements Emitter {
276276
seen: Set<string>
277277
funcs: wasm.Func[]
278278
imports: wasm.Import[]
279+
exports: wasm.Export[]
279280
constructor(tables: Tables) {
280281
this.tables = tables
281282
this.main = []
282283
this.destructors = []
283284
this.seen = new Set()
284285
this.funcs = []
285286
this.imports = []
287+
this.exports = []
286288
}
287289

288290
clone(): BatchEmitter {
@@ -292,6 +294,7 @@ class BatchEmitter implements Emitter {
292294
em.seen = new Set(this.seen)
293295
em.funcs = [...this.funcs]
294296
em.imports = [...this.imports]
297+
em.exports = [...this.exports]
295298
return em
296299
}
297300

@@ -321,6 +324,11 @@ class BatchEmitter implements Emitter {
321324
for (const f of this.tables.funcs) this.emitName(calls, f)
322325
this.destructors.push(func.name)
323326
}
327+
328+
export(name: string, as = name) {
329+
const existing = this.exports.some(ex => ex.name === name && ex.as === as)
330+
if (!existing) this.exports.push(wasm.Export(name, as))
331+
}
324332
}
325333

326334
const refTable = 'jsrefs'
@@ -368,7 +376,8 @@ function wasmmodule(em: BatchEmitter): wasm.Module {
368376
wasm.Export('cm32p2_memory'),
369377
wasm.Export(refTable),
370378
wasm.Export('allocs'),
371-
wasm.Export('frees')
379+
wasm.Export('frees'),
380+
...em.exports
372381
],
373382
globals: [...em.tables.globals, ...refGlobals].map(g => wasm.Global(...g)),
374383
tables: moduleTables(em.tables),

src/cli/compile.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import * as wasm from '../backend/wasm.js'
2+
import * as types from '../frontend/types.js'
3+
import * as ast from '../frontend/ast.js'
4+
import { Binding, MIR, Method } from '../frontend/modules.js'
5+
import { lowerpattern } from '../frontend/patterns.js'
6+
import { xcall, xlist, xpart } from '../frontend/lower.js'
27
import { Options, withOptions } from '../utils/options.js'
8+
import { unreachable } from '../utils/ir.js'
9+
import { reset } from '../utils/cache.js'
310
import * as path from 'path'
411
import { chmod, mkdir, readFile, writeFile } from 'fs/promises'
512
import { spawn, SpawnOptions } from 'node:child_process'
613
import { dirname } from './dirname.js'
714
import { Compiler } from '../backend/compiler.js'
15+
import type { Sig } from '../middle/abstract.js'
16+
import { Def } from '../dwarf/index.js'
817

918
export { Compiler, compile, compileJS, exec, load }
1019

@@ -20,6 +29,7 @@ interface CompileConfig {
2029
compiler?: Compiler
2130
options?: Partial<Options>
2231
output?: string
32+
embed?: boolean
2333
strip?: boolean
2434
}
2535

@@ -37,21 +47,110 @@ async function compile(file: string, config: CompileConfig = {}): Promise<[Compi
3747
return [compiler!, wasmPath]
3848
}
3949

50+
function isJSIdentifier(name: string): boolean {
51+
return /^[$A-Z_][0-9A-Z_$]*$/i.test(name)
52+
}
53+
54+
function buildPaths(file: string, dir: string, output?: string): { js: string, wasm: string } {
55+
if (!output) {
56+
const base = path.basename(file, path.extname(file))
57+
return {
58+
js: path.join(dir, `${base}.js`),
59+
wasm: path.join(dir, `${base}.wasm`)
60+
}
61+
}
62+
const { dir: outDir, name, ext } = path.parse(output)
63+
const wasmBase = ext ? path.join(outDir, name) : output
64+
return { js: output, wasm: `${wasmBase}.wasm` }
65+
}
66+
67+
function exportedFunctions(compiler: Compiler): [string, types.Tag][] {
68+
const mod = compiler.pipe.sources.module(types.tag(''))
69+
const out: [string, types.Tag][] = []
70+
for (const name of [...mod.exports].sort()) {
71+
const value = compiler.pipe.defs.resolve_static(new Binding(types.tag(''), name))
72+
if (!(value instanceof types.Tag)) continue
73+
out.push([name, value])
74+
}
75+
return out
76+
}
77+
78+
// TODO better to have a generic means for converting to JS functions. Exported
79+
// globals can implicitly convert to JS, and we don't need to wrap.
80+
function libWrapperIR(name: string, f: types.Tag): MIR {
81+
const rcall = (code: MIR, fn: types.Tag | Method, args: any[]) => {
82+
const arglist = code.push(code.stmt(xlist(...args)))
83+
const result = code.push(code.stmt(xcall(fn, arglist)))
84+
return code.push(code.stmt(xpart(result, types.Type(1n))))
85+
}
86+
const code = MIR(Def(name))
87+
const args = code.argument(unreachable)
88+
const jsargs = rcall(code, types.tag('common.JSObject'), [args])
89+
const rvargs = rcall(code, types.tag('common.collect'), [jsargs])
90+
const result = code.push(code.stmt(xcall(f, rvargs)))
91+
const value = code.push(code.stmt(xpart(result, types.Type(1n))))
92+
const jsresult = rcall(code, types.tag('common.js'), [value])
93+
code.return(code.push(code.stmt(xpart(jsresult, types.Type(1n)))))
94+
return code
95+
}
96+
97+
function emitSig(compiler: Compiler, em: wasm.BatchEmitter, sig: Sig, main = true): string {
98+
reset(compiler.pipe)
99+
const func = compiler.pipe.wasm.get(sig)
100+
const calls = wasm.calltree(compiler.pipe.wasm, func)
101+
const start = em.main.length
102+
em.emit(calls, func)
103+
if (!main) em.main.length = start
104+
return func.name
105+
}
106+
107+
function jsRuntime(exports: [string, string][], runtime: string, config: { wasmFile?: string, base64?: string, memcheck?: boolean }): string {
108+
const { wasmFile, base64, memcheck = false } = config
109+
const init = base64
110+
? `\nconst __raven = await __ravenInline(${JSON.stringify(base64)}, ${memcheck})\n`
111+
: `\nconst __raven = await __ravenLib(${JSON.stringify(wasmFile)}, ${memcheck})\n`
112+
const wrappers = exports.map(([name, wasmName], i) => {
113+
const fn = `__raven_fn_${i}`
114+
return `const ${fn} = __raven(${JSON.stringify(wasmName)})
115+
export const ${name} = (...args) => ${fn}(args)`
116+
}).join('\n')
117+
return `${runtime}${init}${wrappers}\n`
118+
}
119+
40120
async function compileJS(file: string, config: CompileConfig = {}): Promise<[Compiler, string]> {
41-
let { dir = path.dirname(file), compiler, options = {}, output, strip = false } = config
42-
const base = path.basename(file, path.extname(file))
43-
const jsPath = output ?? path.join(dir, `${base}.js`)
44-
await mkdir(path.dirname(jsPath), { recursive: true })
45-
await withOptions(options, async () => {
121+
let { dir = path.dirname(file), compiler, options = {}, output, embed: inlineWasm = false, strip = false } = config
122+
const memcheck = options.memcheck ?? false
123+
const paths = buildPaths(file, dir, output)
124+
await mkdir(path.dirname(paths.js), { recursive: true })
125+
if (!inlineWasm) await mkdir(path.dirname(paths.wasm), { recursive: true })
126+
await withOptions({ ...options, memcheck }, async () => {
46127
compiler ??= await Compiler.create(load)
47128
const em = await compiler.reload(file)
129+
const exports: [string, string][] = []
130+
const runtime = await readFile(libPath, 'utf8')
131+
const mod = compiler.pipe.sources.module(types.tag(''))
132+
for (const [i, [name, fn]] of exportedFunctions(compiler).entries()) {
133+
if (!isJSIdentifier(name))
134+
throw new Error(`Cannot export ${JSON.stringify(name)} as a JS binding`)
135+
const tag = types.tag(`__raven.lib.${i}`)
136+
const sig = lowerpattern(ast.List(ast.symbol('args')))
137+
const method = mod.method(tag, sig, libWrapperIR(tag.path, fn))
138+
const fname = emitSig(compiler, em, [method, types.Ref], false)
139+
const wname = `raven.lib.${name}`
140+
em.export(fname, wname)
141+
exports.push([name, wname])
142+
}
48143
const bytes = wasm.emitwasm(em, strip)
49-
const base64 = Buffer.from(bytes).toString('base64')
50-
const runtime = await readFile(execPath, 'utf8')
51-
await writeFile(jsPath, `${runtime}\nbinary = Buffer.from('${base64}', 'base64')\n`)
52-
await chmod(jsPath, 0o755)
144+
if (inlineWasm) {
145+
const base64 = Buffer.from(bytes).toString('base64')
146+
await writeFile(paths.js, jsRuntime(exports, runtime, { base64, memcheck }))
147+
} else {
148+
await writeFile(paths.wasm, Buffer.from(bytes))
149+
await writeFile(paths.js, jsRuntime(exports, runtime, { wasmFile: path.basename(paths.wasm), memcheck }))
150+
}
151+
await chmod(paths.js, 0o755)
53152
})
54-
return [compiler!, jsPath]
153+
return [compiler!, paths.js]
55154
}
56155

57156
async function run(cmd: string, args: readonly string[] = [], options: SpawnOptions = {}) {
@@ -62,6 +161,7 @@ async function run(cmd: string, args: readonly string[] = [], options: SpawnOpti
62161
})
63162
}
64163

164+
const libPath = path.join(dirname, '../../dist/cli/lib.js')
65165
const execPath = path.join(dirname, '../../dist/cli/exec.js')
66166

67167
async function exec(file: string, args: string[] = [], config?: CompileConfig): Promise<void> {

src/cli/exec.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,20 @@
22
import * as fs from 'fs/promises'
33
import { loadWasm } from '../backend/support.js'
44

5-
let binary: string | undefined
5+
const wasm = await fs.readFile(process.argv[2])
6+
let { _start, jsrefs, allocs, frees } = await loadWasm(wasm)
7+
_start = (WebAssembly as any).promising(_start)
68

7-
async function main() {
8-
const wasm = binary ? Buffer.from(binary, 'base64') : await fs.readFile(process.argv[2])
9-
let { _start, jsrefs, allocs, frees } = await loadWasm(wasm)
10-
_start = (WebAssembly as any).promising(_start)
11-
try {
12-
await (_start as any)()
13-
} catch (e) {
14-
console.error(e)
15-
process.exit(1)
16-
}
17-
if (allocs.value !== frees.value)
18-
console.warn(`Memory management fault: ${allocs.value} allocs != ${frees.value} frees`)
19-
for (let i = 0; i < jsrefs.length; i++)
20-
if (jsrefs.get(i) !== null)
21-
console.warn("Memory management fault: JSObject")
9+
try {
10+
await (_start as any)()
11+
} catch (e) {
12+
console.error(e)
13+
process.exit(1)
2214
}
2315

24-
setImmediate(() => main())
16+
if (allocs.value !== frees.value)
17+
console.warn(`Memory management fault: ${allocs.value} allocs != ${frees.value} frees`)
18+
19+
for (let i = 0; i < jsrefs.length; i++)
20+
if (jsrefs.get(i) !== null)
21+
console.warn("Memory management fault: JSObject")

src/cli/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,20 @@ async function main() {
119119
.command('build')
120120
.description('Compile a Raven source file')
121121
.argument('<source>', 'Source file to compile')
122-
.option('--js', 'Emit JS')
122+
.option('--js', 'Emit JS wrapper')
123+
.option('--embed', 'Embed WASM into emitted JS')
123124
.option('-o, --output <file>', 'Rename output file')
124125
.option('--time', 'Print compiler phase timing information')
125-
.action(async (source, { output, js, time }) => {
126+
.action(async (source, { output, js, embed, time }) => {
126127
let { inline, memcheck, strip } = program.optsWithGlobals()
127128
source = path.resolve(process.cwd(), source)
128-
let [compiler] = await (js ? compileJS : compile)(source, { options: { inline, memcheck }, output, strip })
129+
const build = js ? compileJS : compile
130+
let [compiler] = await build(source, {
131+
options: { inline, memcheck: js ? false : memcheck },
132+
output,
133+
embed: js && !!embed,
134+
strip
135+
})
129136
if (time) printTiming(compiler)
130137
})
131138

src/cli/lib.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env -S node --enable-source-maps --experimental-wasm-jspi
2+
import * as fs from 'node:fs/promises'
3+
import { loadWasm } from '../backend/support.js'
4+
5+
export { __ravenInline, __ravenLib }
6+
7+
async function __ravenInline(base64: string, check = false) {
8+
return await __ravenLoad(Buffer.from(base64, 'base64'), check)
9+
}
10+
11+
async function __ravenLib(file: string, check = false) {
12+
const wasm = await fs.readFile(new URL(`./${file}`, import.meta.url))
13+
return await __ravenLoad(wasm, check)
14+
}
15+
16+
function checkMemory({ allocs, frees, jsrefs }: any) {
17+
if (allocs.value !== frees.value)
18+
console.warn(`Memory management fault: ${allocs.value} allocs != ${frees.value} frees`)
19+
for (let i = 0; i < jsrefs.length; i++)
20+
if (jsrefs.get(i) !== null)
21+
console.warn("Memory management fault: JSObject")
22+
}
23+
24+
async function __ravenLoad(wasm: Uint8Array, check: boolean) {
25+
const exports = await loadWasm(wasm)
26+
const _start = (WebAssembly as any).promising(exports._start)
27+
await _start()
28+
if (check) checkMemory(exports)
29+
return (name: string) => {
30+
const fn = (exports as any)[name]
31+
if (typeof fn !== 'function') throw new Error(`Missing Raven export: ${name}`)
32+
return (WebAssembly as any).promising(fn)
33+
}
34+
}

0 commit comments

Comments
 (0)