Skip to content

Commit 60cd567

Browse files
authored
Implement support for JS that imports ESM modules (revelrylabs#84)
2 parents f6acc7c + 235e8d8 commit 60cd567

6 files changed

Lines changed: 88 additions & 24 deletions

File tree

lib/nodejs/supervisor.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ defmodule NodeJS.Supervisor do
3232
defp run_in_transaction(module, args, opts) do
3333
binary = Keyword.get(opts, :binary, false)
3434
timeout = Keyword.get(opts, :timeout, @timeout)
35+
esm = Keyword.get(opts, :esm, module |> elem(0) |> to_string |> String.ends_with?(".mjs"))
3536

3637
func = fn pid ->
3738
try do
38-
GenServer.call(pid, {module, args, [binary: binary, timeout: timeout]}, timeout)
39+
GenServer.call(pid, {module, args, [binary: binary, timeout: timeout, esm: esm]}, timeout)
3940
catch
4041
:exit, {:timeout, _} ->
4142
{:error, "Call timed out."}

lib/nodejs/worker.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ defmodule NodeJS.Worker do
9898
when is_tuple(module) do
9999
timeout = Keyword.get(opts, :timeout)
100100
binary = Keyword.get(opts, :binary)
101-
body = Jason.encode!([Tuple.to_list(module), args])
101+
esm = Keyword.get(opts, :esm, false)
102+
body = Jason.encode!([Tuple.to_list(module), args, esm])
102103
Port.command(port, "#{body}\n")
103104

104105
case get_response(~c"", timeout) do

priv/server.js

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
const fs = require('node:fs/promises');
12
const path = require('path')
23
const readline = require('readline')
3-
const WRITE_CHUNK_SIZE = parseInt(process.env.WRITE_CHUNK_SIZE, 10)
44

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

9+
async function fileExists(file) {
10+
return await fs.access(file, fs.constants.R_OK).then(() => true).catch(() => false);
11+
}
12+
713
function requireModule(modulePath) {
814
// When not running in production mode, refresh the cache on each call.
915
if (process.env.NODE_ENV !== 'production') {
@@ -13,6 +19,23 @@ function requireModule(modulePath) {
1319
return require(modulePath)
1420
}
1521

22+
async function importModuleRespectingNodePath(modulePath) {
23+
// to be compatible with cjs require, we simulate resolution using NODE_PATH
24+
for(const nodePath of NODE_PATHS) {
25+
// Try to resolve the module in the current path
26+
const modulePathToTry = path.join(nodePath, modulePath)
27+
if (fileExists(modulePathToTry)) {
28+
// imports are cached. To bust that cache, add unique query string to module name
29+
// eg NodeJS.call({"esm-module.mjs?q=#{System.unique_integer()}", :fn})
30+
// it will leak memory, so I'm not doing it by default!
31+
// see more: https://ar.al/2021/02/22/cache-busting-in-node.js-dynamic-esm-imports/#cache-invalidation-in-esm-with-dynamic-imports
32+
return await import(modulePathToTry)
33+
}
34+
}
35+
36+
throw new Error(`Could not find module '${modulePath}'. Hint: File extensions are required in ESM. Tried ${NODE_PATHS.join(", ")}`)
37+
}
38+
1639
function getAncestor(parent, [key, ...keys]) {
1740
if (typeof key === 'undefined') {
1841
return parent
@@ -21,28 +44,15 @@ function getAncestor(parent, [key, ...keys]) {
2144
return getAncestor(parent[key], keys)
2245
}
2346

24-
function requireModuleFunction([modulePath, ...keys]) {
25-
const mod = requireModule(modulePath)
26-
27-
return getAncestor(mod, keys)
28-
}
29-
30-
async function callModuleFunction(moduleFunction, args) {
31-
const fn = requireModuleFunction(moduleFunction)
32-
const returnValue = fn(...args)
33-
34-
if (returnValue instanceof Promise) {
35-
return await returnValue
36-
}
37-
38-
return returnValue
39-
}
40-
4147
async function getResponse(string) {
4248
try {
43-
const [moduleFunction, args] = JSON.parse(string)
44-
const result = await callModuleFunction(moduleFunction, args)
45-
49+
const [[modulePath, ...keys], args, useImport] = JSON.parse(string)
50+
const importFn = useImport ? importModuleRespectingNodePath : requireModule
51+
const mod = await importFn(modulePath)
52+
const fn = await getAncestor(mod, keys)
53+
if (!fn) throw new Error(`Could not find function '${keys.join(".")}' in module '${modulePath}'`)
54+
const returnValue = fn(...args)
55+
const result = returnValue instanceof Promise ? await returnValue : returnValue
4656
return JSON.stringify([true, result])
4757
} catch ({ message, stack }) {
4858
return JSON.stringify([false, `${message}\n${stack}`])

test/js/esm-module-invalid.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
require('uuid/v4')
2+
3+
export default false

test/js/esm-module.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export { v4 as uuid } from 'uuid'
2+
3+
export function hello(name) {
4+
return `Hello, ${name}!`
5+
}
6+
7+
export function add(a, b) {
8+
return a + b
9+
}
10+
11+
export async function echo(x, delay = 1000) {
12+
return new Promise((resolve) => setTimeout(() => resolve(x), delay))
13+
}

test/nodejs_test.exs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ defmodule NodeJS.Test do
8181

8282
test "function does not exist" do
8383
assert {:error, msg} = NodeJS.call({"keyed-functions", :idontexist})
84-
assert js_error_message(msg) === "TypeError: fn is not a function"
84+
85+
assert js_error_message(msg) ===
86+
"Error: Could not find function 'idontexist' in module 'keyed-functions'"
8587
end
8688

8789
test "object does not exist" do
@@ -220,4 +222,38 @@ defmodule NodeJS.Test do
220222
assert {:ok, 42} = NodeJS.call({"keyed-functions", :logsSomething}, [])
221223
end
222224
end
225+
226+
describe "importing esm module" do
227+
test "works if module is available in path" do
228+
result = NodeJS.call({"./esm-module.mjs", :hello}, ["world"], esm: true)
229+
assert {:ok, "Hello, world!"} = result
230+
end
231+
232+
test "can import exported library function" do
233+
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :uuid}, [], esm: true)
234+
end
235+
236+
test "using mjs extension makes esm: true obsolete" do
237+
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :uuid})
238+
end
239+
240+
test "returned promises are resolved" do
241+
assert {:ok, _uuid} = NodeJS.call({"esm-module.mjs", :echo}, ["1"])
242+
end
243+
244+
test "fails if extension is not specified" do
245+
assert {:error, msg} = NodeJS.call({"esm-module", :hello}, ["me"], esm: true)
246+
assert js_error_message(msg) =~ "Cannot find module"
247+
end
248+
249+
test "fails if file not found" do
250+
assert {:error, msg} = NodeJS.call({"nonexisting.js", :hello}, [], esm: true)
251+
assert js_error_message(msg) =~ "Cannot find module"
252+
end
253+
254+
test "fails if file has errors" do
255+
assert {:error, msg} = NodeJS.call({"esm-module-invalid.mjs", :hello})
256+
assert js_error_message(msg) =~ "ReferenceError: require is not defined in ES module scope"
257+
end
258+
end
223259
end

0 commit comments

Comments
 (0)