DEV Community

Cover image for Techniques for developing JavaScript targets in Nim
medy
medy

Posted on

Techniques for developing JavaScript targets in Nim

In addition to transporting to C and creating executable binaries, Nim can also output JavaScript.
However, this requires a great deal of technique, so I would like to explain it comprehensively and exhaustively in this article.

Basics of JS Targets

Compiling

https://nim-lang.org/docs/backends.html#backends-the-javascript-target

js_sample.nim

echo "hoge"
Enter fullscreen mode Exit fullscreen mode
nim js js_sample.nim
Enter fullscreen mode Exit fullscreen mode

The nim js command will output js_sample.js.
If you have NodeJS in your runtime environment, you can run it as is.

nim js -r js_sample.nim
Enter fullscreen mode Exit fullscreen mode

outputs

hoge
Enter fullscreen mode Exit fullscreen mode

libraries

There is a standard library for JavaScript that can be used conveniently.

lib description
asyncjs You can use async/await for asynchronous processing in JS, where Future[T] in Nim becomes Promise<T> in JS.
dom A library for manipulating the DOM, including the document and window that the browser has.
jsbigints Handles JS BitInt types.
jsconsole You can call conoel.log() and others.
jscore JS Math, JSON, Date and other libraries are provided, but it is safer to use the Nim standard libraries.
jsffi This library converts types between Nim and JS mutually.
jsfetch HTTP client for API access from JS.
jsheaders A library for handling HTTP headers to be used with jsfetch.
jsformdata A library for handling HTTP form data for use with jsfetch.
jsre A library for regular expressions in JS.
jsutils This library provides convenience functions for handling types in JS.

Another 3rd party library is a wrapper library called nodejs. It is quite huge.

How to handle types

Let's see what happens when Nim types are output to JS.

app.nim

import std/jsffi
import std/times

let i = 0
let j = 0.0
let str = "string"
let cstr:cstring = "cstring"
let date = now()
Enter fullscreen mode Exit fullscreen mode

This is converted as follows.

app.js

function makeNimstrLit(c_33556801) {
      var result = [];
  for (var i = 0; i < c_33556801.length; ++i) {
    result[i] = c_33556801.charCodeAt(i);
  }
  return result;
}

function getTime_922747872() {
  var result_922747873 = ({seconds: 0, nanosecond: 0});

    var millis_922747874 = new Date().getTime();
    var seconds_922747880 = convert_922747358(2, 3, millis_922747874);
    var nanos_922747891 = convert_922747358(2, 0, modInt(millis_922747874, convert_922747358(3, 2, 1)));
    result_922747873 = nimCopy(result_922747873, initTime_922747806(seconds_922747880, chckRange(nanos_922747891, 0, 999999999)), NTI922746910);

  return result_922747873;

}

function now_922748331() {
  var result_922748332 = ({m_type: NTI922746911, nanosecond: 0, second: 0, minute: 0, hour: 0, monthdayZero: 0, monthZero: 0, year: 0, weekday: 0, yearday: 0, isDst: false, timezone: null, utcOffset: 0});

    result_922748332 = nimCopy(result_922748332, local_922748328(getTime_922747872()), NTI922746911);

  return result_922748332;

}
var i_469762051 = 0;
var f_469762052 = 0.0;
var str_469762053 = makeNimstrLit("string");
var cstr_469762054 = "cstring";
var date_469762055 = now_922748331();
Enter fullscreen mode Exit fullscreen mode

To treat it as a bare string in the JS world, you need to use cstring.

How to handle arrays

The JsObject type is provided to handle dynamic arrays in the JS world.

https://nim-lang.org/docs/jsffi.html#JsObject

JsObject = ref object of JsRoot
  Dynamically typed wrapper around a JavaScript object.
Enter fullscreen mode Exit fullscreen mode

app.nim

import std/jsconsole
import std/jsffi

proc func1()  =
  let dyArr = newJsObject()
  dyArr["id"] = 1
  dyArr["name"] = "Alice".cstring
  dyArr["status"] = true

  console.log(dyArr)
  console.log(jsTypeOf(dyArr))

func1()
Enter fullscreen mode Exit fullscreen mode

Result

{ id: 1, name: 'Alice', status: true }
object
Enter fullscreen mode Exit fullscreen mode

When you define a Nim struct, it is treated as an object in the JS world.
You can use the to and toJs functions to interconvert between JsObjects and structs.
Since JsObjects do not perform static type checking at compile time, it is better to use structs and their methods for logic as much as possible.

proc to(x: JsObject; T: typedesc): T:type {.importjs: "(#)"}
  Converts a JsObject x to type T.

