Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lesnitsky opened this issue May 16, 2024 · 7 comments
Open

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

lesnitsky opened this issue May 16, 2024 · 7 comments
Labels
area-dart2wasm Issues for the dart2wasm compiler. type-enhancement A request for a change that isn't a bug

Comments

@lesnitsky
Copy link

lesnitsky commented May 16, 2024

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
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
Copy link
Member

mraleph commented May 16, 2024

cc @mkustermann @osa1

@lrhn lrhn added the area-dart2wasm Issues for the dart2wasm compiler. label May 16, 2024
@osa1
Copy link
Member

osa1 commented May 17, 2024

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

This is the same issue described in #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 osa1 closed this as completed May 17, 2024
@osa1 osa1 reopened this May 17, 2024
@osa1
Copy link
Member

osa1 commented May 17, 2024

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
Copy link
Author

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
Copy link
Author

lesnitsky commented May 17, 2024

Pragmas starting with wasm: are for internal use only so shouldn't be used. We will hide them from users soon (#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
Copy link
Member

@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 #55715 (comment)

@lesnitsky
Copy link
Author

@mkustermann Thanks! #55715 (comment) 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++;
  });
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-dart2wasm Issues for the dart2wasm compiler. type-enhancement A request for a change that isn't a bug
Projects
None yet
Development

No branches or pull requests

5 participants