How to call Fortran routines from JavaScript with Node.js

A tour de force introduction to authoring Node.js native add-ons which support calling Fortran routines from JavaScript and usher in a new era of high-performance computation for the web.

Splash image showing a sheep sitting before a chalkboard showing the equation 2+2=5.
Photo by Elimende Inagella / Unsplash

Fortran is a commonly used language for numerical and scientific computation, underpinning many of the higher-level numerical libraries and programming languages in use today. Since Fortran's original development in 1957, researchers and software developers have used Fortran as a primary language for high-performance computation and authored thousands of high-performance programs and libraries for astronomy, climate modeling, computational chemistry, fluid dynamics, simulation, weather prediction, and more.

Rather than attempt to re-implement the entirety of the Fortran ecosystem, programming languages, such as R, MATLAB, and Julia, and numerical libraries, such as NumPy and SciPy, have opted to provide language-specific wrappers around Fortran functionality. Despite significant interest in numerical computing on the web, no one has developed comprehensive JavaScript bindings for Fortran libraries. That is, until now.

In this post, we'll begin laying the groundwork for authoring high-performance Fortran bindings and explore how to call Fortran routines from JavaScript using Node.js. We'll start with a brief introduction to Fortran, followed by writing and compiling a simple Fortran program. We'll then discuss how to use Node-API to link a compiled Fortran routine to the Node.js runtime. And we'll conclude by demonstrating how to use stdlib to simplify the authoring of Node.js bindings.

By the end of this post, you'll have a good understanding of how to call Fortran routines from JavaScript using Node.js.

Prerequisites

Throughout this post, we'll be writing sample programs and performing various steps to compile and run Fortran programs. We'll assume that you have some familiarity with using the terminal, executing commands, and running JavaScript programs. For the most part, terminal commands will assume a Linux-based operating system. Some modifications may be required to successfully run commands and perform compilation steps on Windows.

If you're hoping to follow along, you'll need the following prerequisites:

1) You'll want to make sure you've installed the latest stable Node.js version. To check whether Node.js is already installed

$ node --version

where $ is the terminal prompt and node --version is the entered command.

2) We'll be using npm for installing Node.js dependencies, but you should be able to adapt any installation commands to your preferred JavaScript package manager (e.g., Yarn, pnpm, etc).

3) In order to generate build files appropriate for your operating system (OS), we'll be using node-gyp, which, in turn, has varying prerequisites depending on your OS, including the availability of Python. For more details, see the node-gyp installation instructions.

4) In order to compile Fortran programs, you'll need a Fortran compiler. In this post, we'll be using GNU Fortran (GFortran) to compile Fortran code. GFortran is an implementation of the Fortran programming language in the widely used GNU Compiler Collection (GCC), an open-source project maintained under the umbrella of the GNU Project. To check whether GFortran is already installed

$ gfortran --version

5) And finally, we'll be using GCC to compile and link C source code. To check whether GCC is already installed

$ gcc --version

If you don't have one or more of the above installed, you'll want to go ahead and install those now.

Introduction to Fortran

Fortran is a compiled, imperative programming language well-suited to numerical and scientific computation. Known for its high performance, versatility, and ease of use, Fortran is natively parallel and has built-in support for array handling. This makes Fortran a popular choice for scientific computing.

Many fundamental libraries for numerical computation, such as BLAS (basic linear algebra subprograms), LAPACK (linear algebra package), SLATEC, and MINPACK, among many others, are written in Fortran. These libraries serve as the foundation of popular open-source numerical computation libraries, such as NumPy and SciPy, and numerical programming languages, such as R, MATLAB, and Julia.

Given Fortran's widespread usage and decades of development, one could argue that most modern numerical programming languages and libraries are simply fancy wrappers around Fortran routines. Therefore, enabling JavaScript to call Fortran routines not only leverages these high-performance libraries but also positions JavaScript as a viable language for machine learning and other computation-intensive tasks.

Now, let's get started by compiling our first Fortran program!

Compiling our first Fortran program

Recognizing that some readers of this post may not be familiar with Fortran, let's kick things off by writing a "Hello world" program in Fortran for adding two numbers and printing the result. To begin, open up a text editor and create the file add.f90 containing the following code which contains a function definition for adding two integers and a main program which calls that function and prints the result.

! file: add.f90

!>
! Adds two integer values.
!
! @param {integer} x - first input value
! @param {integer} y - second input value
!<
integer function add( x, y )
    ! Define the input parameters:
    integer, intent(in) :: x, y
    ! ..
    ! Compute the sum:
    add = x + y
end function add

