Skip to content

Snprintf

Chung Leong edited this page Aug 6, 2024 · 7 revisions

This example shows how you can use snprintf(), a function from the C standard library, in Javascript. "Why on earth would I want to do that?" might be your immediate question. The answer is "because it's there". This example is meant to demonstrate the power of Zig, how it allows you to do things that you can't in C. It's meant to show how, thanks to the Zig compiler, you can employ C code in situations where you would never imagine.

Thie example comes in two parts. In the first part, we'd be using snprintf() (and friends) in Node.js. The actual function in glibc or msvcrt would be invoked from JavaScript. In the second part, we'd create a React app that uses libc in WebAssembly.

Node.js test scripts

We'll first initialize the test project and create a couple sub-directories:

mkdir snprintf
cd snprintf
npm init -y
npm install node-zigar
mkdir src zig

In zig, we add the file functions.zig:

const c = @cImport(
    @cInclude("stdio.h"),
);

pub const snprintf = c.snprintf;
pub const sprintf = c.sprintf;
pub const printf = c.printf;
pub const fprintf = c.fprintf;
pub const fopen = c.fopen;
pub const fclose = c.fclose;

pub const I8 = i8;
pub const I16 = i16;
pub const I32 = i32;
pub const I64 = i64;
pub const Usize = usize;
pub const F64 = f64;
pub const CStr = [*:0]const u8;

Basically, we're exporting a number of function from stdio.h, along with some standard types.

Using snprintf

In src, we add the file snprintf.js:

require('node-zigar/cjs');
const { snprintf, I32, F64, CStr } = require('../zig/functions.zig');

const format = 'hello world %d %.9f %s\n';
const args = [ new I32(1234), new F64(Math.PI), new CStr('donut') ];
const len = snprintf(null, 0, format, ...args);
const buffer = new Buffer.alloc(len);
snprintf(buffer, buffer.length, format, ...args);
console.log(buffer.toString());

The example code calls snprintf() twice: the first time to calculate the number of bytes required and a second time to write into the buffer.

In the array args we have our variadic arguments. These must be Zig data objects, since type info for them is not available from the function the declaration. They must match the specifiers in the format string. %d means 32-bit signed integer. %f means 64-bit floating point number. %s means zero-terminated string.

In the terminal, we run the script with the following command:

node src/snprintf.js
hello world 1234 3.141592654 donut

Using sprintf

sprintf() is an older, unsafe version of snprintf(). Just for fun we'll test what would happens when we pass a buffer insufficiently large for the output string:

require('node-zigar/cjs');
const { sprintf, I32, F64, CStr } = require('../zig/functions.zig');

const format = 'hello world %d %.9f %s\n';
const args = [ new I32(1234), new F64(Math.PI), new CStr('donut') ];
const buffer = new Buffer.alloc(16);
sprintf(buffer, format, ...args);
console.log(buffer.toString());
hello world 1234
free(): invalid next size (fast)
Aborted (core dumped)

As expected, we get a buffer overflow error.

Using printf

printf() outputs a formatted string to stdout:

require('node-zigar/cjs');
const { printf, I8, I16, Usize } = require('../zig/functions.zig');

const format = 'hello world %hhd %hd %zx\n';
const args = [ new I8(123), new I16(1234), new Usize(0xFFFF_FFFFn) ];
printf(format, ...args);
hello world 123 1234 ffffffff

%hhd means 8-bit signed integer. %hd means 16-bit signed integer. %zx means size_t in hexadecimal.

printf() actually doesn't get invoked here, because Zigar intercepts calls to the function in order to redirect the output to console.log(). vsnprintf() is the function that gets called.

Using fprintf

fprintf() outputs a formatted string to a file:

require('node-zigar/cjs');
const { fopen, fclose, fprintf, I8, I16, Usize } = require('../zig/functions.zig');
const { readFileSync } = require('fs');

const format = 'hello world %hhd %hd %zx\n';
const args = [ new I8(123), new I16(1234), new Usize(0xFFFF_FFFFn) ];
const f = fopen('hello.txt', 'w');
fprintf(f, format, ...args);
fclose(f);
console.log(readFileSync('hello.txt', 'utf8'));
hello world 123 1234 ffffffff

Like printf(), calls to fprintf() are intercepted by Zigar. After checking that the file handle isn't stdout or stderr, it would call vfprintf() to write to the file.

Deployment

