diff --git a/cli/README.md b/cli/README.md index 9e048005e3..145e09649c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -56,3 +56,7 @@ Available command line options can also be obtained programmatically: import { options } from "assemblyscript/asc"; ... ``` + +Declarative JSON host bindings are generated with `--bindings json`. See +[`assemblyscript/bindings`](../lib/bindings/README.md) for the schema runtime, +supported types, and the security model for untrusted modules. diff --git a/cli/index.d.ts b/cli/index.d.ts index 58c7fb4ef6..5a47cd6396 100644 --- a/cli/index.d.ts +++ b/cli/index.d.ts @@ -97,7 +97,7 @@ export interface CompilerOptions { /** Specifies the WebAssembly text output file (.wat). */ textFile?: string; /** Specified the bindings to generate. */ - bindings?: string[]; + bindings?: Array<"esm" | "raw" | "json">; /** Enables source map generation. Optionally takes the URL. */ sourceMap?: boolean | string; /** Specifies the runtime variant to include in the program. */ diff --git a/cli/index.js b/cli/index.js index 7e202e4f61..536f3fb206 100644 --- a/cli/index.js +++ b/cli/index.js @@ -967,6 +967,7 @@ export async function main(argv, options) { // Write TypeScript definition const bindingsEsm = bindings.includes("esm"); const bindingsRaw = !bindingsEsm && bindings.includes("raw"); + const bindingsJSON = bindings.includes("json"); if (bindingsEsm || bindingsRaw) { if (basepath) { let begin = stats.begin(); @@ -1005,6 +1006,26 @@ export async function main(argv, options) { stderr.write(`Skipped JavaScript binding (no output path)${EOL}`); } } + + // Write declarative JSON bindings + if (bindingsJSON) { + if (basepath) { + let begin = stats.begin(); + stats.emitCount++; + let source; + try { + source = assemblyscript.buildJSON(program); + } catch (e) { + crash("buildJSON", e); + } + stats.emitTime += stats.end(begin); + pending.push( + writeFile(basepath + ".bindings.json", source, baseDir) + ); + } else { + stderr.write(`Skipped JSON binding (no output path)${EOL}`); + } + } } try { diff --git a/cli/options.json b/cli/options.json index f6776dbe26..4eadfa78f4 100644 --- a/cli/options.json +++ b/cli/options.json @@ -79,12 +79,14 @@ "bindings": { "category": "Output", "description": [ - "Specifies the bindings to generate (.js + .d.ts).", + "Specifies the bindings to generate.", "", " esm JavaScript bindings & typings for ESM integration.", " raw Like esm, but exports just the instantiate function.", " Useful where modules are meant to be instantiated", - " multiple times or non-ESM imports must be provided." + " multiple times or non-ESM imports must be provided.", + " json Emits a declarative .bindings.json ABI for use with", + " assemblyscript/bindings. Can be combined with esm or raw." ], "type": "S", "alias": "b" diff --git a/lib/bindings/README.md b/lib/bindings/README.md new file mode 100644 index 0000000000..9bd4ba7436 --- /dev/null +++ b/lib/bindings/README.md @@ -0,0 +1,89 @@ +# Declarative host bindings + +The `assemblyscript/bindings` module instantiates AssemblyScript WebAssembly +without executing compiler-generated JavaScript. Its adapters are built from a +versioned JSON ABI emitted by `asc`. + +## Generate a schema + +```sh +asc module.ts --outFile module.wasm --bindings json +``` + +This writes `module.bindings.json`. JSON bindings can be emitted alongside the +existing bindings: + +```sh +asc module.ts --outFile module.wasm --bindings esm,json +asc module.ts --outFile module.wasm --bindings raw,json +``` + +The compiler automatically exports the runtime functions needed to lower and +retain managed values. `--exportRuntime` remains useful when an application +also needs direct access to the complete runtime interface. + +## Instantiate + +```js +import { readFile } from "node:fs/promises"; +import { instantiate } from "assemblyscript/bindings"; + +const [wasm, schemaText] = await Promise.all([ + readFile("module.wasm"), + readFile("module.bindings.json", "utf8") +]); + +const { module, instance, exports } = await instantiate( + wasm, + schemaText, + { + host: { + log(message) { + console.log(message); + } + } + } +); +``` + +`instantiateSync(moduleOrBytes, schema, imports)` provides the synchronous +equivalent. `adaptInstance(instance, schema, imports)` adapts an existing +instance, but cannot retroactively adapt the imports used to create it. + +Adapted exports inherit the raw WebAssembly exports. AssemblyScript functions, +globals and enums described by the schema are defined as own properties. +Globals retain the `WebAssembly.Global`-like `.value` interface. + +Supported ABI values are booleans, signed and unsigned integers, floats, +strings, `ArrayBuffer`, `Array`, `StaticArray`, typed arrays, plain records, +nullable references and opaque internal references. Optional parameters use +AssemblyScript's `__setArgumentsLength` convention. + +## Security model + +The JSON runtime is designed for capability-based imports: + +- It never reads `globalThis` or uses host globals as an import prototype. +- Every WebAssembly import must appear in the schema and as an own property of + the supplied imports object. +- Extra supplied properties are not forwarded to WebAssembly. +- It does not use `eval`, `Function`, dynamic `import()`, or schema-provided + module URLs. +- `@external.js` bodies are marked by `embeddedJavaScript: true`, but their text + is not included or executed. The host must explicitly provide that import. +- Only schema format version 1 for `wasm32` is accepted. + +Treat the `.wasm` and `.bindings.json` files as one trusted compiler output. +The schema controls ABI interpretation and should not be accepted independently +from an untrusted party. Validate or authenticate both artifacts together. + +WebAssembly isolation does not impose CPU or memory quotas. For untrusted code, +also use a worker or process boundary, set `--maximumMemory`, terminate runaway +execution, restrict all host imports, and compile user source in a separate +sandbox. A compiler bug can affect the compiler process even when generated +JavaScript is never executed. + +Imported globals are passed through as native WebAssembly values. Managed +values returned by an import require runtime allocation and therefore cannot be +returned during WebAssembly's native start section. Prefer `--exportStart`, +which invokes initialization after the instance and adapters are ready. diff --git a/lib/bindings/index.d.ts b/lib/bindings/index.d.ts new file mode 100644 index 0000000000..2bf8009b86 --- /dev/null +++ b/lib/bindings/index.d.ts @@ -0,0 +1,33 @@ +export interface BindingResult { + module: WebAssembly.Module; + instance: WebAssembly.Instance; + exports: T; +} + +export interface BindingsSchema { + format: "assemblyscript-bindings"; + version: 1; + target: "wasm32"; + memory: object; + imports: object[]; + exports: object[]; + types: Record; +} + +export function instantiate( + module: WebAssembly.Module | BufferSource, + schema: BindingsSchema | string, + imports?: WebAssembly.Imports, +): Promise>; + +export function instantiateSync( + module: WebAssembly.Module | BufferSource, + schema: BindingsSchema | string, + imports?: WebAssembly.Imports, +): BindingResult; + +export function adaptInstance( + instance: WebAssembly.Instance, + schema: BindingsSchema | string, + imports?: WebAssembly.Imports, +): T; diff --git a/lib/bindings/index.js b/lib/bindings/index.js new file mode 100644 index 0000000000..57f8f238fb --- /dev/null +++ b/lib/bindings/index.js @@ -0,0 +1,447 @@ +/** + * Instantiates WebAssembly using a declarative AssemblyScript bindings schema. + * No ambient globals or executable code from the schema are consulted. + */ +export async function instantiate(module, schema, imports = {}) { + schema = validateSchema(schema); + const state = createState(schema, imports); + const adaptedImports = state.adaptImports(imports); + const result = await WebAssembly.instantiate(module, adaptedImports); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const compiledModule = result instanceof WebAssembly.Instance ? module : result.module; + const exports = state.adaptExports(instance); + return { module: compiledModule, instance, exports }; +} + +/** Synchronous counterpart of {@link instantiate}. */ +export function instantiateSync(module, schema, imports = {}) { + schema = validateSchema(schema); + const state = createState(schema, imports); + const adaptedImports = state.adaptImports(imports); + const compiledModule = module instanceof WebAssembly.Module + ? module + : new WebAssembly.Module(module); + const instance = new WebAssembly.Instance(compiledModule, adaptedImports); + const exports = state.adaptExports(instance); + return { module: compiledModule, instance, exports }; +} + +/** Adapts an instance that has already been instantiated. */ +export function adaptInstance(instance, schema, imports = {}) { + schema = validateSchema(schema); + const state = createState(schema, imports); + state.adaptImports(imports); + return state.adaptExports(instance); +} + +function validateSchema(schema) { + if (typeof schema === "string") schema = JSON.parse(schema); + if (!schema || schema.format !== "assemblyscript-bindings" || schema.version !== 1) { + throw new TypeError("unsupported AssemblyScript bindings schema"); + } + if (schema.target !== "wasm32") { + throw new TypeError(`unsupported bindings target '${schema.target}'`); + } + if (!schema.memory || !Array.isArray(schema.imports) || !Array.isArray(schema.exports) || !schema.types) { + throw new TypeError("invalid AssemblyScript bindings schema"); + } + return schema; +} + +function createState(schema, suppliedImports) { + let rawExports; + let memory; + let dataView; + const refcounts = new Map(); + const Internref = class Internref extends Number {}; + const registry = typeof FinalizationRegistry === "function" + ? new FinalizationRegistry(release) + : null; + + function own(object, key) { + return object != null && Object.prototype.hasOwnProperty.call(object, key); + } + + function requireImport(imports, moduleName, name) { + if (!own(imports, moduleName) || !own(imports[moduleName], name)) { + throw new TypeError(`missing import '${moduleName}.${name}'`); + } + return imports[moduleName][name]; + } + + function typeOf(type) { + if (type.kind !== "reference") return type; + const definition = schema.types[type.id]; + if (!definition) throw new TypeError(`unknown bindings type '${type.id}'`); + return definition; + } + + function getMemory() { + if (!(memory instanceof WebAssembly.Memory)) { + throw new TypeError("bindings require an exported or explicitly imported WebAssembly.Memory"); + } + return memory; + } + + function view() { + const buffer = getMemory().buffer; + if (!dataView || dataView.buffer !== buffer) dataView = new DataView(buffer); + return dataView; + } + + function get(pointer, type) { + const littleEndian = true; + switch (type.kind) { + case "i8": return view().getInt8(pointer); + case "u8": case "bool": return view().getUint8(pointer); + case "i16": return view().getInt16(pointer, littleEndian); + case "u16": return view().getUint16(pointer, littleEndian); + case "i32": return view().getInt32(pointer, littleEndian); + case "u32": case "reference": case "internref": return view().getUint32(pointer, littleEndian); + case "i64": return view().getBigInt64(pointer, littleEndian); + case "u64": return view().getBigUint64(pointer, littleEndian); + case "f32": return view().getFloat32(pointer, littleEndian); + case "f64": return view().getFloat64(pointer, littleEndian); + default: throw new TypeError(`cannot load bindings type '${type.kind}'`); + } + } + + function set(pointer, type, value) { + const littleEndian = true; + switch (type.kind) { + case "i8": view().setInt8(pointer, value); break; + case "u8": case "bool": view().setUint8(pointer, value); break; + case "i16": view().setInt16(pointer, value, littleEndian); break; + case "u16": view().setUint16(pointer, value, littleEndian); break; + case "i32": view().setInt32(pointer, value, littleEndian); break; + case "u32": case "reference": case "internref": view().setUint32(pointer, value, littleEndian); break; + case "i64": view().setBigInt64(pointer, value, littleEndian); break; + case "u64": view().setBigUint64(pointer, value, littleEndian); break; + case "f32": view().setFloat32(pointer, value, littleEndian); break; + case "f64": view().setFloat64(pointer, value, littleEndian); break; + default: throw new TypeError(`cannot store bindings type '${type.kind}'`); + } + } + + function retain(pointer) { + if (pointer) { + const count = refcounts.get(pointer); + if (count) refcounts.set(pointer, count + 1); + else refcounts.set(rawExports.__pin(pointer), 1); + } + return pointer; + } + + function release(pointer) { + if (pointer) { + const count = refcounts.get(pointer); + if (count === 1) { + rawExports.__unpin(pointer); + refcounts.delete(pointer); + } else if (count) { + refcounts.set(pointer, count - 1); + } + } + } + + function lift(type, value) { + switch (type.kind) { + case "void": return undefined; + case "bool": return value !== 0; + case "u32": return value >>> 0; + case "u64": return BigInt.asUintN(64, value); + case "reference": return liftReference(type, value >>> 0); + case "internref": return liftInternref(value >>> 0); + default: return value; + } + } + + function lower(type, value) { + switch (type.kind) { + case "void": return undefined; + case "bool": return value ? 1 : 0; + case "i64": case "u64": return value || 0n; + case "reference": { + const pointer = lowerReference(type, value); + if (!pointer && !type.nullable) throw new TypeError("value must not be null"); + return pointer; + } + case "internref": { + const pointer = lowerInternref(value); + if (!pointer && !type.nullable) throw new TypeError("value must not be null"); + return pointer; + } + default: return value; + } + } + + function liftMemory(type, pointer) { + return lift(type, get(pointer, type)); + } + + function lowerMemory(type, pointer, value) { + set(pointer, type, lower(type, value)); + } + + function liftReference(type, pointer) { + if (!pointer) return null; + const definition = typeOf(type); + const layout = schema.memory; + const buffer = getMemory().buffer; + switch (definition.kind) { + case "arraybuffer": { + const size = new Uint32Array(buffer)[pointer + layout.objectSizeOffset >>> 2]; + return buffer.slice(pointer, pointer + size); + } + case "string": { + const end = pointer + new Uint32Array(buffer)[pointer + layout.objectSizeOffset >>> 2] >>> 1; + const memoryU16 = new Uint16Array(buffer); + let start = pointer >>> 1; + let string = ""; + while (end - start > 1024) string += String.fromCharCode(...memoryU16.subarray(start, start += 1024)); + return string + String.fromCharCode(...memoryU16.subarray(start, end)); + } + case "array": { + const dataStart = get(pointer + layout.arrayBufferView.dataStartOffset, { kind: "u32" }); + const length = view().getUint32(pointer + layout.arrayBufferView.lengthOffset, true); + return Array.from({ length }, (_, i) => liftMemory(definition.element, dataStart + (i << definition.align))); + } + case "staticarray": { + const length = get(pointer + layout.objectSizeOffset, { kind: "u32" }) >>> definition.align; + return Array.from({ length }, (_, i) => liftMemory(definition.element, pointer + (i << definition.align))); + } + case "typedarray": { + const Constructor = typedArrayConstructor(definition.constructor); + const dataStart = get(pointer + layout.arrayBufferView.dataStartOffset, { kind: "u32" }); + const byteLength = view().getUint32(pointer + layout.arrayBufferView.byteLengthOffset, true); + return new Constructor(buffer, dataStart, byteLength / Constructor.BYTES_PER_ELEMENT).slice(); + } + case "record": { + const result = {}; + for (const field of definition.fields) result[field.name] = liftMemory(field.type, pointer + field.offset); + return result; + } + case "internref": return liftInternref(pointer); + default: throw new TypeError(`unsupported reference type '${definition.kind}'`); + } + } + + function lowerReference(type, value) { + if (value == null) return 0; + const definition = typeOf(type); + const layout = schema.memory; + switch (definition.kind) { + case "arraybuffer": { + const bytes = new Uint8Array(value); + const pointer = allocate(bytes.byteLength, layout.arrayBufferId); + new Uint8Array(getMemory().buffer).set(bytes, pointer); + return pointer; + } + case "string": { + const pointer = allocate(value.length << 1, layout.stringId); + const memoryU16 = new Uint16Array(getMemory().buffer); + for (let i = 0; i < value.length; ++i) memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i); + return pointer; + } + case "array": return lowerArray(definition, value, false); + case "staticarray": return lowerStaticArray(definition, value); + case "typedarray": return lowerTypedArray(definition, value); + case "record": { + const pointer = pin(allocate(definition.size, definition.id)); + try { + for (const field of definition.fields) lowerMemory(field.type, pointer + field.offset, value[field.name]); + } finally { + rawExports.__unpin(pointer); + } + return pointer; + } + case "internref": return lowerInternref(value); + default: throw new TypeError(`unsupported reference type '${definition.kind}'`); + } + } + + function lowerArray(definition, values) { + const layout = schema.memory; + const length = values.length; + const buffer = pin(allocate(length << definition.align, layout.arrayBufferId)); + const header = pin(allocate(layout.arrayBufferView.lengthOffset + 4, definition.id)); + try { + set(header + layout.arrayBufferView.bufferOffset, { kind: "u32" }, buffer); + view().setUint32(header + layout.arrayBufferView.dataStartOffset, buffer, true); + view().setUint32(header + layout.arrayBufferView.byteLengthOffset, length << definition.align, true); + view().setUint32(header + layout.arrayBufferView.lengthOffset, length, true); + for (let i = 0; i < length; ++i) lowerMemory(definition.element, buffer + (i << definition.align), values[i]); + } finally { + rawExports.__unpin(buffer); + rawExports.__unpin(header); + } + return header; + } + + function lowerStaticArray(definition, values) { + const pointer = pin(allocate(values.length << definition.align, definition.id)); + try { + for (let i = 0; i < values.length; ++i) lowerMemory(definition.element, pointer + (i << definition.align), values[i]); + } finally { + rawExports.__unpin(pointer); + } + return pointer; + } + + function lowerTypedArray(definition, values) { + const layout = schema.memory; + const Constructor = typedArrayConstructor(definition.constructor); + const length = values.length; + const buffer = pin(allocate(length << definition.align, layout.arrayBufferId)); + try { + const header = allocate(layout.arrayBufferView.size, definition.id); + set(header + layout.arrayBufferView.bufferOffset, { kind: "u32" }, buffer); + view().setUint32(header + layout.arrayBufferView.dataStartOffset, buffer, true); + view().setUint32(header + layout.arrayBufferView.byteLengthOffset, length << definition.align, true); + new Constructor(getMemory().buffer, buffer, length).set(values); + return header; + } finally { + rawExports.__unpin(buffer); + } + } + + function liftInternref(pointer) { + if (!pointer) return null; + const value = new Internref(retain(pointer)); + if (registry) registry.register(value, pointer); + return value; + } + + function lowerInternref(value) { + if (value == null) return 0; + if (value instanceof Internref) return value.valueOf(); + throw new TypeError("internref expected"); + } + + function allocate(size, id) { + if (!rawExports || typeof rawExports.__new !== "function") { + throw new TypeError("lowering this value requires the '__new' runtime export"); + } + return rawExports.__new(size, id) >>> 0; + } + + function pin(pointer) { + if (typeof rawExports.__pin !== "function" || typeof rawExports.__unpin !== "function") { + throw new TypeError("lowering this value requires the '__pin' and '__unpin' runtime exports"); + } + return rawExports.__pin(pointer) >>> 0; + } + + function adaptFunction(signature, fn, direction) { + if (direction === "import") { + return (...args) => { + for (let i = 0; i < signature.parameters.length; ++i) args[i] = lift(signature.parameters[i].type, args[i]); + const result = fn(...args); + return signature.return.kind === "void" ? undefined : lower(signature.return, result); + }; + } + return function(...args) { + const retained = []; + const provided = arguments.length; + let references = signature.parameters.reduce((count, parameter, index) => count + (index < provided && parameter.type.kind === "reference" ? 1 : 0), 0); + try { + for (let i = 0; i < signature.parameters.length; ++i) { + const type = signature.parameters[i].type; + args[i] = i < provided ? lower(type, args[i]) : omittedValue(type); + if (i < provided && type.kind === "reference" && --references > 0) { + args[i] = retain(args[i]); + retained.push(args[i]); + } + } + if (signature.requiredParameters < signature.parameters.length) { + if (typeof rawExports.__setArgumentsLength !== "function") throw new TypeError("optional arguments require '__setArgumentsLength'"); + rawExports.__setArgumentsLength(provided); + } + const result = fn(...args); + return signature.return.kind === "void" ? undefined : lift(signature.return, result); + } finally { + for (const pointer of retained) release(pointer); + } + }; + } + + function adaptImports(imports) { + const adapted = Object.create(null); + const memoryImport = schema.memory.import; + if (memoryImport) memory = requireImport(imports, memoryImport.module, memoryImport.name); + for (const entry of schema.imports) { + const value = requireImport(imports, entry.module, entry.name); + const module = adapted[entry.module] ||= Object.create(null); + if (entry.kind === "function") { + if (typeof value !== "function") throw new TypeError(`import '${entry.module}.${entry.name}' must be a function`); + module[entry.name] = adaptFunction(entry.signature, value, "import"); + } else { + module[entry.name] = value; + } + } + return adapted; + } + + function adaptExports(instance) { + if (!(instance instanceof WebAssembly.Instance)) throw new TypeError("instance must be a WebAssembly.Instance"); + rawExports = instance.exports; + if (schema.memory.export && rawExports[schema.memory.export] instanceof WebAssembly.Memory) { + memory = rawExports[schema.memory.export]; + } + const adapted = Object.create(rawExports); + for (const entry of schema.exports) { + if (entry.kind === "function") { + const fn = rawExports[entry.name]; + if (typeof fn !== "function") throw new TypeError(`missing WebAssembly export '${entry.name}'`); + Object.defineProperty(adapted, entry.name, { value: adaptFunction(entry.signature, fn, "export"), enumerable: true }); + } else if (entry.kind === "global") { + const global = rawExports[entry.name]; + if (!(global instanceof WebAssembly.Global)) throw new TypeError(`missing WebAssembly global '${entry.name}'`); + const wrapper = { valueOf() { return this.value; } }; + Object.defineProperty(wrapper, "value", { + enumerable: true, + get: () => lift(entry.type, global.value), + set: entry.mutable ? value => { global.value = lower(entry.type, value); } : undefined + }); + Object.defineProperty(adapted, entry.name, { value: wrapper, enumerable: true }); + } else if (entry.kind === "enum") { + const values = {}; + for (const item of entry.values) { + const value = own(item, "value") ? item.value : rawExports[item.export].valueOf(); + values[item.name] = value; + values[value] = item.name; + } + Object.defineProperty(adapted, entry.name, { value: values, enumerable: true }); + } + } + if (schema.start) { + const start = rawExports[schema.start]; + if (typeof start !== "function") throw new TypeError(`missing start export '${schema.start}'`); + start(); + } + return adapted; + } + + return { adaptImports, adaptExports }; +} + +function omittedValue(type) { + return type.kind === "i64" || type.kind === "u64" ? 0n : 0; +} + +function typedArrayConstructor(name) { + switch (name) { + case "Int8Array": return Int8Array; + case "Uint8Array": return Uint8Array; + case "Uint8ClampedArray": return Uint8ClampedArray; + case "Int16Array": return Int16Array; + case "Uint16Array": return Uint16Array; + case "Int32Array": return Int32Array; + case "Uint32Array": return Uint32Array; + case "BigInt64Array": return BigInt64Array; + case "BigUint64Array": return BigUint64Array; + case "Float32Array": return Float32Array; + case "Float64Array": return Float64Array; + default: throw new TypeError(`unsupported typed array '${name}'`); + } +} diff --git a/package.json b/package.json index 869f93b294..fc446e8dff 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,10 @@ "import": "./lib/binaryen.js", "types": "./lib/binaryen.d.ts" }, + "./bindings": { + "import": "./lib/bindings/index.js", + "types": "./lib/bindings/index.d.ts" + }, "./*": "./*" }, "imports": { @@ -79,10 +83,11 @@ "build": "node scripts/build", "watch": "node scripts/build --watch", "coverage": "npx c8 -- npm test", - "test": "npm run test:parser && npm run test:compiler -- --parallel && npm run test:browser && npm run test:asconfig && npm run test:transform && npm run test:cli", + "test": "npm run test:parser && npm run test:compiler -- --parallel && npm run test:browser && npm run test:bindings && npm run test:asconfig && npm run test:transform && npm run test:cli", "test:parser": "node --enable-source-maps tests/parser", "test:compiler": "node --enable-source-maps --no-warnings tests/compiler", "test:browser": "node --enable-source-maps tests/browser", + "test:bindings": "node --enable-source-maps tests/bindings", "test:asconfig": "cd tests/asconfig && npm run test", "test:transform": "npm run test:transform:esm && npm run test:transform:cjs", "test:transform:esm": "node bin/asc tests/compiler/empty --transform ./tests/transform/index.js --noEmit && node bin/asc tests/compiler/empty --transform ./tests/transform/simple.js --noEmit", @@ -104,6 +109,7 @@ "util/", "lib/binaryen.js", "lib/binaryen.d.ts", + "lib/bindings/", "tsconfig-base.json", "NOTICE" ], diff --git a/src/bindings.ts b/src/bindings.ts index 7da5ad772e..5ef6b5904f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -9,3 +9,4 @@ export { JSBuilder } from "./bindings/js"; export { TSDBuilder } from "./bindings/tsd"; +export { JSONBuilder } from "./bindings/json"; diff --git a/src/bindings/json.ts b/src/bindings/json.ts new file mode 100644 index 0000000000..bc2a66b302 --- /dev/null +++ b/src/bindings/json.ts @@ -0,0 +1,241 @@ +import { + DecoratorKind, + Source, + findDecorator +} from "../ast"; + +import { + CommonFlags +} from "../common"; + +import { + Class, + Element, + ElementKind, + Enum, + EnumValue, + Function, + Global, + Interface, + Program, + PropertyPrototype +} from "../program"; + +import { + Type, + TypeFlags +} from "../types"; + +import { + CharCode, + escapeString +} from "../util"; + +import { + ExportsWalker +} from "./util"; + +/** Builds the declarative ABI consumed by `assemblyscript/bindings`. */ +export class JSONBuilder extends ExportsWalker { + static build(program: Program): string { + return new JSONBuilder(program).build(); + } + + private exports: string[] = new Array(); + private types: Map = new Map(); + private pendingTypes: Class[] = new Array(); + + visitGlobal(name: string, element: Global): void { + this.exports.push(`{"kind":"global","name":${quote(name)},"type":${this.makeType(element.type)},"mutable":${element.is(CommonFlags.Const) ? "false" : "true"}}`); + } + + visitEnum(name: string, element: Enum): void { + let values = new Array(); + let members = element.members; + if (members) { + for (let _values = Map_values(members), i = 0, k = _values.length; i < k; ++i) { + let value = unchecked(_values[i]); + if (value.kind != ElementKind.EnumValue) continue; + let enumValue = value; + values.push(value.is(CommonFlags.Inlined) + ? `{"name":${quote(value.name)},"value":${i64_low(enumValue.constantIntegerValue).toString()}}` + : `{"name":${quote(value.name)},"export":${quote(name + "." + value.name)}}` + ); + } + } + this.exports.push(`{"kind":"enum","name":${quote(name)},"values":[${values.join(",")}]}`); + this.visitNamespace(name, element); + } + + visitFunction(name: string, element: Function): void { + if (element.is(CommonFlags.Private)) return; + this.exports.push(`{"kind":"function","name":${quote(name)},"signature":${this.makeSignature(element)}}`); + } + + visitClass(name: string, element: Class): void {} + visitInterface(name: string, element: Interface): void {} + visitNamespace(name: string, element: Element): void {} + visitAlias(name: string, element: Element, originalName: string): void {} + + build(): string { + this.walk(); + let imports = new Array(); + let moduleImports = this.program.moduleImports; + for (let _modules = Map_keys(moduleImports), i = 0, k = _modules.length; i < k; ++i) { + let moduleName = unchecked(_modules[i]); + let members = assert(moduleImports.get(moduleName)); + for (let _names = Map_keys(members), j = 0, l = _names.length; j < l; ++j) { + let name = unchecked(_names[j]); + let element = assert(members.get(name)); + if (element.kind == ElementKind.Function) { + let fn = element; + imports.push(`{"kind":"function","module":${quote(moduleName)},"name":${quote(name)},"signature":${this.makeSignature(fn)},"embeddedJavaScript":${findDecorator(DecoratorKind.ExternalJs, fn.decoratorNodes) ? "true" : "false"}}`); + } else if (element.kind == ElementKind.Global) { + let global = element; + imports.push(`{"kind":"global","module":${quote(moduleName)},"name":${quote(name)},"type":${this.makeType(global.type)}}`); + } + } + } + + // Describing records can discover further referenced types. + for (let i = 0; i < this.pendingTypes.length; ++i) this.makeTypeDefinition(unchecked(this.pendingTypes[i])); + let types = new Array(); + for (let _ids = Map_keys(this.types), i = 0, k = _ids.length; i < k; ++i) { + let id = unchecked(_ids[i]); + types.push(`${quote(id.toString())}:${assert(this.types.get(id))}`); + } + + let program = this.program; + let view = program.arrayBufferViewInstance; + let object = program.OBJECTInstance; + let options = program.options; + let exportStart = options.exportStart; + let objectSizeOffset = object.offsetof("rtSize") - object.nextMemoryOffset; + let memoryImport = options.importMemory + ? `{"module":"env","name":"memory"}` + : "null"; + return `{ + "format": "assemblyscript-bindings", + "version": 1, + "target": "${options.isWasm64 ? "wasm64" : "wasm32"}", + "memory": { + "export": ${options.exportMemory ? quote("memory") : "null"}, + "import": ${memoryImport}, + "objectSizeOffset": ${signedString(objectSizeOffset)}, + "arrayBufferId": ${program.arrayBufferInstance.id}, + "stringId": ${program.stringInstance.id}, + "arrayBufferView": { + "size": ${view.nextMemoryOffset}, + "bufferOffset": ${view.offsetof("buffer")}, + "dataStartOffset": ${view.offsetof("dataStart")}, + "byteLengthOffset": ${view.offsetof("byteLength")}, + "lengthOffset": ${view.nextMemoryOffset} + } + }, + "start": ${exportStart ? quote(exportStart) : "null"}, + "imports": [${imports.join(",")}], + "exports": [${this.exports.join(",")}], + "types": {${types.join(",")}} +}\n`; + } + + private makeSignature(element: Function): string { + let signature = element.signature; + let parameters = new Array(); + for (let i = 0, k = signature.parameterTypes.length; i < k; ++i) { + parameters.push(`{"name":${quote(element.getParameterName(i))},"type":${this.makeType(unchecked(signature.parameterTypes[i]))}}`); + } + return `{"parameters":[${parameters.join(",")}],"requiredParameters":${signature.requiredParameters},"return":${this.makeType(signature.returnType)}}`; + } + + private makeType(type: Type): string { + if (type.isInternalReference) { + let clazz = type.getClassOrWrapper(this.program); + if (!clazz) return `{"kind":"internref","nullable":${type.is(TypeFlags.Nullable)}}`; + this.ensureType(clazz); + return `{"kind":"reference","id":${clazz.id},"nullable":${type.is(TypeFlags.Nullable)}}`; + } + let kind: string; + if (type == Type.void) kind = "void"; + else if (type == Type.bool) kind = "bool"; + else if (type == Type.i8) kind = "i8"; + else if (type == Type.u8) kind = "u8"; + else if (type == Type.i16) kind = "i16"; + else if (type == Type.u16) kind = "u16"; + else if (type == Type.i32 || type == Type.isize32) kind = "i32"; + else if (type == Type.u32 || type == Type.usize32) kind = "u32"; + else if (type == Type.i64 || type == Type.isize64) kind = "i64"; + else if (type == Type.u64 || type == Type.usize64) kind = "u64"; + else if (type == Type.f32) kind = "f32"; + else if (type == Type.f64) kind = "f64"; + else kind = "raw"; + return `{"kind":"${kind}"}`; + } + + private ensureType(clazz: Class): void { + if (this.types.has(clazz.id)) return; + this.types.set(clazz.id, "null"); + this.pendingTypes.push(clazz); + } + + private makeTypeDefinition(clazz: Class): void { + let program = this.program; + let definition: string; + if (clazz.extendsPrototype(program.arrayBufferInstance.prototype)) { + definition = `{"kind":"arraybuffer","id":${clazz.id}}`; + } else if (clazz.extendsPrototype(program.stringInstance.prototype)) { + definition = `{"kind":"string","id":${clazz.id}}`; + } else if (clazz.extendsPrototype(program.arrayPrototype)) { + let valueType = clazz.getArrayValueType(); + definition = `{"kind":"array","id":${clazz.id},"align":${valueType.alignLog2},"element":${this.makeType(valueType)}}`; + } else if (clazz.extendsPrototype(program.staticArrayPrototype)) { + let valueType = clazz.getArrayValueType(); + definition = `{"kind":"staticarray","id":${clazz.id},"align":${valueType.alignLog2},"element":${this.makeType(valueType)}}`; + } else if (clazz.extendsPrototype(program.arrayBufferViewInstance.prototype)) { + definition = `{"kind":"typedarray","id":${clazz.id},"constructor":${quote(typedArrayName(clazz))},"align":${clazz.getArrayValueType().alignLog2}}`; + } else if (isPlainObject(clazz)) { + let fields = new Array(); + let members = clazz.members; + if (members) { + for (let _names = Map_keys(members), i = 0, k = _names.length; i < k; ++i) { + let member = assert(members.get(unchecked(_names[i]))); + if (member.kind != ElementKind.PropertyPrototype) continue; + let property = (member).instance; + if (!property || !property.isField) continue; + fields.push(`{"name":${quote(property.name)},"offset":${property.memoryOffset},"type":${this.makeType(property.type)}}`); + } + } + definition = `{"kind":"record","id":${clazz.id},"size":${clazz.nextMemoryOffset},"fields":[${fields.join(",")}]}`; + } else { + definition = `{"kind":"internref","id":${clazz.id}}`; + } + this.types.set(clazz.id, definition); + } +} + +function typedArrayName(clazz: Class): string { + if (clazz.name == "Uint64Array") return "BigUint64Array"; + if (clazz.name == "Int64Array") return "BigInt64Array"; + return clazz.name; +} + +function isPlainObject(clazz: Class): bool { + if (clazz.base && !clazz.prototype.implicitlyExtendsObject) return false; + let members = clazz.members; + if (members) { + for (let _values = Map_values(members), i = 0, k = _values.length; i < k; ++i) { + let member = unchecked(_values[i]); + if (member.isAny(CommonFlags.Private | CommonFlags.Protected)) return false; + if (member.is(CommonFlags.Constructor) && member.declaration.range != Source.native.range) return false; + } + } + return true; +} + +function quote(value: string): string { + return `"${escapeString(value, CharCode.DoubleQuote)}"`; +} + +function signedString(value: i32): string { + return value < 0 ? "-" + (-value).toString() : value.toString(); +} diff --git a/src/index-wasm.ts b/src/index-wasm.ts index b448aa0ae4..60772ad28a 100644 --- a/src/index-wasm.ts +++ b/src/index-wasm.ts @@ -29,7 +29,8 @@ import { import { TSDBuilder, - JSBuilder + JSBuilder, + JSONBuilder } from "./bindings"; import { @@ -370,6 +371,11 @@ export function buildJS(program: Program, esm: bool): string { return JSBuilder.build(program, esm); } +/** Builds a declarative JSON host bindings schema for the specified program. */ +export function buildJSON(program: Program): string { + return JSONBuilder.build(program); +} + /** Gets the Binaryen module reference of a module. */ export function getBinaryenModuleRef(module: Module): usize { return module.ref; diff --git a/tests/bindings.js b/tests/bindings.js new file mode 100644 index 0000000000..9d47e1c726 --- /dev/null +++ b/tests/bindings.js @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import asc from "../dist/asc.js"; +import { instantiate, instantiateSync } from "../lib/bindings/index.js"; + +const source = ` +@external("host", "join") declare function hostJoin(a: string, b: string): string; +@external("host", "danger") +@external.js("globalThis.__bindingsCodeExecuted = true; return 99;") +declare function danger(): i32; + +export let text = "initial"; +export function add(a: i32, b: i32): i32 { return a + b; } +export function invert(value: bool): bool { return !value; } +export function unsigned(): u32 { return u32.MAX_VALUE; } +export function join(a: string, b: string = "!"): string { return hostJoin(a, b); } +export function callDanger(): i32 { return danger(); } +export function bytes(value: ArrayBuffer): ArrayBuffer { return value; } +export function array(value: Array): Array { return value; } +export function staticArray(value: StaticArray): StaticArray { return value; } +export function typedArray(value: Float32Array): Float32Array { return value; } + +class Pair { x: i32; y: string | null; } +export function pair(value: Pair): Pair { return value; } +`; + +const output = new Map(); +const { error, stderr } = await asc.main([ + "input.ts", + "--outFile", "module.wasm", + "--bindings", "json", + "--exportRuntime" +], { + readFile(name) { + return name === "input.ts" ? source : null; + }, + writeFile(name, contents) { + output.set(name, contents); + }, + listFiles() { + return []; + } +}); +if (error) throw Error(stderr.toString()); + +assert(output.has("module.wasm")); +assert(output.has("module.bindings.json")); +const binary = output.get("module.wasm"); +const schemaText = output.get("module.bindings.json"); +const schema = JSON.parse(schemaText); +const imports = { + env: { + abort(message, file, line, column) { + throw new Error(`abort: ${message} at ${file}:${line}:${column}`); + } + }, + host: { + join(a, b) { return a + b; }, + danger() { return 7; } + } +}; + +const result = await instantiate(binary, schema, imports); +assert(result.module instanceof WebAssembly.Module); +assert(result.instance instanceof WebAssembly.Instance); +assert.equal(result.exports.add(20, 22), 42); +assert.equal(result.exports.invert(true), false); +assert.equal(result.exports.unsigned(), 4294967295); +assert.equal(result.exports.join("hello"), "hello!"); +assert.equal(result.exports.callDanger(), 7); +assert.equal(globalThis.__bindingsCodeExecuted, undefined); +assert(!schemaText.includes("__bindingsCodeExecuted")); +assert.deepEqual(new Uint8Array(result.exports.bytes(new Uint8Array([1, 2, 3]).buffer)), new Uint8Array([1, 2, 3])); +assert.deepEqual(result.exports.array([1, -2, 3]), [1, -2, 3]); +assert.deepEqual(result.exports.staticArray([1, 65535]), [1, 65535]); +assert.deepEqual(result.exports.typedArray(new Float32Array([1.5, 2.5])), new Float32Array([1.5, 2.5])); +assert.deepEqual(result.exports.pair({ x: 7, y: "seven" }), { x: 7, y: "seven" }); +assert.equal(result.exports.text.value, "initial"); +result.exports.text.value = "changed"; +assert.equal(result.exports.text.value, "changed"); + +const sync = instantiateSync(binary, schemaText, imports); +assert.equal(sync.exports.join("sync", "."), "sync."); + +await assert.rejects(instantiate(binary, schema, { env: imports.env }), /missing import 'host\.join'/); +const inherited = Object.assign(Object.create({ host: imports.host }), { env: imports.env }); +await assert.rejects(instantiate(binary, schema, inherited), /missing import 'host\.join'/); +await assert.rejects(instantiate(binary, { ...schema, version: 2 }, imports), /unsupported AssemblyScript bindings schema/); +await assert.rejects(instantiate(binary, { ...schema, target: "wasm64" }, imports), /unsupported bindings target 'wasm64'/); + +console.log("JSON bindings integration tests passed");