!>
! Main execution sequence.
!<
program main
    ! Local variables:
    character(len=999) :: str, tmp
    ! ..
    ! Intrinsic functions:
    intrinsic adjustl, trim
    ! ..
    ! Define a variable for storing the sum:
    integer :: res
    ! ..
    ! Compute the sum:
    res = add( 12, 15 )
    ! ..
    ! Print the results:
    write (str, '(I15)') res
    tmp = adjustl( str )
    print '(A, A)', 'The sum of 12 and 15 is ', trim( tmp )
end program

There are a few things to note in the above program. The first is that, in general, Fortran routines pass arguments by reference. A common practice is to define and pass output variables for storing results—something that we'll revisit later in this post.

Second, a best practice is to specify the intent(xx) of a variable. In the code above, intent(in) indicates that an argument must not be redefined or become undefined during the execution of a subroutine. Similarly, intent(out) indicates that an argument must be defined before the argument is referenced within a subroutine.

Third, in order to print formatted results, we need to perform various string manipulation steps, including writing to character buffers (write), adjusting alignment (adjustl), and trimming results (trim).

For the purposes of getting something working, our program defines a single variable res, which receives the result of passing two number literals to an add function. To test whether the code works, we first need to see if it compiles, and, to do this, we'll use the GNU Fortran (GFortran) compiler, which is part of the GNU Compiler Collection (GCC). While other Fortran compilers exist, such as the Intel Fortran Compiler, LLVM Flang, and LFortran, GFortran is one of the most widely used Fortran compilers, and what we cover in this post should readily translate elsewhere.

In a terminal, navigate to the directory containing add.f90, and execute the following command

$ gfortran add.f90 -o add.out && ./add.out

where add.f90 is the file path of the file to be compiled and add.out is the file path to use for storing a generated executable. If all went according to plan, you should see the following text as output

The sum of 12 and 15 is 27

Defining another Fortran subroutine

In add.f90, we defined a self-contained Fortran program which adds two numbers and prints the result. But what if we want to call Fortran functions and subroutines from another Fortran file or from outside of Fortran, such as from JavaScript running in Node.js?

To see how this is done, let's begin by creating another Fortran file mul.f90, this time containing a subroutine for multiplying two integers and returning an integer result.

! file: mul.f90

!>
! Multiplies two integer values.
!
! @param {integer} x - first input value
! @param {integer} y - second input value
! @param {integer} res - output argument for storing the result
!<
subroutine mul( x, y, res )
    integer, intent(in) :: x, y
    integer, intent(out) :: res
    res = x * y
end subroutine mul

Similar to add, mul takes two input parameters x and y, but this time mul is a subroutine which takes an output parameter res for the storing the result.

If we try compiling mul.f90 as we did with add.f90,

$ gfortran mul.f90 -o mul.out

we'll encounter an error message similar to the following

Undefined symbols for architecture arm64:
  "_main", referenced from:
      <initial-undefines>
ld: symbol(s) not found for architecture arm64
collect2: error: ld returned 1 exit status

In order to successfully generate a standalone executable, Fortran code must have a main program providing an entry point for execution. Without this entry point, a Fortran compiler does not where to begin executing code or where to look to identify the procedures and functions necessary to run a program.

For mul.f90, we're not wanting Fortran to drive execution, and, instead, we're interested in defining an entry point outside of Fortran which will enable a JavaScript runtime to drive execution. This means that we need to figure out a way to establish a bridge between a JavaScript runtime exposing native APIs and Fortran code containing APIs which we want to use. In order to establish such a bridge, we need to disentangle two compiler phases: compilation and linking.

Linking

At a high level, compilation is the process of translating one programming language into another programming language. Often this means taking expressions written in a higher-level language, such as Fortran, and translating them to a lower-level language, such as machine code, in order to create an executable program that a machine can natively understand. The output of compilation is one or more object files, which typically have .o or .obj filename extensions.

Linking is the process of taking one or more object files and combining them into a single executable file. During linking, a "linker" performs several tasks:

  • symbol resolution: resolving references to functions and variables across different object files.
  • address binding: assigning final memory addresses to a program's functions and variables.
  • library inclusion: including code from static or dynamic libraries as required.
  • executable creation: producing the final executable file that can be run on a target system.

When we ran the GFortran command above

$ gfortran mul.f90 -o mul.out

the compiler attempted to perform both compilation and linking. However, if we're trying to combine compiled Fortran code with a separate library (or a runtime such as Node.js), we need to split compilation and linking into separate steps.

Accordingly, in order to just generate the object file, we can amend the previous command as follows

