├── .formatter.exs ├── .github ├── .dependabot.yml └── workflows │ └── test-and-deploy.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── nodejs.ex └── nodejs │ ├── error.ex │ ├── supervisor.ex │ └── worker.ex ├── mix.exs ├── mix.lock ├── priv ├── package.json └── server.js └── test ├── js ├── .gitignore ├── default-function-echo.js ├── esm-module-invalid.mjs ├── esm-module.mjs ├── keyed-functions.js ├── package.json ├── slow-async-echo.js ├── subdirectory │ └── index.js └── terminal-test.js ├── nodejs_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/.dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | open-pull-requests-limit: 5 6 | - package-ecosystem: "npm" 7 | directory: "/test/js" 8 | open-pull-requests-limit: 5 9 | -------------------------------------------------------------------------------- /.github/workflows/test-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test and Deploy 2 | on: 3 | push: 4 | release: 5 | types: [published] 6 | jobs: 7 | test-and-deploy: 8 | runs-on: ubuntu-22.04 9 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 10 | env: 11 | MIX_ENV: test 12 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 13 | strategy: 14 | matrix: 15 | include: 16 | - otp: "24" 17 | elixir: "1.12" 18 | nodejs: "18.x" 19 | - otp: "26" 20 | elixir: "1.16" 21 | nodejs: "20.x" 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: erlef/setup-beam@v1 25 | with: 26 | otp-version: ${{matrix.otp}} 27 | elixir-version: ${{matrix.elixir}} 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{matrix.nodejs}} 31 | - run: mix deps.get 32 | - run: mix compile 33 | - run: npm install --prefix=test/js 34 | - run: mix test 35 | - if: matrix.elixir == '1.16' 36 | run: mix format --check-formatted 37 | - name: Deploy to Hex 38 | if: ${{ github.event_name == 'release' && matrix.elixir == '1.16' }} 39 | run: mix hex.publish --yes 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | node-*.tar 24 | 25 | .elixir_ls 26 | 27 | /node_modules/ 28 | package-lock.json 29 | 30 | app -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.1.2 2 | elixir 1.16.0-otp-26 3 | nodejs 20.1.0 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.1.3] 9 | 10 | ### Added 11 | - Added `debug_mode` configuration option to handle Node.js stdout/stderr messages 12 | - Implemented proper `handle_info/2` callback to handle messages from Node.js processes 13 | - Added safeguards to reset_terminal to prevent errors during termination with invalid ports 14 | 15 | ### Fixed 16 | - Fixed "unexpected message in handle_info/2" errors when Node.js emits debug messages 17 | - Fixed potential crashes during termination when a port becomes invalid 18 | 19 | ### Contributors 20 | - @francois-codes for the initial implementation 21 | - Revelry team for refinements 22 | 23 | ## [3.1.2] 24 | 25 | ### Changed 26 | - fix #90 terminal corruption when running inside an iEx session 27 | 28 | ### Contributors 29 | - @francois-codes for the fix 30 | - @mrdotb and @Valian for contributing to the discussion 31 | 32 | 33 | ## [3.1.1] 34 | 35 | ### Changed 36 | - add a minimal `package.json` alongside `server.js` to work with projects that specify a module type other than `commonjs` 37 | 38 | ### Contributors 39 | - @Valian 40 | 41 | 42 | ## [3.1.0] 43 | 44 | ### Changed 45 | - add support for JS that imports ESM modules (#84) 46 | 47 | ### Contributors 48 | - @Valian 49 | 50 | 51 | ## [3.0.0] 52 | 53 | This version is mainly a maintenance release to get all of the tooling and required language versions up-to-date so we can begin merging more substantive fixes and iterating on functionality. 54 | 55 | ### Changed 56 | - update language support minimums to Elixir 1.12, OTP 24, and Node 18 57 | - format code with the latest `mix format` settings 58 | - replace Travis CI with GitHub Actions for CI/CD 59 | - add `.dependabot.yml` config file 60 | - remove coverage reporting 61 | - upgrade dependencies 62 | 63 | ### Fixed 64 | - fixed test error due to JS TypeError format change 65 | 66 | ### Contributors 67 | - @quentin-bettoum 68 | 69 | 70 | ## [2.0.0] 71 | 72 | ### Added 73 | - support for GenServer name registration to support multiple supervisors 74 | 75 | ### Changed 76 | - updated Elixir requirements to 1.7 77 | 78 | ### Fixed 79 | - `Task.async` and `Task.await` caller leaks with timeouts and worker crash 80 | - `console.*` calls in JavaScript code no longer causes workers to crash 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Revelry Labs LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 5 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 6 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions 9 | of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 12 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 13 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 14 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 15 | DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodeJS 2 | 3 | [![Build Status](https://travis-ci.org/revelrylabs/elixir-nodejs.svg?branch=master)](https://travis-ci.org/revelrylabs/elixir-nodejs) 4 | [![Hex.pm](https://img.shields.io/hexpm/dt/nodejs.svg)](https://hex.pm/packages/nodejs) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Coverage Status](https://opencov.prod.revelry.net/projects/14/badge.svg)](https://opencov.prod.revelry.net/projects/14) 7 | 8 | Provides an Elixir API for calling Node.js functions. 9 | 10 | ## Documentation 11 | 12 | The docs can 13 | be found at [https://hexdocs.pm/nodejs](https://hexdocs.pm/nodejs). 14 | 15 | ## Prerequisites 16 | 17 | - Elixir >= 1.7 18 | - NodeJS >= 10 19 | 20 | ## Installation 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:nodejs, "~> 2.0"} 26 | ] 27 | end 28 | ``` 29 | 30 | ## Starting the service 31 | 32 | Add `NodeJS` to your Supervisor as a child, pointing the required `path` option at the 33 | directory containing your JavaScript modules. 34 | 35 | ```elixir 36 | supervisor(NodeJS, [[path: "/node_app_root", pool_size: 4]]) 37 | ``` 38 | 39 | ### Debug Mode 40 | 41 | When working with Node.js applications, you may encounter debug messages or warnings from the Node.js runtime, especially when using inspector or debugging tools. To properly handle these messages: 42 | 43 | ```elixir 44 | # In your config/dev.exs or other appropriate config file 45 | config :nodejs, debug_mode: true 46 | ``` 47 | 48 | When `debug_mode` is enabled: 49 | - Node.js stdout/stderr messages will be logged at the info level 50 | - Messages like "Debugger listening on..." will not cause errors 51 | - All Node.js processes will log their output through Elixir's Logger 52 | 53 | This is particularly useful during development or when debugging Node.js integration issues. 54 | 55 | ### Calling JavaScript module functions with `NodeJS.call(module, args \\ [])`. 56 | 57 | If the module exports a function directly, like this: 58 | 59 | ```javascript 60 | module.exports = (x) => x 61 | ``` 62 | 63 | You can call it like this: 64 | 65 | ```elixir 66 | NodeJS.call("echo", ["hello"]) #=> {:ok, "hello"} 67 | ``` 68 | 69 | There is also a `call!` form that throws on error instead of returning a tuple: 70 | 71 | ```elixir 72 | NodeJS.call!("echo", ["hello"]) #=> "hello" 73 | ``` 74 | 75 | If the module exports an object with named functions like: 76 | 77 | ```javascript 78 | exports.add = (a, b) => a + b 79 | exports.sub = (a, b) => a - b 80 | ``` 81 | 82 | You can call them like this: 83 | 84 | ```elixir 85 | NodeJS.call({"math", :add}, [1, 2]) # => {:ok, 3} 86 | NodeJS.call({"math", :sub}, [1, 2]) # => {:ok, -1} 87 | ``` 88 | 89 | In order to cope with Unicode character it is necessary to specify the `binary` option: 90 | 91 | ```elixir 92 | NodeJS.call("echo", ["’"], binary: true) # => {:ok, "’"} 93 | ``` 94 | 95 | ### There Are Rules & Limitations (Unfortunately) 96 | 97 | - Function arguments must be serializable to JSON. 98 | - Return values must be serializable to JSON. (Objects with circular references will definitely fail.) 99 | - Modules must be requested relative to the `path` that was given to the `Supervisor`. 100 | E.g., for a `path` of `/node_app_root` and a file `/node_app_root/foo/index.js` your module request should be for `"foo/index.js"` or `"foo/index"` or `"foo"`. 101 | 102 | ### Running the tests 103 | 104 | Since the test suite requires npm dependencies before you can run the tests you will first need to run 105 | 106 | ```bash 107 | cd test/js && npm install && cd ../.. 108 | ``` 109 | 110 | After that you should be able to run 111 | 112 | ```bash 113 | mix test 114 | ``` 115 | 116 | ### Handling Callbacks and Promises 117 | 118 | You can see examples of using promises in the tests here: 119 | 120 | https://github.com/revelrylabs/elixir-nodejs/blob/master/test/nodejs_test.exs#L125 121 | 122 | and from the JavaScript code here: 123 | 124 | ``` 125 | module.exports = async function echo(x, delay = 1000) { 126 | return new Promise((resolve) => setTimeout(() => resolve(x), delay)) 127 | } 128 | ``` 129 | 130 | https://github.com/revelrylabs/elixir-nodejs/blob/master/test/js/slow-async-echo.js 131 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :react_server_render, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:react_server_render, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/nodejs.ex: -------------------------------------------------------------------------------- 1 | defmodule NodeJS do 2 | def start_link(opts \\ []), do: NodeJS.Supervisor.start_link(opts) 3 | def stop(), do: NodeJS.Supervisor.stop() 4 | def call(module, args \\ [], opts \\ []), do: NodeJS.Supervisor.call(module, args, opts) 5 | def call!(module, args \\ [], opts \\ []), do: NodeJS.Supervisor.call!(module, args, opts) 6 | end 7 | -------------------------------------------------------------------------------- /lib/nodejs/error.ex: -------------------------------------------------------------------------------- 1 | defmodule NodeJS.Error do 2 | @moduledoc """ 3 | Error when Node.js sends back an error. 4 | """ 5 | 6 | defexception message: nil, stack: nil 7 | end 8 | -------------------------------------------------------------------------------- /lib/nodejs/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule NodeJS.Supervisor do 2 | use Supervisor 3 | 4 | @timeout 30_000 5 | @default_pool_size 4 6 | 7 | @moduledoc """ 8 | NodeJS.Supervisor 9 | """ 10 | 11 | @doc """ 12 | Starts the Node.js supervisor and workers. 13 | 14 | ## Options 15 | * `:name` - (optional) The name used for supervisor registration. Defaults to #{__MODULE__}. 16 | * `:path` - (required) The path to your Node.js code's root directory. 17 | * `:pool_size` - (optional) The number of workers. Defaults to #{@default_pool_size}. 18 | """ 19 | @spec start_link(keyword()) :: {:ok, pid} | {:error, any()} 20 | def start_link(opts \\ []) do 21 | Supervisor.start_link(__MODULE__, opts, name: supervisor_name(opts)) 22 | end 23 | 24 | @doc """ 25 | Stops the Supervisor and underlying node service 26 | """ 27 | @spec stop() :: :ok 28 | def stop() do 29 | Supervisor.stop(__MODULE__) 30 | end 31 | 32 | defp run_in_transaction(module, args, opts) do 33 | binary = Keyword.get(opts, :binary, false) 34 | timeout = Keyword.get(opts, :timeout, @timeout) 35 | esm = Keyword.get(opts, :esm, module |> elem(0) |> to_string |> String.ends_with?(".mjs")) 36 | 37 | func = fn pid -> 38 | try do 39 | GenServer.call(pid, {module, args, [binary: binary, timeout: timeout, esm: esm]}, timeout) 40 | catch 41 | :exit, {:timeout, _} -> 42 | {:error, "Call timed out."} 43 | 44 | :exit, error -> 45 | {:error, {:node_js_worker_exit, error}} 46 | end 47 | end 48 | 49 | pool_name = supervisor_pool(opts) 50 | :poolboy.transaction(pool_name, func, timeout) 51 | end 52 | 53 | defp supervisor_name(opts) do 54 | Keyword.get(opts, :name, __MODULE__) 55 | end 56 | 57 | defp supervisor_pool(opts) do 58 | opts 59 | |> Keyword.get(:name, __MODULE__) 60 | |> Module.concat(Pool) 61 | end 62 | 63 | def call(module, args \\ [], opts \\ []) 64 | 65 | def call(module, args, opts) when is_bitstring(module), do: call({module}, args, opts) 66 | 67 | def call(module, args, opts) when is_tuple(module) and is_list(args) do 68 | try do 69 | run_in_transaction(module, args, opts) 70 | catch 71 | :exit, {:timeout, _} -> 72 | {:error, "Call timed out."} 73 | end 74 | end 75 | 76 | def call!(module, args \\ [], opts \\ []) do 77 | module 78 | |> call(args, opts) 79 | |> case do 80 | {:ok, result} -> result 81 | {:error, message} -> raise NodeJS.Error, message: message 82 | end 83 | end 84 | 85 | # --- Supervisor Callbacks --- 86 | @doc false 87 | def init(opts) do 88 | path = Keyword.fetch!(opts, :path) 89 | pool_name = supervisor_pool(opts) 90 | pool_size = Keyword.get(opts, :pool_size, @default_pool_size) 91 | worker = Keyword.get(opts, :worker, NodeJS.Worker) 92 | 93 | pool_opts = [ 94 | max_overflow: 0, 95 | name: {:local, pool_name}, 96 | size: pool_size, 97 | worker_module: worker 98 | ] 99 | 100 | children = [ 101 | :poolboy.child_spec(pool_name, pool_opts, [path]) 102 | ] 103 | 104 | opts = [strategy: :one_for_one] 105 | Supervisor.init(children, opts) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/nodejs/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule NodeJS.Worker do 2 | use GenServer 3 | 4 | # Port can't do more than this. 5 | @read_chunk_size 65_536 6 | 7 | # This random looking string makes sure that other things writing to 8 | # stdout do not interfere with the protocol that we rely on here. 9 | # All protocol messages start with this string. 10 | @prefix ~c"__elixirnodejs__UOSBsDUP6bp9IF5__" 11 | 12 | @moduledoc """ 13 | A genserver that controls the starting of the node service 14 | """ 15 | require Logger 16 | 17 | @doc """ 18 | Starts the Supervisor and underlying node service. 19 | """ 20 | @spec start_link([binary()], any()) :: {:ok, pid} | {:error, any()} 21 | def start_link([module_path], opts \\ []) do 22 | GenServer.start_link(__MODULE__, module_path, name: Keyword.get(opts, :name)) 23 | end 24 | 25 | # Node.js REPL Service 26 | defp node_service_path() do 27 | Path.join(:code.priv_dir(:nodejs), "server.js") 28 | end 29 | 30 | # Specifies the NODE_PATH for the REPL service to require modules from. We specify 31 | # both the root path and `/node_modules` folder relative to the root path. This is 32 | # to specify the entry point that the REPL service runs code from. 33 | defp node_path(module_path) do 34 | [module_path, module_path <> "/node_modules"] 35 | |> Enum.join(node_path_separator()) 36 | |> String.to_charlist() 37 | end 38 | 39 | defp node_path_separator do 40 | case :os.type() do 41 | {:win32, _} -> ";" 42 | _ -> ":" 43 | end 44 | end 45 | 46 | # --- GenServer Callbacks --- 47 | @doc false 48 | def init(module_path) do 49 | node = System.find_executable("node") 50 | 51 | port = 52 | Port.open( 53 | {:spawn_executable, node}, 54 | [ 55 | {:line, @read_chunk_size}, 56 | {:env, get_env_vars(module_path)}, 57 | {:args, [node_service_path()]}, 58 | :exit_status, 59 | :stderr_to_stdout 60 | ] 61 | ) 62 | 63 | {:ok, [node_service_path(), port]} 64 | end 65 | 66 | defp get_env_vars(module_path) do 67 | [ 68 | {~c"NODE_PATH", node_path(module_path)}, 69 | {~c"WRITE_CHUNK_SIZE", String.to_charlist("#{@read_chunk_size}")} 70 | ] 71 | end 72 | 73 | defp get_response(data, timeout) do 74 | receive do 75 | {_port, {:data, {flag, chunk}}} -> 76 | data = data ++ chunk 77 | 78 | case flag do 79 | :noeol -> 80 | get_response(data, timeout) 81 | 82 | :eol -> 83 | case data do 84 | @prefix ++ protocol_data -> {:ok, protocol_data} 85 | _ -> get_response(~c"", timeout) 86 | end 87 | end 88 | 89 | {_port, {:exit_status, status}} when status != 0 -> 90 | {:error, {:exit, status}} 91 | after 92 | timeout -> {:error, :timeout} 93 | end 94 | end 95 | 96 | defp decode_binary(data, binary) do 97 | if binary === true do 98 | :binary.list_to_bin(data) 99 | else 100 | data 101 | end 102 | end 103 | 104 | @doc false 105 | def handle_call({module, args, opts}, _from, [_, port] = state) 106 | when is_tuple(module) do 107 | timeout = Keyword.get(opts, :timeout) 108 | binary = Keyword.get(opts, :binary) 109 | esm = Keyword.get(opts, :esm, false) 110 | body = Jason.encode!([Tuple.to_list(module), args, esm]) 111 | Port.command(port, "#{body}\n") 112 | 113 | case get_response(~c"", timeout) do 114 | {:ok, response} -> 115 | decoded_response = 116 | response 117 | |> decode_binary(binary) 118 | |> decode() 119 | 120 | {:reply, decoded_response, state} 121 | 122 | {:error, :timeout} -> 123 | {:reply, {:error, :timeout}, state} 124 | end 125 | end 126 | 127 | # Determines if debug mode is enabled via application configuration 128 | defp debug_mode? do 129 | Application.get_env(:nodejs, :debug_mode, false) 130 | end 131 | 132 | # Handles any messages from the Node.js process 133 | # When debug_mode is enabled, these messages (like Node.js debug info) 134 | # will be logged at info level 135 | @doc false 136 | def handle_info({_pid, {:data, {_flag, msg}}}, state) do 137 | if debug_mode?() do 138 | Logger.info("NodeJS: #{msg}") 139 | end 140 | 141 | {:noreply, state} 142 | end 143 | 144 | # Catch-all handler for other messages 145 | def handle_info(_message, state) do 146 | {:noreply, state} 147 | end 148 | 149 | defp decode(data) do 150 | data 151 | |> to_string() 152 | |> Jason.decode!() 153 | |> case do 154 | [true, success] -> {:ok, success} 155 | [false, error] -> {:error, error} 156 | end 157 | end 158 | 159 | # Safely resets the terminal, handling potential errors if 160 | # the port is already closed or invalid 161 | defp reset_terminal(port) do 162 | try do 163 | Port.command(port, "\x1b[0m\x1b[?7h\x1b[?25h\x1b[H\x1b[2J") 164 | Port.command(port, "\x1b[!p\x1b[?47l") 165 | rescue 166 | _ -> 167 | Logger.debug("NodeJS: Could not reset terminal - port may be closed") 168 | end 169 | end 170 | 171 | @doc false 172 | def terminate(_reason, [_, port]) do 173 | reset_terminal(port) 174 | send(port, {self(), :close}) 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule NodeJS.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :nodejs, 7 | version: "3.1.3", 8 | elixir: "~> 1.12", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package(), 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [ 15 | coveralls: :test, 16 | "coveralls.detail": :test, 17 | "coveralls.post": :test, 18 | "coveralls.html": :test 19 | ], 20 | 21 | # Docs 22 | name: "NodeJS", 23 | source_url: "https://github.com/revelrylabs/elixir-nodejs", 24 | homepage_url: "https://github.com/revelrylabs/elixir-nodejs", 25 | # The main page in the docs 26 | docs: [main: "NodeJS", extras: ["README.md"]] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | # Run "mix help deps" to learn about dependencies. 38 | defp deps do 39 | [ 40 | {:ex_doc, "~> 0.33.0", only: [:dev, :test]}, 41 | {:jason, "~> 1.0"}, 42 | {:poolboy, "~> 1.5.1"}, 43 | {:ssl_verify_fun, "~> 1.1.7"} 44 | ] 45 | end 46 | 47 | defp description do 48 | """ 49 | Provides an Elixir API for calling Node.js functions. 50 | """ 51 | end 52 | 53 | defp package do 54 | [ 55 | files: [ 56 | "lib", 57 | "mix.exs", 58 | "README.md", 59 | "LICENSE", 60 | "CHANGELOG.md", 61 | "priv/server.js", 62 | "priv/package.json" 63 | ], 64 | maintainers: ["Bryan Joseph", "Luke Ledet", "Joel Wietelmann"], 65 | licenses: ["MIT"], 66 | links: %{ 67 | "GitHub" => "https://github.com/revelrylabs/elixir-nodejs" 68 | }, 69 | build_tools: ["mix"] 70 | ] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 3 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 5 | "ex_doc": {:hex, :ex_doc, "0.33.0", "690562b153153c7e4d455dc21dab86e445f66ceba718defe64b0ef6f0bd83ba0", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "3f69adc28274cb51be37d09b03e4565232862a4b10288a3894587b0131412124"}, 6 | "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, 7 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 9 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 10 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 11 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 12 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 18 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 19 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 20 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 21 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 22 | } 23 | -------------------------------------------------------------------------------- /priv/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elixir-nodejs", 3 | "type": "commonjs" 4 | } 5 | -------------------------------------------------------------------------------- /priv/server.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs/promises'); 2 | const path = require('path') 3 | const readline = require('readline') 4 | 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) 7 | const PREFIX = "__elixirnodejs__UOSBsDUP6bp9IF5__"; 8 | 9 | async function fileExists(file) { 10 | return await fs.access(file, fs.constants.R_OK).then(() => true).catch(() => false); 11 | } 12 | 13 | function requireModule(modulePath) { 14 | // When not running in production mode, refresh the cache on each call. 15 | if (process.env.NODE_ENV !== 'production') { 16 | delete require.cache[require.resolve(modulePath)] 17 | } 18 | 19 | return require(modulePath) 20 | } 21 | 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 | 39 | function getAncestor(parent, [key, ...keys]) { 40 | if (typeof key === 'undefined') { 41 | return parent 42 | } 43 | 44 | return getAncestor(parent[key], keys) 45 | } 46 | 47 | async function getResponse(string) { 48 | try { 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 56 | return JSON.stringify([true, result]) 57 | } catch ({ message, stack }) { 58 | return JSON.stringify([false, `${message}\n${stack}`]) 59 | } 60 | } 61 | 62 | async function onLine(string) { 63 | const buffer = Buffer.from(`${await getResponse(string)}\n`) 64 | 65 | // The function we called might have written something to stdout without starting a new line. 66 | // So we add one here and write the response after the prefix 67 | process.stdout.write("\n") 68 | process.stdout.write(PREFIX) 69 | for (let i = 0; i < buffer.length; i += WRITE_CHUNK_SIZE) { 70 | let chunk = buffer.slice(i, i + WRITE_CHUNK_SIZE) 71 | 72 | process.stdout.write(chunk) 73 | } 74 | } 75 | 76 | function startServer() { 77 | process.stdin.on('end', () => process.exit()) 78 | 79 | const readLineInterface = readline.createInterface({ 80 | input: process.stdin, 81 | output: process.stdout, 82 | terminal: false, 83 | }) 84 | 85 | readLineInterface.on('line', onLine) 86 | } 87 | 88 | module.exports = { startServer } 89 | 90 | if (require.main === module) { 91 | startServer() 92 | } 93 | -------------------------------------------------------------------------------- /test/js/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock -------------------------------------------------------------------------------- /test/js/default-function-echo.js: -------------------------------------------------------------------------------- 1 | module.exports = function echo(arg) { 2 | return arg 3 | } 4 | -------------------------------------------------------------------------------- /test/js/esm-module-invalid.mjs: -------------------------------------------------------------------------------- 1 | require('uuid/v4') 2 | 3 | export default false -------------------------------------------------------------------------------- /test/js/esm-module.mjs: -------------------------------------------------------------------------------- 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/js/keyed-functions.js: -------------------------------------------------------------------------------- 1 | const { v4: uuid } = require('uuid'); 2 | 3 | function hello(name) { 4 | return `Hello, ${name}!` 5 | } 6 | 7 | function add(a, b) { 8 | return a + b 9 | } 10 | 11 | function sub(a, b) { 12 | return a - b 13 | } 14 | 15 | function throwTypeError() { 16 | throw new TypeError('oops') 17 | } 18 | 19 | function getBytes(size) { 20 | return Buffer.alloc(size) 21 | } 22 | 23 | class Unserializable { 24 | constructor() { 25 | this.circularRef = this 26 | } 27 | } 28 | 29 | function getIncompatibleReturnValue() { 30 | return new Unserializable() 31 | } 32 | 33 | function getArgv() { 34 | return process.argv 35 | } 36 | 37 | function getEnv() { 38 | return process.env 39 | } 40 | 41 | function logsSomething() { 42 | console.log("Something") 43 | process.stdout.write("something else") 44 | return 42 45 | } 46 | 47 | module.exports = { 48 | uuid, 49 | hello, 50 | math: { add, sub }, 51 | throwTypeError, 52 | getBytes, 53 | getIncompatibleReturnValue, 54 | getArgv, 55 | getEnv, 56 | logsSomething 57 | } 58 | -------------------------------------------------------------------------------- /test/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "default-function-echo.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "uuid": "^9.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/js/slow-async-echo.js: -------------------------------------------------------------------------------- 1 | module.exports = async function echo(x, delay = 1000) { 2 | return new Promise((resolve) => setTimeout(() => resolve(x), delay)) 3 | } 4 | -------------------------------------------------------------------------------- /test/js/subdirectory/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function returnTrue() { 2 | return true 3 | } 4 | -------------------------------------------------------------------------------- /test/js/terminal-test.js: -------------------------------------------------------------------------------- 1 | // Test various ANSI sequences and terminal control characters 2 | module.exports = { 3 | outputWithANSI: () => { 4 | // Color and formatting 5 | process.stdout.write('\u001b[31mred text\u001b[0m\n'); 6 | process.stdout.write('\u001b[1mbold text\u001b[0m\n'); 7 | 8 | // Cursor movement 9 | process.stdout.write('\u001b[2Amove up\n'); 10 | process.stdout.write('\u001b[2Bmove down\n'); 11 | 12 | // Screen control 13 | process.stdout.write('\u001b[2Jclear screen\n'); 14 | process.stdout.write('\u001b[?25linvisible cursor\n'); 15 | 16 | // Return a clean string to verify protocol handling 17 | return "clean output"; 18 | }, 19 | 20 | // Test function that outputs complex ANSI sequences 21 | complexOutput: () => { 22 | // Nested and compound sequences 23 | process.stdout.write('\u001b[1m\u001b[31m\u001b[4mcomplex formatting\u001b[0m\n'); 24 | 25 | // OSC sequences (window title, etc) 26 | process.stdout.write('\u001b]0;Window Title\u0007'); 27 | 28 | // Alternative screen buffer 29 | process.stdout.write('\u001b[?1049h\u001b[Halternate screen\u001b[?1049l'); 30 | 31 | return "complex test passed"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/nodejs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NodeJS.Test do 2 | use ExUnit.Case, async: true 3 | doctest NodeJS 4 | 5 | setup do 6 | path = 7 | __ENV__.file 8 | |> Path.dirname() 9 | |> Path.join("js") 10 | 11 | start_supervised({NodeJS.Supervisor, path: path}) 12 | 13 | :ok 14 | end 15 | 16 | defp js_error_message(msg) do 17 | msg 18 | |> String.split("\n") 19 | |> case do 20 | [_head, js_error | _tail] -> js_error 21 | end 22 | |> String.trim() 23 | end 24 | 25 | describe "large payload" do 26 | test "does not explode" do 27 | NodeJS.call!({"keyed-functions", "getBytes"}, [128_000]) 28 | end 29 | end 30 | 31 | describe "calling default-function-echo" do 32 | test "returns first arg" do 33 | assert 1 == NodeJS.call!("default-function-echo", [1]) 34 | assert "two" == NodeJS.call!("default-function-echo", ["two"]) 35 | assert %{"three" => 3} == NodeJS.call!("default-function-echo", [%{three: 3}]) 36 | assert nil == NodeJS.call!("default-function-echo") 37 | assert 5 == NodeJS.call!({"default-function-echo"}, [5]) 38 | end 39 | end 40 | 41 | describe "calling keyed-functions hello" do 42 | test "replies" do 43 | assert "Hello, Joel!" == NodeJS.call!({"keyed-functions", "hello"}, ["Joel"]) 44 | end 45 | end 46 | 47 | describe "calling keyed-functions math.add and math.sub" do 48 | test "returns correct values" do 49 | assert 2 == NodeJS.call!({"keyed-functions", "math", "add"}, [1, 1]) 50 | assert 1 == NodeJS.call!({"keyed-functions", "math", "sub"}, [2, 1]) 51 | assert 2 == NodeJS.call!({"keyed-functions", :math, :add}, [1, 1]) 52 | assert 1 == NodeJS.call!({"keyed-functions", :math, :sub}, [2, 1]) 53 | end 54 | end 55 | 56 | describe "calling keyed-functions throwTypeError" do 57 | test "returns TypeError" do 58 | assert {:error, msg} = NodeJS.call({"keyed-functions", :throwTypeError}) 59 | assert js_error_message(msg) === "TypeError: oops" 60 | end 61 | 62 | test "with call! raises error" do 63 | assert_raise NodeJS.Error, fn -> 64 | NodeJS.call!({"keyed-functions", :oops}) 65 | end 66 | end 67 | end 68 | 69 | describe "calling keyed-functions getIncompatibleReturnValue" do 70 | test "returns a JSON.stringify error" do 71 | assert {:error, msg} = NodeJS.call({"keyed-functions", :getIncompatibleReturnValue}) 72 | assert msg =~ "Converting circular structure to JSON" 73 | end 74 | end 75 | 76 | describe "calling things that are not functions: " do 77 | test "module does not exist" do 78 | assert {:error, msg} = NodeJS.call("idontexist") 79 | assert msg =~ "Error: Cannot find module 'idontexist'" 80 | end 81 | 82 | test "function does not exist" do 83 | assert {:error, msg} = NodeJS.call({"keyed-functions", :idontexist}) 84 | 85 | assert js_error_message(msg) === 86 | "Error: Could not find function 'idontexist' in module 'keyed-functions'" 87 | end 88 | 89 | test "object does not exist" do 90 | assert {:error, msg} = NodeJS.call({"keyed-functions", :idontexist, :foo}) 91 | 92 | assert js_error_message(msg) === 93 | "TypeError: Cannot read properties of undefined (reading 'foo')" 94 | end 95 | end 96 | 97 | describe "calling function re-exported from an NPM dependency" do 98 | test "uuid" do 99 | assert {:ok, _uuid} = NodeJS.call({"keyed-functions", :uuid}) 100 | end 101 | end 102 | 103 | describe "calling a function in a subdirectory index.js" do 104 | test "subdirectory" do 105 | assert {:ok, true} = NodeJS.call("subdirectory") 106 | end 107 | end 108 | 109 | describe "calling functions that return promises" do 110 | test "gets resolved value" do 111 | assert {:ok, 1234} = NodeJS.call("slow-async-echo", [1234]) 112 | end 113 | 114 | test "doesn't cause responses to be delivered out of order" do 115 | task1 = 116 | Task.async(fn -> 117 | NodeJS.call("slow-async-echo", [1111]) 118 | end) 119 | 120 | task2 = 121 | Task.async(fn -> 122 | NodeJS.call("default-function-echo", [2222]) 123 | end) 124 | 125 | assert {:ok, 2222} = Task.await(task2) 126 | assert {:ok, 1111} = Task.await(task1) 127 | end 128 | 129 | test "can't block js workers" do 130 | own_pid = self() 131 | 132 | # Call a few js functions that are slow to reply 133 | task1 = 134 | Task.async(fn -> 135 | res = NodeJS.call("slow-async-echo", [1111, 60_000], timeout: 1) 136 | Process.send(own_pid, :received_timeout_1, []) 137 | res 138 | end) 139 | 140 | task2 = 141 | Task.async(fn -> 142 | res = NodeJS.call("slow-async-echo", [1112, 60_000], timeout: 1) 143 | Process.send(own_pid, :received_timeout_2, []) 144 | res 145 | end) 146 | 147 | task3 = 148 | Task.async(fn -> 149 | res = NodeJS.call("slow-async-echo", [1113, 60_000], timeout: 1) 150 | Process.send(own_pid, :received_timeout_3, []) 151 | res 152 | end) 153 | 154 | task4 = 155 | Task.async(fn -> 156 | res = NodeJS.call("slow-async-echo", [1114, 60_000], timeout: 1) 157 | Process.send(own_pid, :received_timeout_4, []) 158 | res 159 | end) 160 | 161 | # After 10ms, we definitely should have received all timeout messages 162 | assert_receive :received_timeout_1, 10 163 | assert_receive :received_timeout_2, 10 164 | assert_receive :received_timeout_3, 10 165 | assert_receive :received_timeout_4, 10 166 | 167 | assert {:error, "Call timed out."} = Task.await(task1) 168 | assert {:error, "Call timed out."} = Task.await(task2) 169 | assert {:error, "Call timed out."} = Task.await(task3) 170 | assert {:error, "Call timed out."} = Task.await(task4) 171 | 172 | # We should still get an answer here, before the timeout 173 | assert {:ok, 1115} = NodeJS.call("slow-async-echo", [1115, 1]) 174 | end 175 | end 176 | 177 | describe "overriding call timeout" do 178 | test "works, and you can tell because the slow function will time out" do 179 | assert {:error, "Call timed out."} = NodeJS.call("slow-async-echo", [1111], timeout: 0) 180 | assert_raise NodeJS.Error, fn -> NodeJS.call!("slow-async-echo", [1111], timeout: 0) end 181 | assert {:ok, 1111} = NodeJS.call("slow-async-echo", [1111]) 182 | end 183 | end 184 | 185 | describe "strange characters" do 186 | test "are transferred properly between js and elixir" do 187 | assert {:ok, "’"} = NodeJS.call("default-function-echo", ["’"], binary: true) 188 | end 189 | end 190 | 191 | describe "Implementation details shouldn't leak:" do 192 | test "Timeouts do not send stray messages to calling process" do 193 | assert {:error, "Call timed out."} = NodeJS.call("slow-async-echo", [1111], timeout: 0) 194 | 195 | refute_receive {_ref, {:error, "Call timed out."}}, 50 196 | end 197 | 198 | test "Crashes do not bring down the calling process" do 199 | own_pid = self() 200 | 201 | Task.async(fn -> 202 | {:error, _err} = NodeJS.call("slow-async-echo", [1111]) 203 | # Make sure we reach this line 204 | Process.send(own_pid, :received_error, []) 205 | end) 206 | 207 | # Abuse internal APIs / implementation details to find and kill the worker process. 208 | # Since we don't know which is which, we just kill them all. 209 | [{_, child_pid, _, _} | _rest] = Supervisor.which_children(NodeJS.Supervisor) 210 | workers = GenServer.call(child_pid, :get_all_workers) 211 | 212 | Enum.each(workers, fn {_, worker_pid, _, _} -> 213 | Process.exit(worker_pid, :kill) 214 | end) 215 | 216 | assert_receive :received_error, 50 217 | end 218 | end 219 | 220 | describe "console.log statements" do 221 | test "don't crash NodeJS process" do 222 | assert {:ok, 42} = NodeJS.call({"keyed-functions", :logsSomething}, []) 223 | end 224 | 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 259 | 260 | describe "terminal handling" do 261 | test "handles ANSI sequences without corrupting protocol" do 262 | # Test basic ANSI handling - protocol messages should work 263 | assert {:ok, "clean output"} = NodeJS.call({"terminal-test", "outputWithANSI"}) 264 | 265 | # Test complex ANSI sequences - protocol messages should work 266 | assert {:ok, "complex test passed"} = NodeJS.call({"terminal-test", "complexOutput"}) 267 | 268 | # Test multiple processes don't interfere with each other 269 | tasks = 270 | for _ <- 1..4 do 271 | Task.async(fn -> 272 | NodeJS.call({"terminal-test", "outputWithANSI"}) 273 | end) 274 | end 275 | 276 | results = Task.await_many(tasks) 277 | assert Enum.all?(results, &match?({:ok, "clean output"}, &1)) 278 | end 279 | end 280 | 281 | describe "debug mode" do 282 | test "handles debug messages without crashing" do 283 | File.mkdir_p!("test/js/debug_test") 284 | 285 | File.write!("test/js/debug_test/debug_logger.js", """ 286 | // This file outputs debugging information to stdout 287 | console.log("Debug message: Module loading"); 288 | console.debug("Debug message: Initializing module"); 289 | 290 | module.exports = function testFunction(input) { 291 | console.log(`Debug message: Function called with input: ${input}`); 292 | console.debug("Debug message: Processing input"); 293 | 294 | return `Processed: ${input}`; 295 | }; 296 | """) 297 | 298 | # With debug_mode disabled, function still works despite debug output 299 | result = NodeJS.call("debug_test/debug_logger", ["test input"]) 300 | assert {:ok, "Processed: test input"} = result 301 | 302 | # Enable debug_mode to verify it works in that mode too 303 | original_setting = Application.get_env(:nodejs, :debug_mode) 304 | Application.put_env(:nodejs, :debug_mode, true) 305 | 306 | # Function still works with debug_mode enabled 307 | result = NodeJS.call("debug_test/debug_logger", ["test input"]) 308 | assert {:ok, "Processed: test input"} = result 309 | 310 | # Restore original setting 311 | if is_nil(original_setting) do 312 | Application.delete_env(:nodejs, :debug_mode) 313 | else 314 | Application.put_env(:nodejs, :debug_mode, original_setting) 315 | end 316 | 317 | # Clean up 318 | File.rm!("test/js/debug_test/debug_logger.js") 319 | end 320 | end 321 | end 322 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------