Nōdo – call Node.js from Ruby
Nodo provides a Ruby environment to interact with JavaScript running inside a Node process.
ノード means "node" in Japanese.
Why Nodo?
Nodo will dispatch all JS function calls to a single long-running Node process.
JavaScript code is run in a namespaced environment, where you can access your initialized JS objects during sequential function calls without having to re-initialize them.
IPC is done via unix sockets, greatly improving performance over classic process/eval solutions.
Installation
In your Gemfile:
Node.js
Nodo requires a working installation of Node.js.
If the executable is located in your PATH, no configuration is required.
Otherwise, the path to the binary can be set using:
Nodo.binary = '/usr/local/bin/node'
Usage
In Nodo, you define JS functions as you would define Ruby methods:
class Foo < Nodo::Core function :say_hi, <<~JS (name) => { return `Hello ${name}!`; } JS end foo = Foo.new foo.say_hi('Nodo') => "Hello Nodo!"
JS code can also be supplied using the code: keyword argument:
function :hello, code: "() => 'world'"
Async functions
Nodo supports calling async functions from Ruby.
The Ruby call will happen synchronously, i.e. it will block until the JS
function resolves:
class SyncFoo < Nodo::Core function :do_something, <<~JS async () => { return await asyncFunc(); } JS end
Using npm modules
Install your modules to node_modules:
requireing your dependencies will make the library available as a const with the same name:
class Bar < Nodo::Core require :uuid function :v4, <<~JS () => { return uuid.v4(); } JS end bar = Bar.new bar.v4 => "b305f5c4-db9a-4504-b0c3-4e097a5ec8b9"
import is also supported for loading ESM packages:
class Bar < Nodo::Core import :uuid function :v4, <<~JS () => { return uuid.v4(); } JS end bar = Bar.new bar.v4 => "b305f5c4-db9a-4504-b0c3-4e097a5ec8b9"
Aliasing requires
If the library name cannot be used as name of the constant, the const name
can be given using hash syntax:
class FooBar < Nodo::Core require commonjs: '@rollup/plugin-commonjs' end
Dynamic ESM imports
ES modules can be imported dynamically using nodo.import():
class DynamicFoo < Nodo::Core function :v4, <<~JS async () => { const uuid = await nodo.import('uuid'); return await uuid.v4() } JS end
Note that the availability of dynamic imports depends on your Node version.
Defining JS constants
class BarFoo < Nodo::Core const :HELLO, "World" end
Execute some custom JS during initialization
class BarFoo < Nodo::Core script <<~JS // custom JS to be executed during initialization // things defined here can later be used inside functions const bigThing = someLib.init(); JS end
With the above syntax, the script code will be generated during class definition time. In order to have the code generated when the first instance is created, the code can be defined inside a block:
class Foo < Nodo::Core script do <<~JS var definitionTime = #{Time.now.to_json}; JS end end
Note that the script will still be executed only once, when the first instance of class is created.
Inheritance
Subclasses will inherit functions, constants, dependencies and scripts from their superclasses, while only functions can be overwritten.
class Foo < Nodo::Core function :foo, "() => 'superclass'" end class SubFoo < Foo function :bar, "() => { return 'calling' + foo() }" end class SubSubFoo < SubFoo function :foo, "() => 'subsubclass'" end Foo.new.foo => "superclass" SubFoo.new.bar => "callingsuperclass" SubSubFoo.new.bar => "callingsubsubclass"
Deferred function definition
By default, the function code string literal is created when the class is defined. Therefore any string interpolation inside the code will take place at definition time.
In order to defer the code generation until the first object instantiation, the function code can be given inside a block:
class Deferred < Nodo::Core function :now, <<~JS () => { return #{Time.now.to_json}; } JS function :later do <<~JS () => { return #{Time.now.to_json}; } JS end end instance = Deferred.new sleep 5 instance.now => "2021-10-28 20:30:00 +0200" instance.later => "2021-10-28 20:30:05 +0200"
The block will be invoked when the first instance is created. As with deferred scripts, it will only be invoked once.
Limiting function execution time
The default timeout for a single JS function call is 60 seconds and can be set globally:
If the execution of a single function call exceeds the timeout, Nodo::TimeoutError
is raised.
The timeout can also be set on a per-function basis:
class Foo < Nodo::Core function :sleep, timeout: 1, code: <<~'JS' async (sec) => await new Promise(resolve => setTimeout(resolve, sec * 1000)) JS end Foo.new.sleep(2) => Nodo::TimeoutError raised
Setting NODE_PATH
By default, ./node_modules is used as the NODE_PATH.
To set a custom path:
Nodo.modules_root = 'path/to/node_modules'
Also see: Clean your Rails root
Logging
By default, JS errors will be logged to STDOUT.
To set a custom logger:
Nodo.logger = Logger.new('nodo.log')
In Rails applications, Rails.logger will automatically be set.
Debugging
To get verbose debug output, set
before instantiating any worker instances. The debug mode will be active during the current process run.
To print a debug message from JS code:
nodo.debug("Debug message");
Evaluation
While Nodo is mainly function-based, it is possible to evaluate JS code in the
context of the defined object.
foo = Foo.new.evaluate("3 + 5") => 8
Evaluated code can access functions, required dependencies and constants:
class Foo < Nodo::Core const :BAR, 'bar' require :uuid function :hello, code: '() => "world"' end foo = Foo.new foo.evaluate('BAR') => "bar" foo.evaluate('uuid.v4()') => "f258bef3-0d6f-4566-ad39-d8dec973ef6b" foo.evaluate('hello()') => "world"
Variables defined by evaluation are local to the current instance:
one = Foo.new one.evaluate('a = 1') two = Foo.new two.evaluate('a = 2') one.evaluate('a') => 1 two.evaluate('a') => 2
- Avoid modifying any of your predefined identifiers. Remember that in JS, as in Ruby, constants are not necessarily constant.
- Never evaluate any code which includes un-checked user data. The Node.js process has full read/write access to your filesystem! 💥
Clean your Rails root
For Rails applications, Nodo enables you to move node_modules, package.json and
yarn.lock into your application's vendor folder by setting the NODE_PATH in
an initializer:
# config/initializers/nodo.rb Nodo.modules_root = Rails.root.join('vendor', 'node_modules')
The rationale for this is NPM modules being external vendor dependencies, which should not clutter the application root directory.
With this new default, all yarn operations should be done after cding to vendor.
This repo provides an adapted version
of the yarn:install rake task which will automatically take care of the vendored module location.
Working with web mocking frameworks like WebMock
Nodo uses HTTP via UNIX sockets to connect to its Node process. This may lead to
conflicts during tests when using WebMock or other tools which interfere with
Net::HTTP. In order to work with WebMock, you need to enable its allow_localhost
option:
WebMock.disable_net_connect!(allow_localhost: true)
Node process options
Extra commandline arguments to the node binary can be supplied in standard
Process::spawn array form:
Nodo.args = %w[--enable-source-maps]
Environment variables to be supplied to the node process can be set using:
Nodo.env = { 'NAME' => 'value' }