To deploy precompiled modules to a server where the Zig compiler would be absent, we need to first change the require statement so that it references a .zigar instead of a .zig:

require('node-zigar/cjs');
const { snprintf, I32, F64, CStr } = require('../lib/functions.zigar');

We also need to create node-zigar.config.json, which contains information about the targetted platforms:

{
  "optimize": "ReleaseSmall",
  "sourceFiles": {
    "lib/functions.zigar": "zig/functions.zig"
  },
  "targets": [
    { "platform": "linux", "arch": "x64" },
    { "platform": "linux", "arch": "arm64" },
    { "platform": "linux", "arch": "ppc64" }
  ]
}

Then we run the build command:

npx node-zigar build

The following is the result:

📁 lib
  📁 functions.zigar
    📗 linux.arm64.so
    📗 linux.ppc64.so
    📗 linux.x64.so
  📁 node-zigar-addon
    📗 linux.arm64.node
    📗 linux.ppc64.node
    📗 linux.x64.node
📁 src
📁 zig

If you have Docker and QEMU installed on your computer, you can try running one of the test scripts in a different platform. The command for ARM64:

docker run --platform linux/arm64 --rm -v $(pwd):$(pwd) -w $(pwd) node:latest node src/snprintf.js
Unable to find image 'node:latest' locally
latest: Pulling from library/node
Digest: sha256:86915971d2ce1548842315fcce7cda0da59319a4dab6b9fc0827e762ef04683a
Status: Downloaded newer image for node:latest
hello world 1234 3.141592654 donut

For PowerPC 8:

docker run --platform linux/ppc64le --rm -v $(pwd):$(pwd) -w $(pwd) node:latest node src/snprintf.js
Unable to find image 'node:latest' locally
latest: Pulling from library/node
Digest: sha256:86915971d2ce1548842315fcce7cda0da59319a4dab6b9fc0827e762ef04683a
Status: Downloaded newer image for node:latest
hello world 1234 3.141592654 donut

React app

Now let us create a web app that uses snprintf(). First, create a boilerplate Vite project:

npm create vite@latest
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
✔ Project name: … snprintf
✔ Select a framework: › React
✔ Select a variant: › JavaScript + SWC
cd snprintf
npm install
npm install --save-dev rollup-plugin-zigar
mkdir zig

Then create snprintf.zig in the zig sub-directory:

const c = @cImport(
    @cInclude("stdio.h"),
);

pub const snprintf = c.snprintf;

pub const I8 = i8;
pub const U8 = u8;
pub const I16 = i16;
pub const U16 = u16;
pub const I32 = i32;
pub const U32 = u32;
pub const I64 = i64;
pub const U64 = u64;
pub const Usize = usize;
pub const F64 = f64;
pub const CStr = [*:0]const u8;

Open vite.config.js and add rollup-plugin-zigar to the list of plugins:

import react from '@vitejs/plugin-react-swc';
import zigar from 'rollup-plugin-zigar';
import { defineConfig } from 'vite';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), zigar({ useLibc: true })],
  build: {
    target: 'es2022',
  }
})

For simplicity sake we're not going to disable top-level await. That requires a more recent target than the default.

Open src/app.jsx and replace the content with the following:

import { useCallback, useMemo, useState } from 'react'
import {
  CStr, F64, I16, I32, I64, I8, U16, U32, U64, U8, Usize, snprintf
} from '../zig/snprintf.zig'
import './App.css'

