Skip to content

Commit

Permalink
Implement support for JS that imports ESM modules (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
grossvogel authored May 28, 2024
2 parents f6acc7c + 235e8d8 commit 60cd567
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 24 deletions.
3 changes: 2 additions & 1 deletion lib/nodejs/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ defmodule NodeJS.Supervisor do
defp run_in_transaction(module, args, opts) do
binary = Keyword.get(opts, :binary, false)
timeout = Keyword.get(opts, :timeout, @timeout)
esm = Keyword.get(opts, :esm, module |> elem(0) |> to_string |> String.ends_with?(".mjs"))

func = fn pid ->
try do
GenServer.call(pid, {module, args, [binary: binary, timeout: timeout]}, timeout)
GenServer.call(pid, {module, args, [binary: binary, timeout: timeout, esm: esm]}, timeout)
catch
:exit, {:timeout, _} ->
{:error, "Call timed out."}
Expand Down
3 changes: 2 additions & 1 deletion lib/nodejs/worker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ defmodule NodeJS.Worker do
when is_tuple(module) do
timeout = Keyword.get(opts, :timeout)
binary = Keyword.get(opts, :binary)
body = Jason.encode!([Tuple.to_list(module), args])
esm = Keyword.get(opts, :esm, false)
body = Jason.encode!([Tuple.to_list(module), args, esm])
Port.command(port, "#{body}\n")

case get_response(~c"", timeout) do
Expand Down
52 changes: 31 additions & 21 deletions priv/server.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
const fs = require('node:fs/promises');
const path = require('path')
const readline = require('readline')
const WRITE_CHUNK_SIZE = parseInt(process.env.WRITE_CHUNK_SIZE, 10)

const WRITE_CHUNK_SIZE = parseInt(process.env.WRITE_CHUNK_SIZE, 10)
const NODE_PATHS = (process.env.NODE_PATH || '').split(path.delimiter).filter(Boolean)
const PREFIX = "__elixirnodejs__UOSBsDUP6bp9IF5__";

async function fileExists(file) {
return await fs.access(file, fs.constants.R_OK).then(() => true).catch(() => false);
}

function requireModule(modulePath) {
// When not running in production mode, refresh the cache on each call.
if (process.env.NODE_ENV !== 'production') {
Expand All @@ -13,6 +19,23 @@ function requireModule(modulePath) {
return require(modulePath)
}

async function importModuleRespectingNodePath(modulePath) {
// to be compatible with cjs require, we simulate resolution using NODE_PATH
for(const nodePath of NODE_PATHS) {
// Try to resolve the module in the current path
const modulePathToTry = path.join(nodePath, modulePath)
if (fileExists(modulePathToTry)) {
// imports are cached. To bust that cache, add unique query string to module name
// eg NodeJS.call({"esm-module.mjs?q=#{System.unique_integer()}", :fn})
// it will leak memory, so I'm not doing it by default!
// see more: https://ar.al/2021/02/22/cache-busting-in-node.js-dynamic-esm-imports/#cache-invalidation-in-esm-with-dynamic-imports
return await import(modulePathToTry)
}
}

throw new Error(`Could not find module '${modulePath}'. Hint: File extensions are required in ESM. Tried ${NODE_PATHS.join(", ")}`)
}

function getAncestor(parent, [key, ...keys]) {
if (typeof key === 'undefined') {
return parent
Expand All @@ -21,28 +44,15 @@ function getAncestor(parent, [key, ...keys]) {
return getAncestor(parent[key], keys)
}

function requireModuleFunction([modulePath, ...keys]) {
const mod = requireModule(modulePath)

return getAncestor(mod, keys)
}

async function callModuleFunction(moduleFunction, args) {
const fn = requireModuleFunction(moduleFunction)
const returnValue = fn(...args)

if (returnValue instanceof Promise) {
return await returnValue
}

return returnValue
}

async function getResponse(string) {
try {
const [moduleFunction, args] = JSON.parse(string)
const result = await callModuleFunction(moduleFunction, args)

const [[modulePath, ...keys], args, useImport] = JSON.parse(string)
const importFn = useImport ? importModuleRespectingNodePath : requireModule
const mod = await importFn(modulePath)
const fn = await getAncestor(mod, keys)
if (!fn) throw new Error(`Could not find function '${keys.join(".")}' in module '${modulePath}'`)
const returnValue = fn(...args)
const result = returnValue instanceof Promise ? await returnValue : returnValue
return JSON.stringify([true, result])
} catch ({ message, stack }) {
return JSON.stringify([false, `${message}\n${stack}`])
Expand Down
3 changes: 3 additions & 0 deletions test/js/esm-module-invalid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require('uuid/v4')

export default false
13 changes: 13 additions & 0 deletions test/js/esm-module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export { v4 as uuid } from 'uuid'

export function hello(name) {
return `Hello, ${name}!`
}

export function add(a, b) {
return a + b
}

export async function echo(x, delay = 1000) {
return new Promise((resolve) => setTimeout(() => resolve(x), delay))
}
38 changes: 37 additions & 1 deletion test/nodejs_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ defmodule NodeJS.Test do

test "function does not exist" do
assert {:error, msg} = NodeJS.call({"keyed-functions", :idontexist})
assert js_error_message(msg) === "TypeError: fn is not a function"

assert js_error_message(msg) ===
"Error: Could not find function 'idontexist' in module 'keyed-functions'"
end

test "object does not exist" do
Expand Down Expand Up @@ -220,4 +222,38 @@ defmodule NodeJS.Test do
assert {:ok, 42} = NodeJS.call({"keyed-functions", :logsSomething}, [])
end
end

describe "importing esm module" do
test "works if module is available in path" do
result = NodeJS.call({"./esm-module.mjs", :hello}, ["world"], esm: true)
assert {:ok, "Hello, world!"} = result
end

test "can import exported library function" do
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :uuid}, [], esm: true)
end

test "using mjs extension makes esm: true obsolete" do
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :uuid})
end

test "returned promises are resolved" do
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :echo}, ["1"])
end

test "fails if extension is not specified" do
assert {:error, msg} = NodeJS.call({"esm-module", :hello}, ["me"], esm: true)
assert js_error_message(msg) =~ "Cannot find module"
end

test "fails if file not found" do
assert {:error, msg} = NodeJS.call({"nonexisting.js", :hello}, [], esm: true)
assert js_error_message(msg) =~ "Cannot find module"
end

test "fails if file has errors" do
assert {:error, msg} = NodeJS.call({"esm-module-invalid.mjs", :hello})
assert js_error_message(msg) =~ "ReferenceError: require is not defined in ES module scope"
end
end
end

0 comments on commit 60cd567

Please sign in to comment.