Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

Unreleased
----------

- Support for binding to js_of_ocaml runtime primitives via `@`-prefixed payloads on `[@@js.global]` and `[@@@js.scope "@..."]`, enabling generated bindings to target values supplied by the JavaScript runtime.
- Test suite updates adapted for wasm_of_ocaml.

Version 1.1.6
-------------

Expand Down
153 changes: 153 additions & 0 deletions NODE_RUNTIME_BINDINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Binding Node.js Modules with Runtime Primitives

This guide shows how to use the new runtime primitive support in `gen_js_api` to bind Node.js libraries that are usually obtained with `require(...)`. The feature hinges on two additions:

- any `[@@js.global "@primitive_name"]` binding returns an `Ojs.t` pointing to a primitive exported by the JavaScript runtime;
- a scope string that starts with `@` (for example `[@@@js.scope "@node_fs.promises"]`) resolves the first path component through the runtime primitives before following regular properties.

Together, those tools let you keep your bindings declarative while delegating the actual `require` calls to a tiny JavaScript stub.

## Example layout

```
runtime_primitives/
dune
imports.js
imports.wat
bindings.mli
example.ml
```

### Step 1 - expose the runtime primitives

Create a JavaScript file that `require`s the Node modules you need and publishes them as js_of_ocaml runtime primitives. The js_of_ocaml linker recognises `//Provides: <name>` comments and registers the value under that name at startup.

```javascript
// runtime_primitives/imports.js
'use strict';

//Provides: node_path
var node_path = require('path');

//Provides: node_fs
var node_fs = require('fs');

//Provides: node_version
var node_version = require('process').version;

//Provides: node_console
var node_console = console.log;

```

When targeting WebAssembly you also need to expose the primitives through a `.wat` shim so that `wasm_of_ocaml` can import them at runtime:

```wat
;; runtime_primitives/imports.wat
(global (export "_node_path") (import "js" "node_path") anyref)
(global (export "_node_fs") (import "js" "node_fs") anyref)
(global (export "_node_version") (import "js" "node_version") anyref)
(global (export "_node_console") (import "js" "node_console") anyref)
```

List this file in your dune stanza so that js_of_ocaml ships it with the compiled artefacts:

```
; runtime_primitives/dune
(rule
(targets bindings.ml)
(deps bindings.mli)
(action (run gen_js_api %{deps})))

(executable
(name example)
(libraries ojs)
(preprocess (pps gen_js_api.ppx))
(modes js wasm)
(js_of_ocaml (javascript_files imports.js))
(wasm_of_ocaml (javascript_files imports.js imports.wat)))
```

Adding the file to both `js_of_ocaml` and `wasm_of_ocaml` makes the primitives available in browser and wasm builds alike.

### Step 2 - bind module functions with `[@js.scope "@..."]`

Use `module [@js.scope "@primitive"]` blocks to call methods on runtime primitives without manually threading the module objects. The interface below covers the synchronous filesystem API used in the reference JavaScript while keeping the underlying modules abstract.

```ocaml
(* runtime_primitives/bindings.mli *)
module [@js.scope "@node_fs"] Fs : sig
val write_file_sync : string -> string -> unit [@@js.global "writeFileSync"]
val read_file_sync : string -> encoding:string -> string [@@js.global "readFileSync"]
val readdir_sync : string -> string array [@@js.global "readdirSync"]
val append_file_sync : string -> string -> unit [@@js.global "appendFileSync"]
end

module [@js.scope "@node_path"] Path : sig
val separator: string [@@js.global "sep"]
val join : (string list [@js.variadic]) -> string [@@js.global "join"]
end
```
Each module-level scope starts with `@`, so the ppx turns calls like `Fs.write_file_sync` into direct invocations on the corresponding Node module (`node_fs.writeFileSync` in this case) without requiring you to pass the module object around.

### Step 3 - bind direct values with `@`-prefixed `[@@js.global]`

When you only need the primitive itself—such as a constant exported by a Node module—use the `@` prefix inside `[@@js.global]` to obtain it directly as an OCaml value.

```ocaml
(* runtime_primitives/primitives_bindings.mli continued *)

val node_version : string [@@js.global "@node_version"]
val log : string -> unit [@@js.global "@node_console"]
```

These expand to `Jsoo_runtime.Js.runtime_value ...` calls and convert the results to the requested OCaml types, so you can expose constants or functions alongside the scoped modules described above.

### Step 4 - port the JavaScript example

`main.ml` mirrors the original JavaScript snippet that writes, reads, appends, and re-reads a file while logging progress to the Node console. It relies on the scoped `Fs`/`Path` modules plus the direct `log`, `path_separator`, and `node_version` values.

