Input
Drop a file here or
Options
Command
xxd input.bin
Output
Quick Start
Use xxd in your JavaScript projects with just a few lines of code.
import { createXxd } from 'xxd-wasm';
const xxd = await createXxd();
// Convert text to hex dump
const input = new TextEncoder().encode('Hello, World!');
xxd.FS.writeFile('/input.bin', input);
xxd.callMain(['/input.bin']);
// Output: 00000000: 4865 6c6c 6f2c 2057 6f72 6c64 21 Hello, World!
Capturing Output
By default, xxd prints to stdout. Capture the output by providing custom print handlers.
let output = '';
const xxd = await createXxd({
print: (text) => { output += text + '\n'; },
printErr: (text) => { console.error('xxd error:', text); }
});
// Write input file
const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
xxd.FS.writeFile('/data.bin', data);
// Run xxd with plain hex output
xxd.callMain(['-p', '/data.bin']);
console.log(output); // "48656c6c6f\n"
Writing Output to File
Write output to a file in the virtual filesystem, then read it back.
// Generate C include header
xxd.FS.writeFile('/image.png', pngBytes);
xxd.callMain(['-i', '/image.png', '/image.h']);
// Read the generated header file
const header = xxd.FS.readFile('/image.h', { encoding: 'utf8' });
console.log(header);
/* Output:
unsigned char _image_png[] = {
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ...
};
unsigned int _image_png_len = 1234;
*/
Reverse: Hex to Binary
Convert hex dumps back to binary data using the -r flag.
// Plain hex string to binary
const hexString = '48656c6c6f2c20576f726c6421';
xxd.FS.writeFile('/hex.txt', hexString);
xxd.callMain(['-r', '-p', '/hex.txt', '/output.bin']);
// Read the binary output
const binary = xxd.FS.readFile('/output.bin');
const text = new TextDecoder().decode(binary);
console.log(text); // "Hello, World!"
API Reference
createXxd(options?) → Promise<Module>
Initialize the xxd WASM module. Returns a promise that resolves to the module instance.
options.print — Function called for stdout outputoptions.printErr — Function called for stderr outputmodule.callMain(args: string[]) → number
Run xxd with the given command-line arguments. Returns exit code (0 = success).
module.FS.writeFile(path, data)
Write data (string or Uint8Array) to a file in the virtual filesystem.
module.FS.readFile(path, opts?) → Uint8Array|string
Read a file. Set opts.encoding: 'utf8' to return string.
Command-Line Options
-a Toggle autoskip: '*' replaces nul-lines -b Binary digit dump (01001000 01100101...) -c cols Format <cols> octets per line (default: 16) -C Capitalize variable names in C include output -d Show offset in decimal instead of hex -e Little-endian dump -E Show characters in EBCDIC -g bytes Group octets (default: 2, or 4 for -e) -i Output in C include file style -l len Stop after <len> bytes -n name Set variable name for C include output -o off Add <off> to displayed file position -p Plain hexdump style (no offsets or ASCII) -r Reverse: convert hex dump to binary -s seek Start at <seek> bytes offset -u Use upper case hex letters
Install from npm
npm install xxd-wasm
Node.js / Bundler Usage
import { createXxd } from 'xxd-wasm';
// or: const { createXxd } = require('xxd-wasm');
async function main() {
const xxd = await createXxd();
// Write data to virtual filesystem
xxd.FS.writeFile('/input.bin', 'Hello, World!');
// Run xxd
xxd.callMain(['/input.bin']);
}
main();
Browser (CDN)
<script type="module">
import { createXxd } from 'https://unpkg.com/xxd-wasm/dist/xxd.mjs';
const xxd = await createXxd();
// Ready to use!
</script>
Capture Output
let output = '';
const xxd = await createXxd({
print: (text) => { output += text + '\n'; },
printErr: (text) => { console.error(text); }
});
xxd.FS.writeFile('/data.bin', new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]));
xxd.callMain(['-p', '/data.bin']);
console.log(output); // "48656c6c6f\n"
Build from Source
# Clone the repository
git clone https://github.com/user/xxd-wasm.git
cd xxd-wasm
# Build (requires Emscripten)
make
# Output files
ls build/
# xxd.js (59KB) - JavaScript glue code
# xxd.wasm (25KB) - WebAssembly binary
Required Files
You need both files in the same directory:
xxd.js
(59KB) — JavaScript loader and Emscripten runtime
xxd.wasm
(25KB) — Compiled WebAssembly binary
Porting C Libraries to WebAssembly
A practical guide using xxd-wasm as a real-world example
This tutorial walks through the complete process of porting a C utility to WebAssembly,
from evaluating the source code to publishing an npm package. We'll use this very project
— xxd-wasm — as our example, showing real code and
actual decisions made during development.
How C-to-WASM Conversion Works
To understand what makes C code portable to WebAssembly, you need to understand the two fundamentally different types of operations in any C program:
Compiles directly to WASM
Code that operates entirely in memory and CPU:
- → Arithmetic, loops, conditionals
-
→
Memory operations (
malloc,memcpy) -
→
String manipulation (
strlen,sprintf) - → Data structures, algorithms
-
→
Math functions (
sin,sqrt)
int sum(int* arr, int n) {
int total = 0;
for (int i = 0; i < n; i++)
total += arr[i];
return total;
}
Requires emulation or shimming
Code that asks the OS to do something:
-
→
File I/O (
fopen,read,write) -
→
Network sockets (
socket,connect) -
→
Process control (
fork,exec) -
→
Threading (
pthread_create) - → Time, signals, hardware access
FILE* f = fopen("data.txt", "r");
fread(buf, 1, size, f);
fclose(f);
// Browser has no filesystem!
How Emscripten Bridges the Gap
Emscripten provides a compatibility layer that intercepts OS calls and implements them in JavaScript:
| C Function | Emscripten Implementation | How It Works |
|---|---|---|
fopen(), fread() |
Virtual Filesystem (FS) | In-memory filesystem in JavaScript. Files exist only in WASM memory. |
printf() |
print() callback | Calls your JavaScript function with stdout text. |
malloc() |
WASM Memory | Allocates from the WASM linear memory (ArrayBuffer). |
time() |
Date.now() | Maps to JavaScript's Date API. |
socket() |
Limited / WebSocket only | Raw sockets unavailable in browsers. WebSocket proxy possible. |
The Key Insight
The best WASM candidates are programs where the "interesting work" is pure computation, and OS interactions are just for I/O at the boundaries.
xxd is perfect: it reads bytes in, does hex conversion (pure computation), and writes text out. The file I/O is trivially replaced with Emscripten's virtual filesystem — you write bytes to a virtual file, xxd processes them, output goes to a callback.
Contrast with a program like curl: the "interesting work" IS the network I/O.
Porting it to WASM would require reimplementing HTTP on top of fetch() —
at that point, why not just use fetch() directly?
The Compilation Pipeline
Under the Hood: The LLVM Toolchain
Emscripten isn't a transpiler — it's a full compiler built on LLVM, the same backend used by Clang, Rust, and Swift. Your C code compiles to real machine code, just targeting WASM instead of x86/ARM:
This means you get real optimizations (-O2, -O3), and the output is compact binary code — not bloated transpiled JavaScript. A simple function like hex conversion compiles to just a handful of WASM instructions.
C to WASM: What the Compilation Looks Like
C Source Code
// Convert byte to two hex chars
void byte_to_hex(unsigned char b,
char* out) {
const char* hex = "0123456789abcdef";
out[0] = hex[b >> 4];
out[1] = hex[b & 0xF];
}
// Pure computation:
// - Array indexing
// - Bit shifting
// - Memory writes
// No OS calls!
WASM Output (text format)
;; Same function in WASM
(func $byte_to_hex
(param $b i32) (param $out i32)
(local $hex i32)
;; Load hex string address
(local.set $hex (i32.const 1024))
;; out[0] = hex[b >> 4]
(i32.store8 (local.get $out)
(i32.load8_u
(i32.add (local.get $hex)
(i32.shr_u (local.get $b)
(i32.const 4)))))
;; ... similar for out[1]
)
The Magic: Zero Source Code Changes
You don't modify the C source code at all!
This is the key insight that surprises most people. Emscripten provides its own implementation of
the C standard library. When your code calls fopen(), it's linking
against Emscripten's fopen, not the operating system's.
How Filesystem Shimming Works
Emscripten links your code against its own libc implementation. Calls to fopen,
fread, fwrite go to Emscripten's
virtual filesystem (written in JavaScript), not the kernel.
Before calling main(), you populate the virtual filesystem from JavaScript:
const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
xxd.FS.writeFile('/input.bin', data);
// Now when C code calls fopen("/input.bin", "r")
// it finds your data in the virtual FS!
The original xxd.c code works exactly as written — it has no idea it's running in a browser:
FILE* fp = fopen(filename, "rb"); // Works!
fread(buf, 1, n, fp); // Works!
printf("%08x: ", addr); // Goes to print callback!
fclose(fp); // Works!
stdout and stderr are captured via callbacks you provide at initialization:
const xxd = await createXxd({
print: (text) => { output += text + '\n'; }, // stdout
printErr: (text) => { console.error(text); } // stderr
});
xxd.callMain(['-p', '/input.bin']);
console.log(output); // "48656c6c6f\n"
Complete Data Flow
Advanced: Threading, Networking, and Limitations
Some OS features require more than simple shimming. Here's how Emscripten handles the tricky cases:
Emscripten can compile pthread code to run on Web Workers with SharedArrayBuffer for shared memory.
emcc -pthread -s PTHREAD_POOL_SIZE=4 program.c -o out.js
- • Requires
Cross-Origin-Isolationheaders (COOP/COEP) - • SharedArrayBuffer disabled by default in browsers since Spectre
- • Not all pthread features work (no thread cancellation, etc.)
- • Adds complexity — only use if you really need parallelism
Browsers don't allow raw TCP/UDP sockets. Emscripten offers limited options:
WebSocket Proxy
Route socket calls through a WebSocket server that proxies to real sockets
Fetch API
For HTTP only — Emscripten can shim to fetch(), but you lose socket semantics
Bottom line: If your C code needs raw sockets, WASM probably isn't the right target. Consider keeping networking in JavaScript and only porting the computation to WASM.
Unix signals don't exist in browsers. Code using signal() or
sigaction() will compile but handlers won't be called.
If your code relies on signals for cleanup, you'll need to refactor.
What Works Great (No Changes Needed)
How Browsers Sandbox WebAssembly
A common concern: "If I'm running compiled C code in my browser, couldn't it do something dangerous?" The answer is no — WASM is sandboxed by design at multiple levels.
The Fundamental Guarantee
WebAssembly has no syscall instruction. Unlike native code that can invoke
syscall / int 0x80 to talk directly to the kernel,
WASM bytecode physically cannot make operating system calls. The instruction simply doesn't exist in the spec.
Multiple Layers of Protection
Before any WASM code runs, the browser validates the entire module:
- Type checking — all function calls must match signatures
- Stack validation — no stack underflow/overflow possible
- Control flow integrity — all branches must target valid instructions
WASM gets its own sandboxed memory region — an ArrayBuffer that it cannot escape:
const memory = new WebAssembly.Memory({ initial: 256 });
// WASM can ONLY read/write within this buffer
// - Cannot access JavaScript objects
// - Cannot access DOM
// - Cannot read other tabs' memory
// - Cannot access the filesystem
Even buffer overflows are contained — they corrupt the WASM heap, not the browser.
WASM can only call functions that JavaScript explicitly provides. It cannot discover or invoke anything else:
const imports = {
env: {
print: (x) => console.log(x), // You provide this
readFile: (ptr) => { ... }, // You provide this
// WASM cannot call fetch(), XMLHttpRequest,
// localStorage, or anything you don't give it
}
};
WebAssembly.instantiate(wasmBytes, imports);
Learn more: MDN: WebAssembly.instantiate() · MDN: Importing functions from JavaScript · WASM Spec: Imports
WASM modules are subject to the same security policies as JavaScript: same-origin restrictions, Content Security Policy, CORS for cross-origin loading. A malicious WASM file can't be loaded from an attacker's server unless your CSP allows it.
How Chrome/V8 Executes WASM
V8 (Chrome's JS engine) compiles WASM to native machine code, but the generated code is still sandboxed:
↓
TurboFan (optimizing) → x86/ARM
Liftoff: Fast single-pass compiler for quick startup
TurboFan: Optimizing compiler for hot code paths
Both generate code that respects WASM's memory bounds and can only call imported functions.
Key point: Even though V8 generates native x86/ARM code, that code runs in a
sandbox where all memory accesses are bounds-checked and all external calls go through JavaScript import trampolines.
The generated code cannot contain syscall instructions.
What CAN Go Wrong (And What Can't)
Possible (but contained):
- • Infinite loops (tab hangs, but can be killed)
- • Memory exhaustion (within WASM heap limits)
- • Exploiting bugs in imports YOU provide
- • Side-channel attacks (Spectre-class, mitigated)
Impossible by design:
- • Reading files from disk
- • Making network requests (unless you import fetch)
- • Accessing other browser tabs
- • Executing shell commands
- • Escaping the browser sandbox
TL;DR: Why WASM is Safe
No syscalls
Can't talk to the OS directly
Sandboxed memory
Can't escape its ArrayBuffer
Explicit imports
Can only call what you provide
Contents
Evaluating the C Source
Before porting, assess whether your C code is a good WASM candidate. Look for these characteristics:
- • Self-contained with minimal dependencies
- • Uses standard C library functions
- • File I/O via
stdio.h - • Command-line interface with
main() - • Cross-platform code (no OS-specific syscalls)
- • Heavy OS-specific code (signals, threads)
- • Network sockets or system calls
- • Hardware access or device drivers
- • Large external library dependencies
- • Inline assembly or architecture-specific code
The original xxd source code (~1150 lines) is an ideal candidate because:
-
Single file, no dependencies — Just
stdio.h,stdlib.h,string.h -
Standard file I/O — Uses
fopen(),fread(),fwrite()which Emscripten virtualizes -
CLI-based — Has
main(argc, argv)we can call directly -
Cross-platform — Already has
#ifdeffor Windows, Unix, Mac
/* xxd.c - First step: check what headers are used */
#include <stdio.h> /* ✓ Emscripten provides this */
#include <stdlib.h> /* ✓ Emscripten provides this */
#include <string.h> /* ✓ Emscripten provides this */
#include <ctype.h> /* ✓ Emscripten provides this */
/* Platform-specific includes - check for red flags */
#ifdef WIN32
# include <io.h> /* Windows only - Emscripten will skip */
# include <fcntl.h>
#endif
/* No network, no threads, no hardware = good! */
Setting Up Emscripten
Emscripten is the compiler toolchain that converts C/C++ to WebAssembly. Install it using the official SDK:
# Clone the Emscripten SDK
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
# Install and activate the latest version
./emsdk install latest
./emsdk activate latest
# Add to your shell (add this to ~/.bashrc or ~/.zshrc)
source ./emsdk_env.sh
# Verify installation
emcc --version
# emcc (Emscripten gcc/clang-like replacement) 3.x.x
Quick Test
Before configuring flags, verify basic compilation works:
emcc src/xxd.c -o test.js && node test.js --help
Compilation Flags Explained
The magic is in the Emscripten flags. Here's the complete command used for xxd-wasm, with each flag explained:
emcc src/xxd.c -o dist/xxd.js \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_NAME='createXxd' \
-s EXPORTED_RUNTIME_METHODS='["callMain", "FS"]' \
-s EXPORTED_FUNCTIONS='["_main"]' \
-s ALLOW_MEMORY_GROWTH=1 \
-s ENVIRONMENT='web,node' \
-s EXIT_RUNTIME=0 \
-s INVOKE_RUN=0 \
-O2
-s WASM=1
Output WebAssembly binary (default). Generates .wasm file alongside JS.
-s MODULARIZE=1
Critical! Wraps the output in a factory function instead of executing immediately. This lets you control when the module initializes and pass configuration options.
-s EXPORT_NAME='createXxd'
Names the factory function. Usage: const xxd = await createXxd()
-s EXPORTED_RUNTIME_METHODS='["callMain", "FS"]'
Key exports:
• callMain — Invoke main(argc, argv) with arguments
• FS — Virtual filesystem for reading/writing files
-s EXPORTED_FUNCTIONS='["_main"]'
Export the main function. Note the underscore prefix — C functions are exported as _functionName.
-s ALLOW_MEMORY_GROWTH=1
Let WASM heap grow dynamically. Essential for handling large files — without this, memory is fixed at startup.
-s ENVIRONMENT='web,node'
Build for both browser and Node.js. Emscripten includes the right loaders for each environment.
-s EXIT_RUNTIME=0
Don't clean up after main() returns. Allows calling callMain() multiple times.
-s INVOKE_RUN=0
Don't auto-run main() on load. We'll call it manually with our own arguments.
-O2
Optimization level. -O2 is a good balance; -O3 for max speed, -Os for smallest size.
Creating JavaScript Wrappers
Emscripten's raw output is functional but not ergonomic. Create a wrapper to provide a clean API:
/**
* Build script to create a clean wrapper around Emscripten output
*/
const fs = require('fs');
// Read the Emscripten-generated code
let emscriptenCode = fs.readFileSync('dist/xxd-core.js', 'utf8');
// Fix WASM filename if needed
emscriptenCode = emscriptenCode.replace(/xxd-core\.wasm/g, 'xxd.wasm');
// Create a clean wrapper with better API
const wrapper = `
${emscriptenCode}
/**
* Create an xxd instance with a friendly API
*/
export async function createXxd(options = {}) {
const module = await createXxdCore({
print: options.print || console.log,
printErr: options.printErr || console.error,
...options
});
return {
// Run xxd with CLI arguments
callMain: (args) => module.callMain(args),
// Virtual filesystem
FS: {
writeFile: (path, data) => module.FS.writeFile(path, data),
readFile: (path, opts) => module.FS.readFile(path, opts),
unlink: (path) => module.FS.unlink(path),
},
};
}
`;
fs.writeFileSync('dist/xxd.mjs', wrapper);
Raw Emscripten Output
// Confusing: what methods exist?
const Module = await createXxdCore();
Module.FS.writeFile('/in', data);
Module.callMain(['/in']);
// Error handling? Types?
Clean Wrapper
// Clear API with options
const xxd = await createXxd({
print: (s) => output += s
});
xxd.FS.writeFile('/in', data);
xxd.callMain(['-p', '/in']);
Capturing stdout/stderr
The print and printErr options intercept
what the C code writes to stdout/stderr. This is how you capture xxd's hex output in JavaScript!
Building an npm Package
Structure your package to support both CommonJS and ESM, with TypeScript types:
xxd-wasm/ ├── src/ │ └── xxd.c # Original C source ├── dist/ # npm package output │ ├── xxd.js # CommonJS entry │ ├── xxd.mjs # ESM entry │ ├── xxd.wasm # WebAssembly binary │ └── xxd.d.ts # TypeScript types ├── scripts/ │ └── build-wrapper.js # Wrapper generator ├── Makefile # Build automation └── package.json
{
"name": "xxd-wasm",
"version": "1.0.0",
"description": "xxd hex dump utility compiled to WebAssembly",
"main": "dist/xxd.js",
"module": "dist/xxd.mjs",
"types": "dist/xxd.d.ts",
"exports": {
".": {
"import": "./dist/xxd.mjs",
"require": "./dist/xxd.js",
"types": "./dist/xxd.d.ts"
}
},
"files": [
"dist/xxd.js",
"dist/xxd.mjs",
"dist/xxd.wasm",
"dist/xxd.d.ts"
],
"keywords": ["xxd", "hex", "wasm", "webassembly"],
"license": "(MIT OR GPL-2.0)"
}
CC = emcc
SRC = src/xxd.c
DIST_DIR = dist
FLAGS = -s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_NAME='createXxdCore' \
-s EXPORTED_RUNTIME_METHODS='["callMain", "FS"]' \
-s EXPORTED_FUNCTIONS='["_main"]' \
-s ALLOW_MEMORY_GROWTH=1 \
-s ENVIRONMENT='web,node' \
-s EXIT_RUNTIME=0 \
-s INVOKE_RUN=0 \
-O2
# Build the npm package
npm: $(DIST_DIR)/xxd.js
$(DIST_DIR)/xxd-core.js: $(SRC)
@mkdir -p $(DIST_DIR)
$(CC) $(FLAGS) $(SRC) -o $(DIST_DIR)/xxd-core.js
$(DIST_DIR)/xxd.js: $(DIST_DIR)/xxd-core.js
node scripts/build-wrapper.js
.PHONY: npm clean
export interface XxdOptions {
print?: (text: string) => void;
printErr?: (text: string) => void;
}
export interface XxdModule {
callMain(args: string[]): number;
FS: {
writeFile(path: string, data: string | Uint8Array): void;
readFile(path: string, opts?: { encoding?: string }): Uint8Array | string;
unlink(path: string): void;
};
}
export function createXxd(options?: XxdOptions): Promise<XxdModule>;
export default createXxd;
Testing & CI/CD
Write tests to verify your WASM module works correctly, then automate builds with GitHub Actions:
const { createXxd } = require('../dist/xxd.js');
async function test() {
let output = '';
const xxd = await createXxd({
print: (text) => { output += text + '\n'; },
printErr: (text) => { console.error('ERROR:', text); }
});
// Test 1: Basic hex dump
output = '';
xxd.FS.writeFile('/test.bin', 'Hello');
xxd.callMain(['/test.bin']);
console.assert(output.includes('48656c6c6f'), 'Basic hex dump failed');
// Test 2: Plain hex output (-p)
output = '';
xxd.callMain(['-p', '/test.bin']);
console.assert(output.trim() === '48656c6c6f', 'Plain hex failed');
// Test 3: Reverse (-r)
xxd.FS.writeFile('/hex.txt', '48656c6c6f');
xxd.callMain(['-r', '-p', '/hex.txt', '/out.bin']);
const result = xxd.FS.readFile('/out.bin', { encoding: 'utf8' });
console.assert(result === 'Hello', 'Reverse failed');
console.log('✓ All tests passed!');
}
test().catch(console.error);
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@v14
with:
version: 'latest'
- name: Build WASM
run: make npm
- name: Run Tests
run: make test
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
if: contains(github.event.head_commit.message, '[publish]')
runs-on: ubuntu-latest
permissions:
id-token: write # Required for npm trusted publishing
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
npm Trusted Publishing
Configure trusted publishing on npmjs.com to avoid storing NPM_TOKEN secrets. Link your GitHub repository, and npm will authenticate via OIDC.