$ gfortran -c mul.f90

where the -c flag instructs the compiler to compile, but not to link. After running this command from the same directory as mul.f90, you should see a mul.o (or mul.obj) file containing the compiled source code.

Linking Fortran files

To demonstrate linking as a separate phase, create a mul_script.f90 file containing the following code containing a main program which calls the mul function and prints the result.

! file: mul_script.f90

!>
! Main execution sequence.
!<
program main
    ! Local variables:
    character(len=999) :: str, tmp
    ! ..
    ! Intrinsic functions:
    intrinsic adjustl, trim
    ! ..
    ! Define a variable for storing the product:
    integer :: res
    ! ..
    ! Call the `mul` subroutine to compute the product:
    call mul( 4, 5, res )
    ! ..
    ! Print the results:
    write (str, '(I15)') res
    tmp = adjustl( str )
    print '(A, A)', 'The product of 4 and 5 is ', trim( tmp )
end program

We can then perform the same compilation step as we did for mul.f90.

$ gfortran -c mul_script.f90

At this point, we should have two object files: mul.o and mul_script.o (or mul.obj and mul_script.obj, respectively). To link them into a single executable, we can run the following command in which we define the path of the output executable and pass in the paths of the object files we wish to link.

$ gfortran -o mul_script.out mul.o mul_script.o

Once linked, we can test that everything works by running the generated executable.

$ ./mul_script.out

If all went according to plan, you should see the following text as output

The product of 4 and 5 is 20

At this point, we've successfully compiled and linked together separate Fortran source files, and we can now turn our attention to linking compiled Fortran to non-Fortran code.

Linking Fortran and C

A common scenario in numerical computing is exposing numerical computing libraries written in Fortran as C functions. C also happens to be the programming language used by Node.js to expose APIs for building native add-ons (i.e., extensions to the Node.js runtime). Accordingly, if we can figure out how to link Fortran to C, we'll be well on our way to creating a Node.js native add-on capable of calling Fortran routines.

Writing Fortran wrappers

While the mul function defined above can be used in conjunction with other Fortran files, we cannot simply call mul from C as we do in Fortran because Fortran expects arguments to be passed by reference rather than by value. It's also worth mentioning that, because Fortran functions can only return scalar values and not, e.g., pointers to arrays, general best practice is to expose Fortran functions as subroutines, which are the equivalent of C functions returning void and which allow passing pointers for storing output return values.

While mul is already a subroutine, if we wanted to expose add to C, we'd first need to wrap add as a subroutine in a manner similar to the following code snippet containing the subroutine wrapper addsub which forwards input arguments to add and assigns the result to an output argument res.

!>
! Wraps `add` as a subroutine.
!
! @param {integer} x - first input value
! @param {integer} y - second input value
! @param {integer} res - output argument for storing the result
!<
subroutine addsub( x, y, res )
    implicit none
    ! ..
    ! External functions:
    interface
        integer function add( x, y )
            integer :: x, y
        end function add
    end interface
    ! ..
    integer, intent(in) :: x, y
    integer, intent(out) :: res
    ! ..
    res = add( x, y )
    return
end subroutine addsub

Defining function prototypes in C

With those preliminaries out of the way, to help the C compiler reason about functions defined elsewhere (e.g., in a Fortran library or in other source files), we need to define function prototypes for any functions we plan to use before we use them. For our use case of calling a single Fortran routine, we can create a mul_fortran.h header file containing a single function declaration for the mul subroutine.

// file: mul_fortran.h

#ifndef MUL_FORTRAN_H
#define MUL_FORTRAN_H

#ifdef __cplusplus
extern "C" {
#endif

void mul( const int *x, const int *y, int *res );

#ifdef __cplusplus
}
#endif

#endif

One thing to note is that, in the above header file, we prevent name mangling by using extern "C". This is common practice in order to facilitate interoperation of C and C++, and preventing name mangling helps avoid compiler errors if we decide to use mul in C++ in the future.

Calling Fortran routines from C

Next, similar to how we created a Fortran program for calling a Fortran function defined in a separate file, we can create a main.c file containing a main function which calls mul and prints the result.

// file: main.c

#include "mul_fortran.h"
#include <stdio.h>

int main( void ) {
    int x = 4;
    int y = 5;
    int res;

    // Compute the product, passing arguments by reference:
    mul( &x, &y, &res );

    printf( "The product of %d and %d is %d\n", x, y, res );
    return 0;
}

Compiling C and Fortran

To compile our C program, we can run the following command

$ gcc -I mul_fortran.h -c main.c

