dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.22k stars 1.57k forks source link

Make it easier to run dart compiled to wasm in node.js #55744

Open lesnitsky opened 5 months ago

lesnitsky commented 5 months ago

I'm trying to run dart compiled to wasm in node.js:

Dart version: Dart SDK version: 3.4.0 (stable) (Mon May 6 07:59:58 2024 -0700) on "macos_arm64" Node version: v22.2.0

Dart code (dart_wasm_node.dart):

import 'dart:async';

@pragma('wasm:import', 'host.sayHi')
external void sayHi(String message);

void main() async {
  var iterations = 0;
  Timer.periodic(Duration(seconds: 1), (timer) async {
    sayHi('$iterations');

    if (iterations == 5) {
      timer.cancel();
    }

    iterations++;
  });
}

Compilation:

dart compile wasm lib/dart_wasm_node.dart

Node.js code (run.mjs)

import fs from "node:fs/promises";
import { instantiate, invoke } from "./lib/dart_wasm_node.mjs";

const imports = {
  host: {
    sayHi: (message) => console.log(message),
  },
};

const wasmBufferPromise = fs.readFile("./lib/dart_wasm_node.wasm");
const instantiated = await instantiate(wasmBufferPromise, imports);

invoke(instantiated.instance);
dart_wasm_node.mjs ```js let buildArgsList; // `modulePromise` is a promise to the `WebAssembly.module` object to be // instantiated. // `importObjectPromise` is a promise to an object that contains any additional // imports needed by the module that aren't provided by the standard runtime. // The fields on this object will be merged into the importObject with which // the module will be instantiated. // This function returns a promise to the instantiated module. export const instantiate = async (modulePromise, importObjectPromise) => { let dartInstance; function stringFromDartString(string) { const totalLength = dartInstance.exports.$stringLength(string); let result = ''; let index = 0; while (index < totalLength) { let chunkLength = Math.min(totalLength - index, 0xFFFF); const array = new Array(chunkLength); for (let i = 0; i < chunkLength; i++) { array[i] = dartInstance.exports.$stringRead(string, index++); } result += String.fromCharCode(...array); } return result; } function stringToDartString(string) { const length = string.length; let range = 0; for (let i = 0; i < length; i++) { range |= string.codePointAt(i); } if (range < 256) { const dartString = dartInstance.exports.$stringAllocate1(length); for (let i = 0; i < length; i++) { dartInstance.exports.$stringWrite1(dartString, i, string.codePointAt(i)); } return dartString; } else { const dartString = dartInstance.exports.$stringAllocate2(length); for (let i = 0; i < length; i++) { dartInstance.exports.$stringWrite2(dartString, i, string.charCodeAt(i)); } return dartString; } } // Prints to the console function printToConsole(value) { if (typeof dartPrint == "function") { dartPrint(value); return; } if (typeof console == "object" && typeof console.log != "undefined") { console.log(value); return; } if (typeof print == "function") { print(value); return; } throw "Unable to print message: " + js; } // Converts a Dart List to a JS array. Any Dart objects will be converted, but // this will be cheap for JSValues. function arrayFromDartList(constructor, list) { const length = dartInstance.exports.$listLength(list); const array = new constructor(length); for (let i = 0; i < length; i++) { array[i] = dartInstance.exports.$listRead(list, i); } return array; } buildArgsList = function(list) { const dartList = dartInstance.exports.$makeStringList(); for (let i = 0; i < list.length; i++) { dartInstance.exports.$listAdd(dartList, stringToDartString(list[i])); } return dartList; } // A special symbol attached to functions that wrap Dart functions. const jsWrappedDartFunctionSymbol = Symbol("JSWrappedDartFunction"); function finalizeWrapper(dartFunction, wrapped) { wrapped.dartFunction = dartFunction; wrapped[jsWrappedDartFunctionSymbol] = true; return wrapped; } // Imports const dart2wasm = { _48: v => stringToDartString(v.toString()), _63: () => { let stackString = new Error().stack.toString(); let frames = stackString.split('\n'); let drop = 2; if (frames[0] === 'Error') { drop += 1; } return frames.slice(drop).join('\n'); }, _72: s => stringToDartString(JSON.stringify(stringFromDartString(s))), _73: s => printToConsole(stringFromDartString(s)), _89: (ms, c) => setInterval(() => dartInstance.exports.$invokeCallback(c), ms), _90: (handle) => clearInterval(handle), _91: (c) => queueMicrotask(() => dartInstance.exports.$invokeCallback(c)), _92: () => Date.now(), _93: (a, i) => a.push(i), _104: a => a.length, _106: (a, i) => a[i], _107: (a, i, v) => a[i] = v, _109: a => a.join(''), _119: (s, p, i) => s.indexOf(p, i), _122: (o, start, length) => new Uint8Array(o.buffer, o.byteOffset + start, length), _123: (o, start, length) => new Int8Array(o.buffer, o.byteOffset + start, length), _124: (o, start, length) => new Uint8ClampedArray(o.buffer, o.byteOffset + start, length), _125: (o, start, length) => new Uint16Array(o.buffer, o.byteOffset + start, length), _126: (o, start, length) => new Int16Array(o.buffer, o.byteOffset + start, length), _127: (o, start, length) => new Uint32Array(o.buffer, o.byteOffset + start, length), _128: (o, start, length) => new Int32Array(o.buffer, o.byteOffset + start, length), _131: (o, start, length) => new Float32Array(o.buffer, o.byteOffset + start, length), _132: (o, start, length) => new Float64Array(o.buffer, o.byteOffset + start, length), _136: (o) => new DataView(o.buffer, o.byteOffset, o.byteLength), _140: Function.prototype.call.bind(Object.getOwnPropertyDescriptor(DataView.prototype, 'byteLength').get), _141: (b, o) => new DataView(b, o), _143: Function.prototype.call.bind(DataView.prototype.getUint8), _145: Function.prototype.call.bind(DataView.prototype.getInt8), _147: Function.prototype.call.bind(DataView.prototype.getUint16), _149: Function.prototype.call.bind(DataView.prototype.getInt16), _151: Function.prototype.call.bind(DataView.prototype.getUint32), _153: Function.prototype.call.bind(DataView.prototype.getInt32), _159: Function.prototype.call.bind(DataView.prototype.getFloat32), _161: Function.prototype.call.bind(DataView.prototype.getFloat64), _182: o => o === undefined, _183: o => typeof o === 'boolean', _184: o => typeof o === 'number', _186: o => typeof o === 'string', _189: o => o instanceof Int8Array, _190: o => o instanceof Uint8Array, _191: o => o instanceof Uint8ClampedArray, _192: o => o instanceof Int16Array, _193: o => o instanceof Uint16Array, _194: o => o instanceof Int32Array, _195: o => o instanceof Uint32Array, _196: o => o instanceof Float32Array, _197: o => o instanceof Float64Array, _198: o => o instanceof ArrayBuffer, _199: o => o instanceof DataView, _200: o => o instanceof Array, _201: o => typeof o === 'function' && o[jsWrappedDartFunctionSymbol] === true, _205: (l, r) => l === r, _206: o => o, _207: o => o, _208: o => o, _209: b => !!b, _210: o => o.length, _213: (o, i) => o[i], _214: f => f.dartFunction, _215: l => arrayFromDartList(Int8Array, l), _216: l => arrayFromDartList(Uint8Array, l), _217: l => arrayFromDartList(Uint8ClampedArray, l), _218: l => arrayFromDartList(Int16Array, l), _219: l => arrayFromDartList(Uint16Array, l), _220: l => arrayFromDartList(Int32Array, l), _221: l => arrayFromDartList(Uint32Array, l), _222: l => arrayFromDartList(Float32Array, l), _223: l => arrayFromDartList(Float64Array, l), _224: (data, length) => { const view = new DataView(new ArrayBuffer(length)); for (let i = 0; i < length; i++) { view.setUint8(i, dartInstance.exports.$byteDataGetUint8(data, i)); } return view; }, _225: l => arrayFromDartList(Array, l), _226: stringFromDartString, _227: stringToDartString, _230: l => new Array(l), _234: (o, p) => o[p], _238: o => String(o) }; const baseImports = { dart2wasm: dart2wasm, Math: Math, Date: Date, Object: Object, Array: Array, Reflect: Reflect, }; const jsStringPolyfill = { "charCodeAt": (s, i) => s.charCodeAt(i), "compare": (s1, s2) => { if (s1 < s2) return -1; if (s1 > s2) return 1; return 0; }, "concat": (s1, s2) => s1 + s2, "equals": (s1, s2) => s1 === s2, "fromCharCode": (i) => String.fromCharCode(i), "length": (s) => s.length, "substring": (s, a, b) => s.substring(a, b), }; dartInstance = await WebAssembly.instantiate(await modulePromise, { ...baseImports, ...(await importObjectPromise), "wasm:js-string": jsStringPolyfill, }); return dartInstance; } // Call the main function for the instantiated module // `moduleInstance` is the instantiated dart2wasm module // `args` are any arguments that should be passed into the main function. export const invoke = (moduleInstance, ...args) => { const dartMain = moduleInstance.exports.$getMain(); const dartArgs = buildArgsList(args); moduleInstance.exports.$invokeMain(dartMain, dartArgs); } ```

