TypeScript x Perl

4 min read Original article ↗

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.

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 -S

Done. Unicode works. Stop emailing me1.

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.

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.

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

Zeroperl is now a proper embeddable Perl5 runtime.

Source: github.com/6over3/zeroperl

Packages: @6over3/zeroperl-ts, @uswriting/exiftool

Discussion about this post

Ready for more?