where -I mul_fortran.h instructs the compiler to use the function declarations defined in the header file we created above.

Before linking main.o and mul.o, we first need to recompile mul.f90, making sure to instruct GFortran to not modify function names by appending underscores during compilation. This ensures that the name used in our C code matches the exported symbol from compiled Fortran. One should be careful, however, as non-mangled names may conflict with existing symbols defined in C.

To prevent GFortran from appending underscores to symbol names, we set the -fno-underscoring compiler option when calling GFortran.

$ gfortran -fno-underscoring -c mul.f90

Now that we've compiled our source files, it's time to generate an executable!

$ gcc -o main.out main.o mul.o

Depending on your operating system, if the previous command errors, you may need to modify the previous command to

$ gcc -o main.out main.o mul.o -lgfortran

where -lgfortran instructs GCC to link to the standard Fortran libraries. And finally, to test that everything works, we run the executable by entering the following command

$ ./main.out

If successful, you should see the following text as output

The product of 4 and 5 is 20

Phew! If you're new to Fortran and C, congratulations on making it this far!

Now that we've successfully managed to link Fortran and C code, we can turn our attention to using Node.js native add-ons to call Fortran routines from JavaScript.

Node-API

Node-API is an API for building Node.js native add-ons (i.e., extensions to the Node.js JavaScript runtime). There's a long history of add-on evolution and development in Node.js, of which I'll spare you the details. The real benefit of Node-API is in providing a stable Application Binary Interface (ABI), which insulates add-ons from changes in the underlying JavaScript engine (namely, V8) and which allows modules compiled for one version of Node.js to run on later versions of Node.js without recompilation. In short, Node-API provides the glue code, in the form of C APIs, necessary for us to extend Node.js capabilities with C/C++ code written and compiled independently of Node.js itself.

In order to access Node-API APIs, we need to do two things:

  1. Include the <node_api.h> header in our C files.
  2. Compile C source files using Node-API APIs with node-gyp, a build system based on Google's GYP, a meta-build system for generating other build systems.

So without further ado...

Creating an add-on file

Let's start by creating an addon.c file which will serve as an entry point for our native add-on. In this file, we'll define two functions—addon and Init—and register a Node-API module which exports a function in a manner similar to how we'd export a function if writing a module in vanilla JavaScript.

// file: addon.c

#include <node_api.h>
#include <assert.h>

/**
* Receives JavaScript callback invocation data.
*
* @param env    environment under which the function is invoked
* @param info   callback data
* @return       Node-API value
*/
static napi_value addon( napi_env env, napi_callback_info info ) {

    // NOTE: we'll add code here later in this post

    return NULL;
}

/**
* Defines the Node.js module "exports" object for the native add-on.
*
* @param env      environment under which the function is invoked
* @param exports  exports object
* @return         Node-API value
*/
static napi_value Init( napi_env env, napi_value exports ) {
    napi_value fcn;

    // Export the add-on function as a "default" export:
    napi_status status = napi_create_function( env, "exports", NAPI_AUTO_LENGTH, addon, NULL, &fcn );

    // Verify that we successfully wrapped the `addon` function as a JavaScript function object:
    assert( status == napi_ok );

    // Return the JavaScript function object to allow registering with the JavaScript runtime:
    return fcn;
}

/**
* Register a Node-API module which exports a function.
*/
NAPI_MODULE( NODE_GYP_MODULE_NAME, Init )

The addon.c file is comprised of three parts:

  1. addon: this function receives JavaScript invocation data. If we assume foo() is a JavaScript function exposed by a native add-on, env is the environment in which the JavaScript code runs and info is an opaque object which can be used to retrieve function arguments and other contextual data when foo is invoked.
  2. Init: similar to how module.exports defines the APIs a Node.js module exposes to other Node.js modules, this function defines the "exports" object and initializes exported values. In this context, initialization typically means wrapping C APIs as JavaScript objects so that a JavaScript engine can pass data back and forth between JavaScript and native code.
  3. NAPI_MODULE: this is a macro exposed by Node-API for registering a Node-API module with the Node.js JavaScript runtime.

At this point, we're starting to accumulate a number of moving parts: Fortran source files, GFortran, C source files, GCC, Node-API, and a heretofore mentioned, but not explained, node-gyp.

Diagram providing an overview of building a Node.js native add-on capable of calling Fortran routines from JavaScript

As may be observed in the diagram above, a key component which we have yet to cover, but which is necessary to allow building a Node.js native add-on in a manner that is portable across platforms, is the binding.gyp file. It's this file and node-gyp that we'll dive into next.

node-gyp