function App() {
  const [format, setFormat] = useState('')
  const [argStr, setArgStr] = useState('')
  const argTypes = useMemo(() => {
    const list = []
    const re = /%(.*?)([diuoxfegacspn%])/gi
    let m
    while (m = re.exec(format)) {
      let type
      switch (m[2].toLowerCase()) {
        case 'n':
        case 'i':
        case 'd': {
          if (m[1].includes('hh')) type = I8
          else if (m[1].includes('h')) type = I16
          else if (m[1].includes('ll') || m[1].includes('j')) type = I64
          else if (m[1].includes('z')) type = Usize
          else type = I32
        } break
        case 'u':
        case 'x':
        case 'o': {
          if (m[1].includes('hh')) type = U8
          else if (m[1].includes('h')) type = U16
          else if (m[1].includes('ll') || m[1].includes('j')) type = U64
          else if (m[1].includes('z')) type = Usize
          else type = U32
        } break
        case 'a':
        case 'f':
        case 'e':
        case 'g': type = F64; break;
        case 'c': type = U8; break
        case 's': type = CStr; break
        case 'p': type = Usize; break
      }
      if (type) {
        list.push(type)
      }
    }
    return list
  }, [format])
  const args = useMemo(() => {
    try {
      return eval(`[${argStr}]`)
    } catch (err) {
      return err
    }
  }, [argStr])
  const result = useMemo(() => {
    try {
      if (args instanceof Error) throw args
      const vargs = []
      for (const [ index, arg ] of args.entries()) {
        const type = argTypes[index]
        if (!type) throw new Error(`No specifier for argument #${index + 1}: ${arg}`)
        vargs.push(new type(arg))
      }
      const len = snprintf(null, 0, format, ...vargs)
      if (len < 0) throw new Error('Invalid format string')
      const buffer = new CStr(len + 1)
      snprintf(buffer, buffer.length, format, ...vargs)
      return buffer.string
    } catch (err) {
      return err
    }
  }, [args, format, argTypes])
  const onFormatChange = useCallback((evt) => {
    setFormat(evt.target.value)
  }, [])
  const onArgStrChange = useCallback((evt) => {
    setArgStr(evt.target.value)
  }, [])
  const categorize = function(t) {
    if (t.name.startsWith('i')) return 'signed'
    else if (t.name.startsWith('u')) return 'unsigned'
    else if (t.name.startsWith('f')) return 'float'
    else return 'other'
  }

  return (
    <>
      <div id="call">
        snprintf(buffer, size,
          <input id="format" value={format} onChange={onFormatChange} />,
          <div id="arg-container">
            <input id="arg-str" value={argStr} onChange={onArgStrChange} />
            <div id="arg-types">
              {argTypes.map((t) => <label className={categorize(t)}>{t.name}</label>)}
            </div>
          </div>
        );
      </div>
      <div id="result" className={result instanceof Error ? 'error' : ''}>
        {result.toString()}
      </div>
    </>
  )
}

export default App

While the code above is longish, our app is actually quite simple. It's just a form with two inputs: one for the format string and the other the list of arguments. We scan the format string to look for expected Zig types. When we see %lld, we know we need an i64. When it's just %d, then we use i32, and so on.

With the help of eval() we convert the argument string into an array of values. We converts them to Zig data objects and pass them to snprintf() in a useMemo hook:

      const len = snprintf(null, 0, format, ...vargs)
      if (len < 0) throw new Error('Invalid format string')
      const buffer = new CStr(len + 1)
      snprintf(buffer, buffer.length, format, ...vargs)
      return buffer.string

As a final step we need to add styles of our app's UI elements to src/App.css:

#root {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

#call {
  text-align: center;
}

#format {
  margin-left: .5em;
  width: 15em;
}

#arg-str {
  margin-left: .5em;
  width: 20em;
}

#arg-container {
  display: inline-block;
  position: relative;
}

#arg-types {
  position: absolute;
  left: .5em;
  top: 1.75em;
  text-align: left;
  white-space: nowrap;
}

#arg-types LABEL {
  font-size: 80%;
  padding: 1px 2px 1px 2px;
  margin-right: 3px;
  border: 1px solid black;
}

#arg-types LABEL.signed {
  background-color: #CCFFFF;
}

#arg-types LABEL.unsigned {
  background-color: #FFCCFF;
}

#arg-types LABEL.float {
  background-color: #FFCCCC;
}

#arg-types LABEL.other {
  background-color: #FFFFCC;
}

#result {
  font-family: 'Courier New', Courier, monospace;
  font-size: 110%;
  margin-top: 3em;
  padding: .5em .5em .5em .5em;
  border: 2px dashed #999999;
  width: fit-content;
  white-space: pre;
}

#result.error {
  border-color: #FF0000;
  color: #FF0000;
}

Now start up Vite in dev mode and open the link:

npm run dev

The following should appear after a brief moment:

Browser

As you type in the format string, tags will appear under the other input, each indicating the type of an expected argument:

Browser

Live demo

You can see the web app in action here.

Source dode

You can find the complete source code for this example here and here.

Conclusion

snprintf() and its friends aren't particular useful functions. The purpose of this demo is to show how easy it's to make use of C code in a JavaScript app, whether it's on the server side or in the browser. I hope you're convinced. Should an occasion arise where you need to do something low-level or you have an algorithm that only exists in C, now you know what you can use.


Additional examples.

Clone this wiki locally