```ocaml
open Bindings

let initial_content = "Hello, Node.js!"
let appended_line = "\nAppending a new line."
let encoding = "utf-8"
let filename = "example.txt"

let run () =
let file = Path.join ["."; filename] in

Fs.write_file_sync file initial_content;

let content = Fs.read_file_sync file ~encoding in
if content <> initial_content then
failwith "Unexpected initial content";
log ("File content: " ^ content);

let files = Fs.readdir_sync "." |> Array.to_list in
if not (List.mem filename files) then
failwith "example.txt missing from directory listing";
log ("Files in current directory: " ^ String.concat ", " files);

Fs.append_file_sync file appended_line;

let updated = Fs.read_file_sync file ~encoding in
if updated <> initial_content ^ appended_line then
failwith "Append failed";
log ("Updated content: " ^ updated);
log ("Path separator reported by Node: " ^ Path.separator);
log ("Node.js version: " ^ node_version)


let () = run ()
```

### Putting it together

1. Declare each required Node module once in `imports.js` (and mirror them in `imports.wat` for wasm) using the js_of_ocaml `//Provides:` convention.
2. Export the files through dune so that the js_of_ocaml toolchain registers those primitives at runtime.
3. Map node modules in OCaml with `module [@js.scope "@primitive"]` blocks, and use `@`-prefixed `[@@js.global]` bindings for direct values.
4. Consume the generated modules from OCaml exactly as you would in JavaScript, as shown in `example.ml`.

With these pieces in place you can keep writing high-level `gen_js_api` bindings while relying on the new runtime primitive support to bridge your OCaml code to Node-specific libraries provided via `require`.
2 changes: 1 addition & 1 deletion dune-project
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
(lang dune 3.0)
(lang dune 3.17)
(name gen_js_api)
(version 1.1.6)

Expand Down
2 changes: 1 addition & 1 deletion gen_js_api.opam
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ license: "MIT"
homepage: "https://github.com/LexiFi/gen_js_api"
bug-reports: "https://github.com/LexiFi/gen_js_api/issues"
depends: [
"dune" {>= "3.0"}
"dune" {>= "3.17"}
"ocaml" {>= "4.13"}
"ppxlib" {>= "0.37"}
"js_of_ocaml-compiler" {with-test}
Expand Down
2 changes: 1 addition & 1 deletion lib/ojs.mli
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,4 @@ module Bool : T with type t = bool
module Float : T with type t = float
module Array (A: T) : T with type t = A.t array
module List (A: T) : T with type t = A.t list
module Option (A: T) : T with type t = A.t option
module Option (A: T) : T with type t = A.t option
4 changes: 3 additions & 1 deletion node-test/bindings/dune
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
(pps gen_js_api.ppx))
(modes byte)
(js_of_ocaml
(javascript_files imports.js)))
(javascript_files imports.js))
(wasm_of_ocaml
(javascript_files imports.js imports.wat)))