node-gyp is a build system based on Google's GYP, which, in turn, is a meta-build system for generating other build systems. The key idea behind GYP is the generation of build files, such as Makefiles, Ninja build files, Visual Studio projects, and XCode projects, which are tailored to the platform on which a project is being compiled. Once GYP scaffolds a project in a manner tailored to the host platform, GYP can then perform build steps which replicate as closely as possible the way that one would have set up a native build of the project were one writing the project build system from scratch. node-gyp subsequently extends GYP by providing the configuration and tooling specific to developing Node.js native add-ons.

Configuring how to build an add-on

In order to describe the configuration necessary to build a Node.js native add-on, one needs to provide a binding.gyp file. This file is written in a JSON-like format and is placed at the root of a JavaScript package alongside a package's package.json file. GYP configuration files can be awkward to write, and, unfortunately, GYP has long been abandoned by the Google team responsible for its creation. Adding insult to injury, good documentation for authoring GYP files can be hard to come by, as the GYP documentation is incomplete and finding real-world examples doing exactly what you are wanting to do can be a time-consuming task, especially when authoring binding.gyp files requiring specialized configuration (e.g., as might be needed when compiling CUDA, OpenCL, or Fortran).

Nevertheless, persist we shall! Fortunately, writing a minimal binding.gyp file capable of supporting Fortran compilation is within reach. Start by creating a binding.gyp file specifying various configuration parameters, including build targets, source files, compiler flags, and rules for how to process files having a specific file type.

# file: binding.gyp

# A `.gyp` file for building a Node.js native add-on.
#
# [1]: https://gyp.gsrc.io/docs/InputFormatReference.md
# [2]: https://gyp.gsrc.io/docs/UserDocumentation.md
{
  # Define variables to be used throughout the configuration for all targets:
  'variables': {
    # Set variables based on the host OS:
    'conditions': [
      [
        'OS=="win"',
        {
          # Define the object file suffix on Windows:
          'obj': 'obj',
        },
        {
          # Define the object file suffix for other operating systems (e.g., Linux and MacOS):
          'obj': 'o',
        }
      ],
    ],
  },

  # Define compilation targets:
  'targets': [
    # Define a target to generate an add-on:
    {
      # The target name should match the add-on export name (see addon.c above):
      'target_name': 'addon',

      # List of source files:
      'sources': [
        # Relative paths should be relative to this configuration file...
        './addon.c',
        './mul.f90',
      ],

      # List directories which contain relevant headers to include during compilation:
      'include_dirs': [
        # Relative paths should be relative to this configuration file...
        './',
      ],

      # Define settings which should be applied when a target's object files are used as linker input:
      'link_settings': {
        # Define linker flags for libraries against which to link (e.g., '-lm', '-lblas', etc):
        'libraries': [],

        # Define directories in which to find libraries to link to (e.g., '/usr/lib'):
        'library_dirs': []
      },

      # Define custom build actions for particular source files:
      'rules': [
        {
          # Define a rule name:
          'rule_name': 'compile_fortran',

          # Define the filename extension for which this rule should apply:
          'extension': 'f90',

          # Set a flag specifying whether to process generated output as sources for subsequent steps:
          'process_outputs_as_sources': 1,

          # Define the pathnames to be used as inputs when performing processing:
          'inputs': [
            # Full path of the current input:
            '<(RULE_INPUT_PATH)',
          ],

          # Define the outputs produced during processing:
          'outputs': [
            # Store an output object file in a directory for placing intermediate results (only accessible within a single target):
            '<(INTERMEDIATE_DIR)/<(RULE_INPUT_ROOT).<(obj)',
          ],

          # Define the command-line invocation:
          'action': [
            'gfortran',
            '-fno-underscoring',
            '-c',
            '<@(_inputs)',
            '-o',
            '<@(_outputs)',
          ],
        },
      ],
    },
  ],
}

A few comments:

  1. GYP configuration files support variables, conditionals, and expressions. In the configuration file above, <(RULE_INPUT_PATH), <(INTERMEDIATE_DIR), and <(RULE_INPUT_ROOT) are predefined variables provided by the GYP generator module. Variables such as <@(_inputs) and <@(_outputs) represent variable expansions and correspond to variables which should be expanded in list contexts.
  2. While GYP attempts to automate and abstract away the generation of build files tailored to the operating system on which to compile, this doesn't absolve us from needing to consider platform variability. For example, the configuration file above includes a conditional for resolving an appropriate object file filename extension based on the target operating system.
  3. Configuration files can quickly become complex depending on operating system variability, including the availability of specialized compilers, such as GFortran, and the need for bespoke rules for varying input file types.