node run.mjs

file:///Users/lesnitsky/w/lesnitsky/wasm_experiment/lib/dart_wasm_node.mjs:79
        const dartList = dartInstance.exports.$makeStringList();
                                              ^

TypeError: Cannot read properties of undefined (reading '$makeStringList')
    at buildArgsList (file:///Users/lesnitsky/w/lesnitsky/wasm_experiment/lib/dart_wasm_node.mjs:79:47)
    at invoke (file:///Users/lesnitsky/w/lesnitsky/wasm_experiment/lib/dart_wasm_node.mjs:230:22)
    at file:///Users/lesnitsky/w/lesnitsky/wasm_experiment/run.mjs:13:1

Node.js v22.2.0

Lines 216-220 of generated dart_wasm_node.mjs

dartInstance = await WebAssembly.instantiate(await modulePromise, {
        ...baseImports,
        ...(await importObjectPromise),
        "wasm:js-string": jsStringPolyfill,
    });

As per MDN docs for WebAssembly.instantiate:

Return value

A Promise that resolves to a ResultObject which contains two fields:

Question

Is this a bug? Why ResultObject is assigned to dartInstance, not resultObject.instance?

Fix (?)

    const r = await WebAssembly.instantiate(await modulePromise, {
        ...baseImports,
        ...(await importObjectPromise),
        "wasm:js-string": jsStringPolyfill,
    });

    dartInstance = r.instance;

    return r;

Running again:

node run.mjs

[Object: null prototype] {}
[Object: null prototype] {}
[Object: null prototype] {}
[Object: null prototype] {}
[Object: null prototype] {}
[Object: null prototype] {}

Yay! Dart wasm is executed, but returned value is not a string.

Question?

Did I do something wrong here?

@pragma('wasm:import', 'host.sayHi')
external void sayHi(String message);

Fix (?)

I'm able to get an actual string if I manually copy stringFromDartString from dart_wasm_node.js

This is the final run.mjs:

import fs from "node:fs/promises";
import { instantiate, invoke } from "./lib/dart_wasm_node.mjs";

let dartInstance;

function stringFromDartString(string) {
  const totalLength = dartInstance.exports.$stringLength(string);
  let result = "";
  let index = 0;
  while (index < totalLength) {
    let chunkLength = Math.min(totalLength - index, 0xffff);
    const array = new Array(chunkLength);
    for (let i = 0; i < chunkLength; i++) {
      array[i] = dartInstance.exports.$stringRead(string, index++);
    }
    result += String.fromCharCode(...array);
  }
  return result;
}

const imports = {
  host: {
    sayHi: (message) => console.log(stringFromDartString(message)),
  },
};

const wasmBufferPromise = fs.readFile("./lib/dart_wasm_node.wasm");
const instantiated = await instantiate(wasmBufferPromise, imports);

dartInstance = instantiated.instance;

invoke(instantiated.instance);
mraleph commented 5 months ago

cc @mkustermann @osa1

osa1 commented 5 months ago

Is this a bug? Why ResultObject is assigned to dartInstance, not resultObject.instance?

This is the same issue described in https://github.com/dart-lang/sdk/pull/55412. WebAssembly.instantiate confusingly returns different types of values depending on the types of the arguments. In the generated .mjs we're expecting the module to be compiled with the "secondary overload": https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiate_static#secondary_overload_%E2%80%94_taking_a_module_object_instance

I.e. you need to pass a WebAssembly.Module (instead of the binary for a Wasm module) to the instantiate function exported by the .mjs file.

Did I do something wrong here?

Pragmas starting with wasm: are for internal use only so shouldn't be used. We will hide them from users soon (#55733).

You should use js_interop: https://dart.dev/interop/js-interop/usage

With js_interop you should be able to just pass a JS string and it should work.

Closing as there isn't a bug here. (we have a tracking issue for hiding the wasm pragmas)

osa1 commented 5 months ago

I missed the part that this is about supporting node.js. I think that's up to the product team to decide. Reopened the issue.

lesnitsky commented 5 months ago

I.e. you need to pass a WebAssembly.Module (instead of the binary for a Wasm module) to the instantiate function exported by the .mjs file.

Thanks, didn't know that, passing compiled module indeed works

You should use js_interop: https://dart.dev/interop/js-interop/usage

This also worked, so with my run.mjs updated to

import fs from "node:fs/promises";
import { instantiate, invoke } from "./lib/dart_wasm_node.mjs";

global.sayHi = (msg) => {
  console.log(msg);
};

const wasmBufferPromise = await fs.readFile("./lib/dart_wasm_node.wasm");
const wasmModule = await WebAssembly.compile(wasmBufferPromise);
const instance = await instantiate(wasmModule);

invoke(instance);

and dart_wasm_node.dart to:

import 'dart:async';
import 'dart:js_interop';

@JS()
external void sayHi(String message);

void main() async {
  var iterations = 0;
  Timer.periodic(Duration(seconds: 1), (timer) async {
    sayHi('$iterations');

    if (iterations == 5) {
      timer.cancel();
    }

    iterations++;
  });
}

everything works as expected 🎉

I missed the part that this is about supporting node.js. I think that's up to the product team to decide.

Not sure if anything else is required, so I'll leave it up to you to decide whether this issue should be kept open.

lesnitsky commented 5 months ago

Pragmas starting with wasm: are for internal use only so shouldn't be used. We will hide them from users soon (https://github.com/dart-lang/sdk/issues/55733).

What would be an alternative for wasm:export in js_interop land? Can't find anything in docs that would help me call something in dart from node.

Something along these lines:

@pragma('wasm:export', 'receive')
void receive(String message) {
  print('Received: $message');
}
const wasmBufferPromise = await fs.readFile("./lib/dart_wasm_node.wasm");
const wasmModule = await WebAssembly.compile(wasmBufferPromise);
const instance = await instantiate(wasmModule);

instance.exports.receive(stringToDartString(msg));
mkustermann commented 5 months ago

@lesnitsky The instance.exports are exports of wasm functions - but we don't support that "officially" yet (we'll hide it soon and expose a proper mechanism with sufficient checks later on). Using JS interop you can convert dart functions to JS wrapper functions which can be called from JS. One way is to make the Dart code convert dart functions to JSFunction and making them available to JS via setting properties (e.g. on globalThis).

See e.g. the example here https://github.com/dart-lang/sdk/issues/55715#issuecomment-2110629525

lesnitsky commented 5 months ago

@mkustermann Thanks! https://github.com/dart-lang/sdk/issues/55715#issuecomment-2110629525 helped.

import fs from "node:fs/promises";
import { instantiate, invoke } from "./lib/dart_wasm_node.mjs";

const wasmBuffer = await fs.readFile("./lib/dart_wasm_node.wasm");
const wasmModule = await WebAssembly.compile(wasmBuffer);
const instance = await instantiate(wasmModule);

invoke(instance);

global.onDartMessage = (msg) => {
  console.log(msg);
  onJSMessage("hello from js");
};
import 'dart:async';
import 'dart:js_interop';

@JS()
external void onDartMessage(String message);

@JS()
external set onJSMessage(JSFunction handler);

void handler(String message) {
  print(message);
}

void main() async {
  onJSMessage = handler.toJS;

  var iterations = 0;

  Timer.periodic(Duration(seconds: 1), (timer) async {
    onDartMessage('hello from dart');

    if (iterations == 5) timer.cancel();
    iterations++;
  });
}