proc toJs[T](val: T): JsObject {.importjs: "(#)"}
  Converts a value of any type to type JsObject.
Enter fullscreen mode Exit fullscreen mode

app.nim

type Person = object
  id:int
  name:cstring
  status:bool

proc new(_:type Person, id:int, name:string, status:bool):Person =
  return Person(id:id, name:name.cstring, status:status)

proc func1()  =
  let dyArr = newJsObject()
  dyArr["id"] = 1
  dyArr["name"] = "Alice".cstring
  dyArr["status"] = true

  console.log(dyArr)
  console.log(jsTypeOf(dyArr))

  let person = dyArr.to(Person)
  console.log(person)

  let person2 = Person.new(2, "Bob", false)
  console.log(person2)


func1()
Enter fullscreen mode Exit fullscreen mode

Result

{ id: 1, name: 'Alice', status: true }
object
{ id: 1, name: 'Alice', status: true }
{ id: 2, name: 'Bob', status: false }
Enter fullscreen mode Exit fullscreen mode

Dom manipulation

Let's display the text entered from the HTML input tag on the p tag in real time.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script defer type="module" src="app.js"></script>
  <title>Document</title>
</head>
<body>
  <input type="text" id="input">
  <p id="content"></p>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

app.nim

import dom

proc onInput(e:Event) =
  let content = document.getElementById("content")
  content.innerText = e.target.value

let input = document.getElementById("input")
input.addEventListener("input", onInput)
Enter fullscreen mode Exit fullscreen mode

The following JS file is output.

app.js

function onInput_469762050(e_469762051) {
    var content_469762052 = document.getElementById("content");
    content_469762052.innerText = e_469762051.target.value;


}
var input_469762062 = document.getElementById("input");
input_469762062.addEventListener("input", onInput_469762050, false);
Enter fullscreen mode Exit fullscreen mode

The dom library allows you to use Event, document, getElementById, etc. from Nim.

API access

API access is essential for front-end development.
Nim provides the jsfetch library for API access with JS targets.

app.nim

import std/asyncjs
import std/jsfetch
import std/jsconsole

proc apiAccess() {.async.} =
  let url:cstring = "https://api.coindesk.com/v1/bpi/currentprice.json"
  let resp = await fetch(url)
  let json = await resp.json()
  console.log(json)

discard apiAccess()
Enter fullscreen mode Exit fullscreen mode

The following JS file is output.

app.js

async function apiAccess_469762071() {
  var result_469762073 = null;

  BeforeRet: do {
    var url_469762079 = "https://api.coindesk.com/v1/bpi/currentprice.json";
    var resp_469762087 = (await fetch(url_469762079));
    var json_469762092 = (await resp_469762087.json());
    console.log(json_469762092);
    result_469762073 = undefined;
    break BeforeRet;
  } while (false);

  return result_469762073;

}
var _ = apiAccess_469762071();
Enter fullscreen mode Exit fullscreen mode

Pragmas

Nim requires frequent use of pragmas when developing JS targets.
Pragmas are like annotations in other languages, which allow you to give compile-time instructions to the compiler.

exportc

The output JS files we have seen so far have suffixes in variable and function names. By using exportc, you can prohibit suffixes from being added.

app.nim

import std/jsconsole
import std/jsffi

proc hello(arg: cstring){.exportc.} =
  let arg {.exportc.} = arg
  console.log("hello " & arg)

let name {.exportc.}: cstring = "Alice"
hello(name)
Enter fullscreen mode Exit fullscreen mode

app.js

function hello(arg_469762052) {
    var arg = arg_469762052;
    console.log(("hello " + arg));
}
var name = "Alice";
hello(name);
Enter fullscreen mode Exit fullscreen mode

emit

With emit, the processing you write in is put directly into the output JS file.
When developing a JS target, you can define the bare JavaScript it.

app.nim

{.emit:"""
function hello(arg){
  console.log("hello " + arg)
}
""".}
Enter fullscreen mode Exit fullscreen mode

app.js

function hello(arg){
  console.log("hello " + arg)
}
Enter fullscreen mode Exit fullscreen mode

importjs

It is used to map JS functions to Nim functions so that you can call JS functions from the Nim world.
Using # inserts the arguments in order from the front, while using @ inserts everything after in that position.

app.nim

import std/jsffi

{.emit:"""
function add(a, b){
  console.log(a + b)
}
""".}

proc add(a, b:int) {.importjs:"add(#, #)".}

add(2, 3)
Enter fullscreen mode Exit fullscreen mode

app.js

function add(a, b){
  console.log(a + b)
}