Building an add-on

Now that we have a GYP configuration file, it's time to install node-gyp. In your terminal, run

$ npm install --no-save node-gyp

The node-gyp executable will subsequently be available in the ./node_modules/.bin directory. To generate the appropriate project build files for the current platform, run the following command

$ ./node_modules/.bin/node-gyp configure

This will generate a ./build directory containing platform-specific build files. To build the native add-on, we can run

$ ./node_modules/.bin/node-gyp build

which will generate an addon.node file in a ./build/Release sub-folder. To remove generated files, run

$ ./node_modules/.bin/node-gyp clean

As we continue to iterate on our addon.c file, we'll want to perform the clean-configure-build sequence each time we make changes. Accordingly, we can consolidate the above steps into a single command

$ ./node_modules/.bin/node-gyp clean && \
  ./node_modules/.bin/node-gyp configure && \
  ./node_modules/.bin/node-gyp build

Calling a Fortran routine from JavaScript

At this point, we've got almost all of the core building blocks for calling a Fortran routine from JavaScript. We're only missing two things:

  1. Logic in addon.c which calls the Fortran routine.
  2. A JavaScript file which invokes the function exposed by our native add-on.

Updating the add-on file

To start, let's revisit our addon.c file. In this file, we need to make four changes:

  1. Retrieve provided arguments.
  2. Convert from JavaScript objects to native C types.
  3. Add logic to call our Fortran routine mul.
  4. Return a result as a JavaScript object.

Luckily, we already have experience with (3) when we wrote main.c and linked against our compiled Fortran routine. As in main.c, we want to include the mul_fortran.h header, which we can do by making the following change in addon.c

// file: addon.c

+ #include "mul_fortran.h"
#include <node_api.h>
#include <assert.h>

Next, we'll want to modify the addon function in addon.c to include logic for calling the mul Fortran routine. In the snippet below, we copy the invocation logic used in main.c into the implementation of the addon function.

/**
* Receives JavaScript callback invocation data.
*
* @param env    environment under which the function is invoked
* @param info   callback data
* @return       Node-API value
*/
static napi_value addon( napi_env env, napi_callback_info info ) {

    // ...

    // Call the Fortran routine:
    int res;
    mul( &x, &y, &res );

    // ...

    return NULL;
}

Now on to argument munging. Fortunately, Node-API provides several APIs for converting from JavaScript objects to native C data types. In particular, we're interested in converting JavaScript numbers to C integers, which is demonstrated in the following code snippet which defines the number of expected input arguments, retrieves those arguments from provided callback info using napi_get_cb_info, and converts JavaScript objects to native C data types using napi_get_value_int32.

/**
* Receives JavaScript callback invocation data.
*
* @param env    environment under which the function is invoked
* @param info   callback data
* @return       Node-API value
*/
static napi_value addon( napi_env env, napi_callback_info info ) {
    napi_status status;

    // Define the expected number of input arguments:
    size_t argc = 2;

    // Retrieve the input arguments from the callback info:
    napi_value argv[ 2 ];
    status = napi_get_cb_info( env, info, &argc, argv, NULL, NULL );
    assert( status == napi_ok );

    // Convert each argument to a signed 32-bit integer:
    int x;
    status = napi_get_value_int32( env, argv[ 0 ], &x );
    assert( status == napi_ok );

    int y;
    status = napi_get_value_int32( env, argv[ 1 ], &y );
    assert( status == napi_ok );

    // Call the Fortran routine:
    int res;
    mul( &x, &y, &res );

    // ...

    return NULL;
}

And finally, we need to convert the integer result to a JavaScript object for use within JavaScript, which is demonstrated in the following code snippet which adds logic for converting a C signed 32-bit integer to an opaque object representing a JavaScript number using napi_create_int32.

/**
* Receives JavaScript callback invocation data.
*
* @param env    environment under which the function is invoked
* @param info   callback data
* @return       Node-API value
*/
static napi_value addon( napi_env env, napi_callback_info info ) {
    napi_status status;

    // Define the expected number of input arguments:
    size_t argc = 2;

    // Retrieve the input arguments from the callback info:
    napi_value argv[ 2 ];
    status = napi_get_cb_info( env, info, &argc, argv, NULL, NULL );
    assert( status == napi_ok );

    // Convert each argument to a signed 32-bit integer:
    int x;
    status = napi_get_value_int32( env, argv[ 0 ], &x );
    assert( status == napi_ok );

    int y;
    status = napi_get_value_int32( env, argv[ 1 ], &y );
    assert( status == napi_ok );

    // Call the Fortran routine:
    int res;
    mul( &x, &y, &res );

    // Convert the result to a JavaScript object:
    napi_value out;
    status = napi_create_int32( env, res, &out );
    assert( status == napi_ok );

    return out;
}

