XSLT 3.0 / XPath 3.1 / XSD 1.0–1.1 for Node.js — a Node package around the
go-xslt engine, built as a native
addon: the Go library compiled to a C shared library
(-buildmode=c-shared), called from Node via koffi
(FFI, no node-gyp/C++ glue to build or ship) inside a small pool of real
child processes.
- Pure computation in, pure computation out: every call is
async, takes a plain JSON-serializable request, and returns a plain result object. Errors are never thrown for engine-level problems (bad stylesheet, invalid XML, schema violations) — they come back asdiagnosticson the result (seehasErrors()). - Real filesystem access:
document()/doc(),xsl:include/xsl:import,xsl:result-document, andcollection()all work against the real filesystem viabaseDir, exactly as in the Go library — it's ordinary native Go code, not a sandboxed runtime. - Memory is actually released between calls. An earlier WebAssembly build of this package didn't: WASM linear memory can only grow for the life of the process, so repeated large transforms in one long-lived process meant unbounded (eventually crashing) memory growth. This native build runs as ordinary compiled Go in a real OS process — Go's own scavenger returns freed heap pages to the OS the normal way. See How it works for what that's built on.
- Real concurrency: each call in the pool runs on its own OS process, not a single-threaded interpreter — multiple transforms genuinely run in parallel.
npm install @tim-riep/node-xsltThe package carries the native library for every supported platform (see Platform support) and loads the one matching the machine; nothing is compiled at install time and no toolchain is needed.
import { transform, evalXPath, validate, hasErrors } from "@tim-riep/node-xslt";
const res = await transform({
stylesheet: `<?xml version="1.0"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<greeting>Hello, <xsl:value-of select="/root/@name"/>!</greeting>
</xsl:template>
</xsl:stylesheet>`,
source: `<root name="World"/>`,
});
if (hasErrors(res)) {
console.error(res.diagnostics);
} else {
console.log(res.output); // <greeting>Hello, World!</greeting>
}Runs an XSLT 3.0 transformation.
interface TransformRequest {
stylesheet: string;
source?: string;
params?: Record<string, string>;
initialTemplate?: string;
baseDir?: string; // enables disk-based document()/include/result-document/collection()
}Like transform, but delivers the result as a binary Readable (Buffer
chunks) instead of buffering it into one string — for results large enough
that holding the whole thing in memory matters. output applies real
backpressure: if whatever it's piped to falls behind, the engine itself
pauses serializing until the consumer catches up. result resolves once the
transform finishes, with everything TransformResult carries except
output (diagnostics, method, messages, secondaryOutputs,
durationMs).
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { transformStream, hasErrors } from "@tim-riep/node-xslt";
const { output, result } = transformStream({ stylesheet, source, baseDir });
await pipeline(output, createWriteStream("out.xml"));
const meta = await result;
if (hasErrors(meta)) console.error(meta.diagnostics);As with the underlying engine's TransformTo, a run-phase error can occur
after some output has already gone out (e.g. a runtime error deep into a
large document); the diagnostic still arrives on result (never a
rejection — destroying output early behaves the same way), and bytes
already emitted on output before that point are the caller's to discard.
Evaluates a standalone XPath 3.1 expression, with or without a context document.
await evalXPath({ expression: "1 + 2" }); // value: "3"
await evalXPath({ expression: "/root/@name/string()", source: "<root name='x'/>" });Validates an XML instance against one or more XSD 1.0/1.1 schemas.
await validate({
schemas: [schemaText],
instance: xmlText,
version: "1.1", // default "1.0"
});Pre-loads the child-process pool, so that startup cost happens up front rather than on the first call. Every function above lazily loads it on first use anyway.
Terminates the pool's child processes. Not needed for normal use — pool children are unref'd and never keep a process alive on their own, and a script or server can just exit normally — but useful when something wants deterministic, immediate shutdown instead (a short-lived CLI, a test suite).
Shorthand for result.diagnostics.some(d => d.severity === "error"), mirroring
the Go engine's own Result.HasErrors().
Full request/response shapes (including Diagnostic, SecondaryOutput,
XPathItem) are exported as TypeScript types — see dist/index.d.ts, or the
go-xslt engine package
these mirror field-for-field.
This package doesn't reimplement any XSLT/XPath/XSD logic — it's a thin
bridge over the compiled Go engine (JSON in/out for transform/validate/
evalXPath; raw bytes for transformStream's chunks). Anything the
underlying go-xslt engine doesn't support, this package doesn't support
either; see that repository's README for XSLT/XPath/XSD conformance status.
| x64 | arm64 | |
|---|---|---|
| macOS | ✓ | ✓ |
| Linux (glibc) | ✓ | ✓ |
| Windows | ✓ | ✓ |
The Go library is compiled to one native shared library per platform
(.dylib/.so/.dll). All six ship inside the one package, under
dist/native/goxslt-<platform>-<arch>.<ext>, and src/loader.ts picks the
one for process.platform/process.arch. The trade-off against separate
per-platform packages is install size: every install downloads all six
(roughly 9 MB each unpacked) instead of just its own. If neither exists for
the current machine you get an error naming the platform.
- Linux musl (Alpine) is not supported: the libraries are built against glibc (on Ubuntu 22.04 runners, to stay loadable on as many distributions as possible).
native/ is a small Go program (a separate module, depending on
github.com/tim-riep/go-xslt) built with go build -buildmode=c-shared,
exporting a plain C ABI: Transform/Validate/EvalXPath (JSON string in,
JSON string out, freed by the caller via an exported Free), and a
streaming counterpart for TransformTo — TransformToStart/Next/
Result/Free — described below. The Go library itself stays cgo-free;
only this thin export wrapper needs it.
On the Node side, each pool worker (src/child.ts) is a real
child_process, not a worker_threads.Worker, and every koffi call in it
is synchronous, never .func.async(). Both of those are load-bearing,
found the hard way:
- koffi's own
.func.async()leaves its internal worker-pool threads spinning at close to 100% CPU per thread indefinitely after the call completes — reproduced with nothing but one async call against this library, no pool or streaming involved. - Loading this same native library inside a
worker_thread— even calling it purely synchronously, even afterunref()-ing the worker — leaves the process unable to exit on its own. Reproduced with nothing butkoffi.load()+ one sync call in a worker and no code of ours involved; the identical script run as a real child process exits cleanly in well under a second.
So: every call is synchronous, and always runs inside a forked child
process, dispatched from src/loader.ts's small pool over that child's IPC
channel (process.send/process.on('message')).
One more thing had to be right for the pool to actually let Node exit
naturally when idle: child.unref() alone doesn't cover the IPC channel.
ChildProcess#unref()/#ref() toggle whether the process handle counts
toward keeping the event loop alive; the IPC channel underlying
send()/on('message') is a separate handle (child.channel) with its
own independent ref-count that child.unref() does not touch. Skip it and
a pool child with a persistent message listener (which every pool child
has, so it can be reused for more than one call) keeps the process alive
forever even once fully idle and unref()'d — reproduced with a single
forked child, no pool involved. src/loader.ts's refChild()/unrefChild()
toggle both together.
(A related, narrower gotcha specific to this package's own test suite, not
its public behavior: node:test's test() wrapping so much as one
awaited call to this library — no assertions, nothing else in the file —
reproducibly hangs 30–70+ seconds, spinning CPU, before the process exits.
The identical call without the node:test wrapper exits in well under a
second. test/harness.mjs is a ~20-line stand-in that sidesteps it; nothing
here indicates real programs using this package hit the same thing, since
they don't run through node:test.)
The obvious design for transformStream — Go calls back into JS once per
chunk, blocking on the callback's return value for backpressure — doesn't
work here: a koffi callback invoked from a background thread only
synchronizes up to the point the JS function itself returns, which for an
async function is its first await, not its eventual resolution. Using
that for backpressure would hand the native side a not-yet-resolved value
and carry on regardless — exactly the class of bug the synchronous-only
rule above exists to avoid.
Instead, streaming is pull-based: TransformToStart runs the transform
on its own goroutine, writing each chunk into a small buffered Go channel
(this is what backpressure actually is here — the channel send blocks when
full, an ordinary goroutine-to-goroutine handoff); TransformToNext pulls
one chunk at a time; TransformToResult fetches the final diagnostics/
metadata once Next reports done; TransformToFree cancels (if not
already finished) and releases the handle — always called exactly once,
whether the stream was read to completion or abandoned early, since an
abandoned stream's goroutine would otherwise block forever on an unread
channel send. Nothing in this path ever calls back into JS.
Requires Go ≥ 1.25, a C compiler (cgo, for native/ only — the underlying
Go library stays cgo-free), and Node ≥ 20.
npm install
npm run build # tsup (TS -> dist/{index,child}.{js,cjs,d.ts}) + go build -> dist/native/*
npm testnpm test runs each test/*.test.js file directly with plain node (not
node --test — see the node:test gotcha above) and lists every failing
file at the end. The build and test entry points are Node scripts rather than
shell one-liners so they behave the same on Windows.
.github/workflows/_build.yml— reusable; shared by the two below. cgo can't reliably cross-compile, so every target is built on a native runner (ubuntu-22.04,ubuntu-22.04-arm,macos-15,macos-15-intel,windows-2022,windows-11-arm; Windows-on-ARM gets llvm-mingw as its C compiler). Each library is uploaded as an artifact, then the test suite runs against that exact artifact on that same platform, per Node version..github/workflows/ci.yml— on every push tomain, every PR, and on demand: the above with Node 22 and 24 (6 platforms × 2 = 12 test jobs), plus the type-check..github/workflows/release.yml— on every pushed tag matchingv*: verifies the tag matchespackage.json'sversion, runs the above (Node 22), downloads the six CI-built libraries intodist/native/, checks all six are there (scripts/check-natives.mjs, which is also theprepublishOnlyguard — a publish from a checkout that only built its own platform refuses), publishes the one package, and creates a GitHub release.
# 1. bump "version" in package.json to match the tag you're about to push
npm version 0.2.1 --no-git-tag-version
git commit -am "release v0.2.1"
git push
# 2. tag and push — this triggers the release workflow
git tag v0.2.1
git push origin v0.2.1Publishing uses npm's trusted publishing (OIDC) rather than a long-lived
NPM_TOKEN secret: the workflow gets id-token: write permission, and the
npm CLI (pinned to >=11.5.1 in the workflow — the minimum that supports
this) exchanges GitHub's OIDC token for a short-lived npm auth token at
publish time. Provenance attestations come along automatically as part of
that same exchange. The matching Trusted Publisher (repo tim-riep/node-xslt,
workflow release.yml) is configured on the package's npmjs.com settings page,
which is the only npm-side setup there is.
Apache-2.0, see LICENSE. This package bundles a compiled build of go-xslt (Apache-2.0) — see NOTICE.