(rule
(targets imports.ml)
Expand Down
23 changes: 15 additions & 8 deletions node-test/bindings/expected/fs.ml
Original file line number Diff line number Diff line change
Expand Up @@ -84,25 +84,32 @@ let readdir : string -> string list Promise.t =
fun (x39 : string) ->
Promise.t_of_js
(fun (x40 : Ojs.t) -> Ojs.list_of_js Ojs.string_of_js x40)
(Ojs.call (Ojs.get_prop_ascii Imports.fs "promises") "readdir"
[|(Ojs.string_to_js x39)|])
(Ojs.call
(Ojs.get_prop_ascii (Jsoo_runtime.Js.runtime_value "node_fs")
"promises") "readdir" [|(Ojs.string_to_js x39)|])
let open_ : string -> flag:string -> FileHandle.t Promise.t =
fun (x42 : string) ~flag:(x43 : string) ->
Promise.t_of_js FileHandle.t_of_js
(Ojs.call (Ojs.get_prop_ascii Imports.fs "promises") "open"
(Ojs.call
(Ojs.get_prop_ascii (Jsoo_runtime.Js.runtime_value "node_fs")
"promises") "open"
[|(Ojs.string_to_js x42);(Ojs.string_to_js x43)|])
let rmdir : string -> unit Promise.t =
fun (x45 : string) ->
Promise.t_of_js Ojs.unit_of_js
(Ojs.call (Ojs.get_prop_ascii Imports.fs "promises") "rmdir"
[|(Ojs.string_to_js x45)|])
(Ojs.call
(Ojs.get_prop_ascii (Jsoo_runtime.Js.runtime_value "node_fs")
"promises") "rmdir" [|(Ojs.string_to_js x45)|])
let rename : string -> string -> unit Promise.t =
fun (x47 : string) (x48 : string) ->
Promise.t_of_js Ojs.unit_of_js
(Ojs.call (Ojs.get_prop_ascii Imports.fs "promises") "rename"
(Ojs.call
(Ojs.get_prop_ascii (Jsoo_runtime.Js.runtime_value "node_fs")
"promises") "rename"
[|(Ojs.string_to_js x47);(Ojs.string_to_js x48)|])
let unlink : string -> unit Promise.t =
fun (x50 : string) ->
Promise.t_of_js Ojs.unit_of_js
(Ojs.call (Ojs.get_prop_ascii Imports.fs "promises") "unlink"
[|(Ojs.string_to_js x50)|])
(Ojs.call
(Ojs.get_prop_ascii (Jsoo_runtime.Js.runtime_value "node_fs")
"promises") "unlink" [|(Ojs.string_to_js x50)|])
7 changes: 1 addition & 6 deletions node-test/bindings/expected/imports.ml
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
[@@@js.dummy "!! This code has been generated by gen_js_api !!"]
[@@@ocaml.warning "-7-32-39"]
let path : Ojs.t =
Ojs.get_prop_ascii (Ojs.get_prop_ascii Ojs.global "__LIB__NODE__IMPORTS")
"path"
let fs : Ojs.t =
Ojs.get_prop_ascii (Ojs.get_prop_ascii Ojs.global "__LIB__NODE__IMPORTS")
"fs"
let path : Ojs.t = Jsoo_runtime.Js.runtime_value "node_path"
2 changes: 1 addition & 1 deletion node-test/bindings/fs.mli
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[@@@js.scope (Imports.fs, "promises")]
[@@@js.scope "@node_fs.promises"]

module Dirent : sig
type t = Ojs.t
Expand Down
9 changes: 5 additions & 4 deletions node-test/bindings/imports.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
globalThis.__LIB__NODE__IMPORTS = {
path: require('path'),
fs: require('fs'),
};
//Provides: node_path
var node_path = require('path');

//Provides: node_fs
var node_fs = require('fs');
5 changes: 1 addition & 4 deletions node-test/bindings/imports.mli
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
[@@@js.scope "__LIB__NODE__IMPORTS"]

val path: Ojs.t [@@js.global]
val fs: Ojs.t [@@js.global]
val path: Ojs.t [@@js.global "@node_path"]
3 changes: 3 additions & 0 deletions node-test/bindings/imports.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

(global (export "_node_path") (import "js" "node_path") anyref)
(global (export "_node_fs") (import "js" "node_fs") anyref)
2 changes: 1 addition & 1 deletion node-test/bindings/number.mli
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ module Static : sig
val negative_infinity: t -> float [@@js.get "NEGATIVE_INFINITY"]
val positive_infinity: t -> float [@@js.get "POSITIVE_INFINITY"]
end
val number: Static.t [@@js.global "Number"]
val number: Static.t [@@js.global "Number"]
14 changes: 14 additions & 0 deletions node-test/runtime_primitives/bindings.mli
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module [@js.scope "@node_fs"] Fs : sig
val write_file_sync : string -> string -> unit [@@js.global "writeFileSync"]
val read_file_sync : string -> encoding:string -> string [@@js.global "readFileSync"]
val readdir_sync : string -> string array [@@js.global "readdirSync"]
val append_file_sync : string -> string -> unit [@@js.global "appendFileSync"]
end

module [@js.scope "@node_path"] Path : sig
val separator: string [@@js.global "sep"]
val join : (string list [@js.variadic]) -> string [@@js.global "join"]
end

val node_version : string [@@js.global "@node_version"]
val log : string -> unit [@@js.global "@node_console"]
28 changes: 28 additions & 0 deletions node-test/runtime_primitives/dune
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
(rule
(targets bindings.ml)
(deps bindings.mli)
(action
(run gen_js_api %{deps})))

(executable
(name example)
(libraries ojs)
(preprocess
(pps gen_js_api.ppx))
(modes js wasm)
(js_of_ocaml
(javascript_files imports.js))
(wasm_of_ocaml
(javascript_files imports.js imports.wat)))

(rule
(alias runtest)
(enabled_if %{bin-available:node})
(action
(run node %{dep:./example.bc.js})))

(rule
(alias runtest-wasm)
(enabled_if %{bin-available:node})
(action
(run node %{dep:./example.bc.wasm.js})))
33 changes: 33 additions & 0 deletions node-test/runtime_primitives/example.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
open Bindings

let initial_content = "Hello, Node.js!"
let appended_line = "\nAppending a new line."
let encoding = "utf-8"
let filename = "example.txt"

let run () =
let file = Path.join ["."; filename] in

Fs.write_file_sync file initial_content;

let content = Fs.read_file_sync file ~encoding in
if content <> initial_content then
failwith "Unexpected initial content";
log ("File content: " ^ content);

let files = Fs.readdir_sync "." |> Array.to_list in
if not (List.mem filename files) then
failwith "example.txt missing from directory listing";
log ("Files in current directory: " ^ String.concat ", " files);

Fs.append_file_sync file appended_line;

let updated = Fs.read_file_sync file ~encoding in
if updated <> initial_content ^ appended_line then
failwith "Append failed";
log ("Updated content: " ^ updated);
log ("Path separator reported by Node: " ^ Path.separator);
log ("Node.js version: " ^ node_version)


let () = run ()
13 changes: 13 additions & 0 deletions node-test/runtime_primitives/imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

//Provides: node_path
var node_path = require('path');

//Provides: node_fs
var node_fs = require('fs');

//Provides: node_version
var node_version = require('process').version;

//Provides: node_console
var node_console = console.log;
4 changes: 4 additions & 0 deletions node-test/runtime_primitives/imports.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(global (export "_node_path") (import "js" "node_path") anyref)
(global (export "_node_fs") (import "js" "node_fs") anyref)
(global (export "_node_version") (import "js" "node_version") anyref)
(global (export "_node_console") (import "js" "node_console") anyref)
Loading