TypeScript x Perl
Sandboxing Perl with WebAssembly - Part 4.
Two quick updates on zeroperl: I fixed the unicode bug that everyone kept emailing me about, and I turned the whole thing into a library you can embed in other languages.
The Unicode Thing
Configure’s locale format detection fails during cross-compilation. It defaults to name=value pairs, but WASI uses positional notation with semicolons as separators. So when you set LC_ALL=UTF8, Perl would panic because it was looking for an ‘=’ that wasn’t there.
The fix: patch config.sh after Configure runs, before regenerating config.h:
sed -i “s/d_perl_lc_all_uses_name_value_pairs=’define’/d_perl_lc_all_uses_name_value_pairs=’undef’/” config.sh
sed -i “s/d_perl_lc_all_separator=’undef’/d_perl_lc_all_separator=’define’/” config.sh
sed -i ‘s|^perl_lc_all_separator=.*|perl_lc_all_separator=’”’”’”;”’”’”’|’ config.sh
sed -i “s/d_perl_lc_all_category_positions_init=’undef’/d_perl_lc_all_category_positions_init=’define’/” config.sh
sed -i “s/^perl_lc_all_category_positions_init=.*/perl_lc_all_category_positions_init=’{ 0, 1, 2, 3, 4, 5 }’/” config.sh
sh ./Configure -SDone. Unicode works. Stop emailing me1.
From CLI Tool to Embeddable Library
The more interesting change: zeroperl is no longer just a command-line WebAssembly module. I refactored it into a reactor library with a C API.
Instead of main() running once and exiting, you get:
zeroperl_init() // Boot the interpreter
zeroperl_eval(”print ‘hello’”) // Run code strings
zeroperl_run_file(”/script.pl”) // Execute files
zeroperl_get_sv(”varname”) // Get Perl variables
zeroperl_set_sv(”varname”, “value”) // Set Perl variables
zeroperl_reset() // Clean slate
zeroperl_shutdown() // Complete teardown
This follows the standard perlembed pattern (PERL_SYS_INIT3, perl_alloc, perl_construct, perl_parse, perl_run) but wrapped in functions you can call repeatedly. The key insight is that perl_run() must complete before you can use eval_pv(), so initialization happens in two phases: system setup, then interpreter readiness.
Each API function uses Asyncify to bridge synchronous Perl with asynchronous host operations. When your Perl code calls read() on a File API blob, that needs to suspend the WebAssembly stack, wait for the async read, then resume. Asyncify handles the stack manipulation.
Here’s the eval implementation:
typedef struct {
const char *code;
int argc;
char **argv;
int result;
} zeroperl_eval_context;
static int zeroperl_eval_callback(int argc, char **argv) {
zeroperl_eval_context *ctx = (zeroperl_eval_context *)argv;
if (!zero_perl || !zero_perl_can_evaluate) {
ctx->result = -1;
return -1;
}
zeroperl_clear_error_internal();
dTHX;
dSP;
ENTER;
SAVETMPS;
if (ctx->argc > 0 && ctx->argv) {
AV *argv_av = get_av(”ARGV”, GV_ADD);
av_clear(argv_av);
for (int i = 0; i < ctx->argc; i++) {
av_push(argv_av, newSVpv(ctx->argv[i], 0));
}
}
SV *result = eval_pv(ctx->code, FALSE);
if (SvTRUE(ERRSV)) {
zeroperl_capture_error();
ctx->result = -1;
} else {
ctx->result = 0;
}
FREETMPS;
LEAVE;
return ctx->result;
}
ZEROPERL_API(”zeroperl_eval”)
int zeroperl_eval(const char *code, int argc, char **argv) {
if (!zero_perl || !zero_perl_can_evaluate) {
return -1;
}
zeroperl_eval_context ctx = {
.code = code, .argc = argc, .argv = argv, .result = 0
};
return asyncjmp_rt_start(zeroperl_eval_callback, 0, (char **)&ctx);
}
The ZEROPERL_API() macro exports functions with the right visibility for WASI, making them callable in any WebAssembly runtime.
TypeScript Wrapper
To prove this actually works, I built @6over3/zeroperl-ts. It wraps the C API in async JavaScript:
import { ZeroPerl } from ‘@6over3/zeroperl-ts’;
const perl = await ZeroPerl.create();
await perl.eval(`
$| = 1; # Enable autoflush
print “Hello from Perl!\\n”;
`);
await perl.setVariable(’name’, ‘Alice’);
await perl.eval(’print “Hello, $name!\\n”’);
const result = await perl.getVariable(’name’);
console.log(result); // “Alice”
await perl.dispose();
It handles WASI initialization, virtual filesystem setup, and stdio capturing. You can hook stdout/stderr:
const perl = await ZeroPerl.create({
stdout: (data) => console.log(data),
stderr: (data) => console.error(data),
env: { DEBUG: ‘true’ }
});
Works in Node.js and browsers.
ExifTool, Finally
The whole point of this project was running ExifTool without installing Perl. @uswriting/exiftool does exactly that, client-side, using zeroperl-ts:
import { parseMetadata } from ‘@uswriting/exiftool’;
document.querySelector(’input[type=”file”]’).addEventListener(’change’, async (event) => {
const file = event.target.files[0];
const result = await parseMetadata(file);
if (result.success) {
console.log(result.data);
}
});
No server. No uploads. No native binaries. Just WebAssembly running Perl running ExifTool. It handles files over 2GB using the File API without loading them into memory (see part 3 for how async I/O works).
Try it: metadata.jp
Closing
Zeroperl is now a proper embeddable Perl5 runtime.
Source: github.com/6over3/zeroperl
Packages: @6over3/zeroperl-ts, @uswriting/exiftool
I actually don’t mind


This is amazing work!
Patching config.sh to handle WASI's positional notation is such a clean fix for the locale detction issue. I really apricate how you made zeroperl embeddable with that C API wrapping the standard perlembed pattern. The two phase initialization makes sense since perl_run() needs to complete before eval_pv() can work. Having ExifTool running entirely client side without server uploads is a killer use case.