Putting it all together, we have the following addon.c file which defines the entirety of our native add-on bindings.


// file: addon.c

#include "mul_fortran.h"
#include <node_api.h>
#include <assert.h>

/**
* Receives JavaScript callback invocation data.
*
* @param env    environment under which the function is invoked
* @param info   callback data
* @return       Node-API value
*/
static napi_value addon( napi_env env, napi_callback_info info ) {
    napi_status status;

    // Define the expected number of input arguments:
    size_t argc = 2;

    // Retrieve the input arguments from the callback info:
    napi_value argv[ 2 ];
    status = napi_get_cb_info( env, info, &argc, argv, NULL, NULL );
    assert( status == napi_ok );

    // Convert each argument to a signed 32-bit integer:
    int x;
    status = napi_get_value_int32( env, argv[ 0 ], &x );
    assert( status == napi_ok );

    int y;
    status = napi_get_value_int32( env, argv[ 1 ], &y );
    assert( status == napi_ok );

    // Call the Fortran routine:
    int res;
    mul( &x, &y, &res );

    // Convert the result to a JavaScript object:
    napi_value out;
    status = napi_create_int32( env, res, &out );
    assert( status == napi_ok );

    return out;
}

/**
* Defines the Node.js module "exports" object for the native add-on.
*
* @param env      environment under which the function is invoked
* @param exports  exports object
* @return         Node-API value
*/
static napi_value Init( napi_env env, napi_value exports ) {
    napi_value fcn;

    // Export the add-on function as a "default" export:
    napi_status status = napi_create_function( env, "exports", NAPI_AUTO_LENGTH, addon, NULL, &fcn );

    // Verify that we successfully wrapped the `addon` function as a JavaScript function object:
    assert( status == napi_ok );

    // Return the JavaScript function object to allow registering with the JavaScript runtime:
    return fcn;
}

/**
* Register a Node-API module which exports a function.
*/
NAPI_MODULE( NODE_GYP_MODULE_NAME, Init )

To confirm that our Node.js add-on still compiles, we can re-run our build sequence defined above.

$ ./node_modules/.bin/node-gyp clean && \
  ./node_modules/.bin/node-gyp configure && \
  ./node_modules/.bin/node-gyp build

Creating a JavaScript file importing the native add-on

We're here! The moment that we've been waiting for! Time to create a JavaScript file which loads our Node.js native add-on and calls its public API. 🥁

Thankfully, loading a native add-on is just like loading any other JavaScript module. To see this in action, let's create a mul.js file which imports the native add-on module, calls the function exposed by the add-on, and prints the result.

// file: mul.js

// Import the native add-on module:
const addon = require( './build/Release/addon.node' );

// Compute the product of two integers:
const res = addon( 5, 10 );
console.log( 'The product of %d and %d is %d', 5, 10, res );

To test whether everything works as expected, we can run the script by passing the script's file path to the Node.js executable.

$ node ./mul.js

If all went according to plan, you should see the following text as output

The product of 5 and 10 is 50

That's it! We did it. 😅

Barring any platform quirks or dreaded compiler errors, we successfully called a Fortran routine from JavaScript. 🙌

Simplifying add-on authoring with stdlib

Depending on API complexity, authoring Node.js native add-ons can be verbose and error prone. This verbosity largely stems from the need for argument validation logic and status checks. For example, when handling typed arrays, one needs to perform multiple steps, such as verifying that an input argument is a typed array, verifying that an input argument is a typed array of the correct type, resolving the length of a typed array, converting a JavaScript object representing a typed array to a C pointer pointing to the start of the underlying typed array memory, and, for applications involving strided arrays, ensuring that typed array properties are consistent with other input arguments, such as strides and offsets.

While some validation logic can be performed in JavaScript or omitted entirely, a general best practice is to include such logic in order to ensure data integrity when calling APIs outside of Node-API APIs and to avoid hard-to-track down bugs leading to segmentation faults and buffer overflows. And furthermore, best practice requires that, after each invocation of a Node-API function, one must check napi_status return values to ensure that the JavaScript engine was able to successfully perform the requested operation. As a consequence, lines of code add up, and you find yourself writing the same logic over and over.

Macros for module registration and data type conversion

