-
Notifications
You must be signed in to change notification settings - Fork 3
Snprintf
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.
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.
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
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.
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.
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.
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
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:
As you type in the format string, tags will appear under the other input, each indicating the type of an expected argument:
You can see the web app in action here.
You can find the complete source code for this example here and here.
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.