add(2, 3);
Enter fullscreen mode Exit fullscreen mode

Doing Practical Development

Now, based on what we have seen so far, let's call Preact, a lightweight React-like library, from Nim and use it.

https://preactjs.com/

The HTML file used here should look like this.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script defer type="module" src="app.js"></script>
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Call Preact function from Nim.

Import libraries from CDN using emit and map library functions to Nim functions using importjs.

lib.nim

import std/dom
import std/jsffi

# ==================== Definition of Preact ====================

{.emit: """
import { h, render } from 'https://cdn.jsdelivr.net/npm/preact@10.11.3/+esm';
import htm from 'https://cdn.jsdelivr.net/npm/htm@3.1.1/+esm';

const html = htm.bind(h);
""".}


type Component* = JsObject

proc html*(arg:cstring):Component {.importjs:"eval('html`' + # + '`')".}
template html*(arg:string):Component = html(arg.cstring)


{.emit: """
function renderApp(component, dom){
  render(html``<${component} />``, dom)
}
""".}
proc renderApp*(component: proc():Component, dom: Element) {.importjs: "renderApp(#, #)".}

# ================== hooks ==================

{.emit:"""
import { useState, useEffect } from 'https://cdn.jsdelivr.net/npm/preact@10.11.3/hooks/+esm';
""".}

type IntStateSetter = proc(arg: int)

proc intUseState(arg: int): JsObject {.importjs: "useState(#)".}
proc useState*(arg: int): (int, IntStateSetter) =
  let state = intUseState(arg)
  let value = to(state[0], int)
  let setter = to(state[1], IntStateSetter)
  return (value, setter)


type StrStateSetter = proc(arg: cstring)

proc strUseState(arg: cstring): JsObject {.importjs: "useState(#)".}
proc useState*(arg: cstring): (cstring, StrStateSetter) =
  let state = strUseState(arg)
  let value = to(state[0], cstring)
  let setter = to(state[1], StrStateSetter)
  return (value, setter)


type States* = cstring|int|float|bool

proc useEffect*(cb: proc(), dependency: array) {.importjs: "useEffect(#, [])".}
proc useEffect*(cb: proc(), dependency: seq[States]) {.importjs: "useEffect(#, #)".}
Enter fullscreen mode Exit fullscreen mode

The caller of the library does this.
The JSX part is a string that JS interprets, and the variable or function you want to call on it is expected to be called with the variable name as written there, so use {.exportc.} to avoid suffixes.

app.nim

import std/jsffi
import std/dom
import ./lib

proc App():Component {.exportc.} =
  let (message {.exportc.}, setMessage) = useState("")
  let (msgLen {.exportc.}, setMsgLen) = useState(0)

  proc setMsg(e:Event) {.exportc.} =
    setMessage(e.target.value)

  useEffect(proc() =
    setMsgLen(message.len)
  , @[message])

  return html("""
    <input type="text" oninput=${setMsg} />
    <p>${message}</p>
    <p>message length...${msgLen}</p>
  """)

renderApp(App, document.getElementById("app"))
Enter fullscreen mode Exit fullscreen mode

It works like this.
It works like this.

Nim as a static typing in JavaScript

let (message {.exportc.}, setMessage) = useState("")
Enter fullscreen mode Exit fullscreen mode

The setMessage here is StrStateSetter, a function that only accepts cstring types as arguments.
This is because lib.nim defines it as bellow.

type StrStateSetter = proc(arg: cstring)

proc strUseState(arg: cstring): JsObject {.importjs: "useState(#)".}
proc useState*(arg: cstring): (cstring, StrStateSetter) =
  let state = strUseState(arg)
  let value = to(state[0], cstring)
  let setter = to(state[1], StrStateSetter)
  return (value, setter)
Enter fullscreen mode Exit fullscreen mode

What if we try to put an int variable here?

proc setMsg(e:Event) {.exportc.} =
  # setMessage(e.target.value)
  setMessage(1)
Enter fullscreen mode Exit fullscreen mode

Of course compile time error raised.

/projects/nimjs/app.nim(11, 15) Error: type mismatch: got <int literal(1)>
but expected one of:
StrStateSetter = proc (arg: cstring){.closure.}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I introduced a technique for developing JavaScript targets in Nim.
As you can see, we found that we can very easily create a React-like SPA in Nim using JS assets in a static type-safe manner without using the NodeJS environment.
I will continue to develop a front-end framework made by Nim based on what I have introduced here. I would appreciate your support.
I also hope to see more Nim library assets that wrap JavaScript.

https://github.com/itsumura-h/nim-palladian

https://itsumura-h.github.io/nim-palladian/

Top comments (0)