To simplify add-on authoring, stdlib provides several utilities, both functional APIs and macros, which abstract away common boilerplate. For example, we can refactor the addon.c file defined above to use stdlib's napi macros for retrieving input arguments, handling conversion to and from native C data types, and initializing and registering an exported function with Node.js.

// file: addon2.c

#include "mul_fortran.h"
#include "stdlib/napi/create_int32.h"
#include "stdlib/napi/argv_int32.h"
#include "stdlib/napi/argv.h"
#include "stdlib/napi/export.h"
#include <node_api.h>

static napi_value addon( napi_env env, napi_callback_info info ) {
    STDLIB_NAPI_ARGV( env, info, argv, argc, 2 ); // retrieve function arguments
    STDLIB_NAPI_ARGV_INT32( env, x, argv, 0 );    // convert to C data type
    STDLIB_NAPI_ARGV_INT32( env, y, argv, 1 );    // convert to C data type
    int res;
    mul( &x, &y, &res );
    STDLIB_NAPI_CREATE_INT32( env, res, out );    // convert to JavaScript object
    return out;
}

STDLIB_NAPI_MODULE_EXPORT_FCN( addon )

Specialized macros for common function signatures

The use case explored in this post—namely, calling a C/Fortran function which operates on and returns scalar values—is something that we do quite often in stdlib, especially for testing native C APIs and sharing test logic across JavaScript and C implementations. Accordingly, stdlib provides several more macro abstractions which abstract away all argument retrieval, argument validation, and module registration logic for certain input/output data type combinations.

// file: addon3.c

#include "mul_fortran.h"
#include "stdlib/math/base/napi/binary.h"

static int multiply( x, y ) {
    int res;
    mul( &x, &y, &res );
    return res;
}

STDLIB_MATH_BASE_NAPI_MODULE_II_I( multiply )

Two comments regarding the code above:

  1. STDLIB_MATH_BASE_NAPI_MODULE_II_I is a macro for registering a Node-API module for an exported function accepting two signed 32-bit integer input arguments and returning a signed 32-bit integer output value. This signature is encoded in the macro name as II_I.
  2. We need to wrap the Fortran routine in a C function, as the module registration macro assumes that a registered II_I function expects arguments to be passed by value, not by reference, and returns a scalar value.

Learning from real-world examples in stdlib

For more details on how we author Node-API native add-ons and leverage macros and various utilities for simplifying the add-on creation process, the best place to start is by browsing stdlib source code. For the examples explored in this post, we've brushed aside some of the complexity in ensuring cross-platform configuration portability (looking at you Windows!) and in specifying compiler options for optimizing compiled code. For those interested in learning more, you'll find many more examples throughout the codebase, and, if you have questions, don't be afraid to stop by and say hi! 👋

Conclusion

In this post, we explored several aspects when authoring Node.js native add-ons, with a particular eye toward being able to call Fortran routines from JavaScript. This effort involved compilation and linking, writing C interfaces, module registration, and build configuration. Along the way, we relied on a variety of tools for generating build artifacts, including Fortran and C compilers, Node-API, and node-gyp. We touched on best practices and potential pitfalls, and we observed how stdlib can make authoring Node.js native add-ons much easier.

All in all, it was a lot, with several moving parts and complex toolchains. But our exploration was well worth the effort. By leveraging Fortran's high-performance capabilities within Node.js, you can significantly enhance and accelerate your numerical and scientific computing tasks. With Node.js native add-ons, you can bridge the gap between modern web technologies and established scientific computing practices, providing a powerful toolset for you and others and opening the door to new and more powerful Node.js applications.

In future posts, we'll explore more complex use cases, including the ability to leverage hardware-optimized routines for linear algebra and machine learning. There's still a lot to learn and more ground to cover. We hope that you'll continue to follow along as we share our insights and that you'll join us in our mission to realize a future where JavaScript and the web are preferred environments for numerical and scientific computation. 🚀


Pranav Goswami is a developer of stdlib and a computer science graduate who's passionate about technology, algorithms, compilers, and epic roadtrips.

Athan Reines is a software engineer at Quansight and core developer of stdlib.


stdlib is an open source software project dedicated to providing a comprehensive suite of robust, high-performance libraries to accelerate your project's development and give you peace of mind knowing that you're depending on expertly crafted, high-quality software.

If you've enjoyed this post, give us a star 🌟 on GitHub and consider financially supporting the project. Your contributions and continued support help ensure the project's long-term success and are greatly appreciated!


If you'd like to view the code covered in this post on GitHub, please visit the source code repository.

License

All code is licensed under Apache License, Version 2.0.

Copyright (c) 2024 Pranav Goswami and Athan Reines.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.