runtime-artifacts
v0.1.0
Published
Compress code into artifact blobs, download them at runtime, and materialize runnable Node.js apps on the fly.
Maintainers
Readme
runtime-artifacts
Compress code into artifact blobs, ship them to blob storage, then import them transparently at runtime via
rta://URLs — with capability-scoped sandboxing, atomic hot-swap, differential manifests, and on-the-fly TypeScript transpilation.
Why this library exists
This is a fair question — the previous version of this library was
essentially "npm pack + S3 SDK + zlib + require()", which doesn't
justify existing on its own. The current version is built around six
capabilities that no single existing Node library provides together:
| Capability | runtime-artifacts | ncc / esbuild | import-http | isolated-vm | Docker / Lambda Layers | @node-loader/http |
| --------------------------------- | ----------------- | ------------- | ----------- | ----------- | ---------------------- | ----------------- |
| Transparent rta:// ESM imports | ✅ | ❌ (build-time) | ✅ (URL only) | ❌ | ❌ | ⚠️ (low-level) |
| Compression (brotli/gzip/deflate) | ✅ | ❌ | ❌ | ❌ | ✅ (image layers) | ❌ |
| SHA-256 integrity verification | ✅ | ❌ | ❌ | ❌ | ✅ (digests) | ❌ |
| On-disk content-addressed cache | ✅ | N/A | ❌ | N/A | ✅ (layer cache) | ❌ |
| Capability-scoped sandboxing | ✅ | ❌ | ❌ | ✅ (alone) | ✅ (cgroups) | ❌ |
| Atomic hot-swap (in-flight safe) | ✅ | ❌ | ❌ | ❌ | ✅ (k8s rolling) | ❌ |
| Differential manifests | ✅ | ❌ | ❌ | ❌ | ✅ (layer diff) | ❌ |
| On-the-fly TS transpilation | ✅ | ❌ (build-time) | ❌ | ❌ | ❌ | ❌ |
| Per-function unit splitting | ✅ | ❌ | ❌ | ❌ | ❌ (layer-grained) | ❌ |
If you only need one of these capabilities, use a specialized tool:
- Bundle an app into one file at build time →
@vercel/ncc,esbuild,rollup - Run untrusted JS safely in a separate V8 isolate →
isolated-vm - Import ESM modules from HTTP URLs →
@node-loader/http(then add your own integrity + caching) - Package a whole app with OS deps → Docker, OCI images
- Ship runtime code layers on AWS → Lambda Layers
If you need two or more of these together — for example, shipping
plugins to a long-running server that need to be sandboxed, hot-swappable,
integrity-verified, and bandwidth-efficient — runtime-artifacts is
the only Node library that does the whole job.
The six unique capabilities
1. Transparent rta:// ESM imports
// app.mjs
import "runtime-artifacts/loader-register"; // or: --import runtime-artifacts/loader-register
const handler = (await import("rta://[email protected]/index.js")).default;
console.log(handler("world"));The loader hook intercepts rta:// URLs, fetches the manifest from
the configured blob provider, downloads the requested entry's blob,
decompresses it, verifies its SHA-256, and returns the source to
Node's module system — all in-process, all cached. No
materialize-to-disk-then-require round trip.
2. Capability-scoped sandboxing via isolated-vm
import { SandboxRunner } from "runtime-artifacts";
const runner = new SandboxRunner();
const { result } = await runner.run(materializedApp, manifest, {
capabilities: {
allowedModules: ["crypto"], // only crypto; no fs, no child_process
fs: { allowRead: ["/data/in/"] }, // reads allowed only from /data/in/
http: { allowHosts: ["api.example.com", "*.cdn.com"] },
timeoutMs: 5000,
memoryMB: 128,
},
args: ["input"],
});isolated-vm runs code in a separate V8 isolate with its own heap.
Unlike Node's vm module (which the Node docs explicitly say is not
a security boundary), isolated-vm doesn't share process, require,
or any host global unless you explicitly bridge them. The
SandboxRunner layer adds capability-based access control on top — a
guest can't require("child_process") unless you whitelist it, and
require("fs") returns a wrapper that rejects reads outside the
allowed paths.
3. Atomic hot-swap with reference counting
import { AppRegistry } from "runtime-artifacts";
const registry = new AppRegistry(provider);
await registry.install(v1Manifest);
const handle = registry.acquire("my-app"); // refcount on v1 = 1
await registry.swap(v2Manifest); // new acquires → v2
// In-flight handle still uses v1:
await handle.invoke("x"); // runs v1
// New acquire uses v2:
const h2 = registry.acquire("my-app");
await h2.invoke("x"); // runs v2
handle.release(); // v1 refcount → 0 → GC after graceThe registry keeps multiple versions of the same app alive
simultaneously. swap() is atomic for new acquires; existing handles
keep their pinned version. Old versions are GC'd after a configurable
grace period once their refcount hits zero. This is the primitive you
need for hot-reloadable plugin systems and zero-downtime deploys
within a single long-running process.
4. Differential manifests
import { computeDelta, resolveDelta } from "runtime-artifacts";
// Ship only the changed blobs between v1 and v2:
const delta = computeDelta(v1Manifest, v2Manifest);
// { base: "[email protected]", delta: { add: [...], remove: [...] } }
// Client resolves the delta against the cached base:
const resolved = await resolveDelta(deltaManifest, provider);
// resolved.entries = v1.entries + delta.add − delta.removeA delta manifest declares a base and a list of add/remove operations.
Clients that already have the base cached only download the changed
blobs. For a 10 MB app with a 200 KB hotfix, that's a 50× bandwidth
reduction on the wire vs shipping a full new manifest.
5. On-the-fly TypeScript / JSX transpilation
// Ship raw TypeScript — no build step:
const { manifest } = await packAndPush({
provider, name: "ts-plugin", version: "1.0.0",
entry: "src/index.ts",
srcDir: "./src",
});When the loader encounters a .ts / .tsx / .mts blob, it
transpiles to JavaScript via esbuild (lazy-loaded) before handing
the source to Node. .jsx is transpiled to CJS. Transpiled output is
cached by source SHA-256, so the cost is paid once per artifact
version. If esbuild isn't installed, a regex-based type-strip
fallback handles the common cases (with a loud warning).
6. Fine-grained module splitting (per-function updates)
// Pack with split: true — each function/class/const becomes its own blob.
const { manifest: v1 } = await packAndPush({
provider, name: "api", version: "1.0.0",
entry: "index.js", srcDir: "./src",
split: true, // ← cut JS/TS into per-export units
});
// ...later, change one function and pack v2:
const { manifest: v2 } = await packAndPush({
provider, name: "api", version: "1.0.1",
entry: "index.js", srcDir: "./src",
split: true,
});
// The unit-level diff shows exactly what changed:
import { computeUnitDiff, summarizeUnitDiff, fetchDeltaBytes } from "runtime-artifacts";
const diffs = computeUnitDiff(v1, v2);
const summary = summarizeUnitDiff(v1, v2, diffs);
// summary.changedUnits === 1 (only the function you edited)
// summary.unchangedUnits === 49 (every other function, cached)
// summary.savingsRatio === 0.97 (97% bandwidth saved on this update)
// Only the changed unit's blob is fetched from the provider:
const delta = await fetchDeltaBytes(v1, v2, provider, cache);
// delta.unitsFetched === 1
// delta.unitsCached === 49When split: true is passed to packAndPush, every JS/TS source file
is parsed with acorn and split
into independently-addressable units:
__head__— imports + side effects (always required)<ExportName>— one unit per named export (export function,export class,export const)__default__— the default export
Each unit becomes its own compressed blob in storage, with its own SHA-256 hash. When a single function changes, only that function's blob is re-fetched by clients — typically a few hundred bytes versus the file's full compressed size.
For a file with 50 exports where you tweak one function, that's a ~50× bandwidth reduction on the wire compared to file-level delta (which would re-download the entire file). For large generated files (OpenAPI clients, Prisma outputs, protobuf stubs) this is the difference between a 2-second cold-start and a 50-millisecond incremental update.
CLI:
# Pack with --split
rta pack --name api --version 1.0.0 --src ./src --provider file:///tmp/blobs --split
# See what changed between two versions (unit-level)
rta diff --old manifest-v1.json --new manifest-v2.json
# Output:
# Entries: 2 total, 0 added, 0 removed, 1 unchanged
# Units: 6 total, 1 changed (need fetch), 5 unchanged (cache hit)
# Bandwidth: 86 bytes to fetch / 491 total (82.5% saved)
# ~ index.js [~greet | 4 cached]
# = package.json (whole-file, unchanged)Reassembly: the downloader fetches each unit (cache-first by SHA-256), verifies each unit's integrity, decompresses, and concatenates them in unit order to reconstruct the original source. A final SHA-256 check on the assembled source catches any reassembly bug or unit tampering.
Non-JS files (JSON, assets, etc.) are never split — they go through the normal whole-file code path.
Quick start
Library
import {
packAndPush, AppRegistry, MemoryBlobProvider,
} from "runtime-artifacts";
const provider = new MemoryBlobProvider();
const { manifest } = await packAndPush({
provider, name: "hello", version: "1.0.0",
entry: "index.js", srcDir: "./app",
});
const registry = new AppRegistry(provider);
await registry.install(manifest);
const handle = registry.acquire<{ default: (s: string) => string }>("hello");
const result = await handle.invoke("world");
handle.release();Transparent loader
# Terminal 1: pack and publish
rta pack --name hello --version 1.0.0 --entry index.js \
--src ./app --provider file:///tmp/blobs \
--out ./manifest.json
# Terminal 2: import as if it were local
RTA_PROVIDER=file:///tmp/blobs RTA_CACHE=./.cache \
node --import runtime-artifacts/loader-register ./app.mjs// app.mjs
const mod = await import("rta://[email protected]/index.js");
console.log(mod.default("world"));CLI
rta pack --name X --version Y --src ./dir --provider file:///tmp/blobs --out manifest.json
rta pull --manifest manifest.json --provider file:///tmp/blobs --out ./materialized --cache ./.cache
rta run --manifest manifest.json --provider file:///tmp/blobs --cache ./.cache -- args...
rta info --manifest manifest.json
rta cache --root ./.cache stats|clearArchitecture
┌─────────────────┐ ┌──────────────────┐
│ Source tree │ packAndPush() │ Blob provider │
│ ./app/*.ts │ ───────────────────▶ │ S3 / Azure / │
│ │ compress + upload │ HTTP / local / │
└─────────────────┘ │ memory │
▲ └──────────────────┘
│ ▲
┌─────────────────┐ ESM loader hook │
│ import │ ─────────────────────────────────┘
│ "rta://..." │ fetch + decompress + verify
└─────────────────┘ + transpile (TS/JSX)
│
▼
┌─────────────────────────────────────────────────┐
│ In-process registry │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ v1.0.0 │ │ v1.0.1 │ │ v1.0.2 │ │
│ │ refcnt 1│ │ refcnt 0│ │ refcnt 3│ ←swap() │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ▲ ▲ │
│ └── acquire() ──────────┘ │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ SandboxRunner │ (isolated-vm when available)
│ cap-scoped │ capability: { fs, http, modules, ... }
│ require │
└─────────────────┘Module layout
| File | Responsibility |
| --------------------------- | ------------------------------------------------------------ |
| src/loader.ts | Node ESM loader hook — rta:// URL resolution + source loading |
| src/loader-register.ts | Side-effect import that registers the loader via module.register() |
| src/sandbox.ts | isolated-vm runner with capability-scoped require, fs, http |
| src/registry.ts | In-process registry with atomic hot-swap + refcount GC |
| src/diff.ts | Differential manifests: compute + resolve deltas against base, unit-level diff + summary |
| src/splitter.ts | AST-based module splitter — cuts JS/TS into per-export units |
| src/transpile.ts | On-the-fly TS/JSX → JS via esbuild (with regex fallback) |
| src/compress.ts | gzip / deflate / brotli codecs, buffers + streams |
| src/manifest.ts | Manifest schema, validation, self-signing, SplitUnit type |
| src/cache.ts | On-disk SHA-256-addressed cache with shard subdirs |
| src/blob/index.ts | BlobProvider interface + createBlobProvider() factory |
| src/blob/{memory,local,http,s3,azure}.ts | Provider implementations |
| src/downloader.ts | Concurrent fetcher + cache + integrity verification + per-unit materialization |
| src/factory.ts | AppFactory — materialize + invoke (basic, for non-sandboxed use) |
| src/pack.ts | packAndPush() — compress + upload + emit manifest + optional split |
| bin/cli.ts | rta CLI (pack/push/pull/run/info/diff/cache) |
Manifest schema
{
"schema": 1,
"id": "[email protected]",
"name": "hello",
"version": "1.0.0",
"createdAt": "2025-01-01T00:00:00.000Z",
"entries": [
{
"path": "index.js",
"blobName": "[email protected]/index.js",
"codec": "brotli",
"sha256": "96bdd857d2a0ec3f095075a6f3373df894fd571548f10e321fc4dee21781f9b6",
"size": 110,
"compressedSize": 94
}
],
"entry": "index.js",
"entryExport": "default",
"env": { "NODE_ENV": "production" },
"manifestSha256": "e2ff194f2b7180198dc1124251ce938c0a57e213cbfcc65a9a21ba2ba8799983"
}entries[].sha256is the SHA-256 of the compressed blob bytes — verified on every download.manifestSha256is the SHA-256 of the manifest's canonical JSON (sorted keys, no whitespace, excluding the field itself). The downloader rejects tampered manifests before fetching any blobs.
Delta manifest extension
{
"schema": 1,
"id": "[email protected]",
"name": "app",
"version": "1.0.1",
"base": "[email protected]",
"delta": {
"add": [ { "path": "index.js", ... } ],
"remove": [ "old/deleted.js" ]
},
"entry": "index.js",
...
}Split-entry extension (units)
When an entry is packed with split: true, it carries a units
array describing the per-export blobs. The entry's own sha256
becomes the SHA-256 of the assembled (decompressed + concatenated)
source, used as a final integrity check after reassembly:
{
"path": "index.js",
"blobName": "[email protected]/index.js",
"codec": "brotli",
"sha256": "<sha256 of the reassembled source>",
"size": 1100,
"compressedSize": 480,
"units": [
{ "name": "__head__", "codec": "brotli", "sha256": "...", "size": 200, "compressedSize": 110,
"blobName": "[email protected]/index.js::__head__" },
{ "name": "greet", "codec": "brotli", "sha256": "...", "size": 80, "compressedSize": 72,
"blobName": "[email protected]/index.js::greet" },
{ "name": "multiply", "codec": "brotli", "sha256": "...", "size": 60, "compressedSize": 58,
"blobName": "[email protected]/index.js::multiply" },
{ "name": "__default__","codec": "brotli", "sha256": "...", "size": 120, "compressedSize": 90,
"blobName": "[email protected]/index.js::__default__" }
]
}When units is present, the downloader does NOT fetch
[email protected]/index.js as a single blob. Instead, it fetches each unit
individually (cache-first by SHA-256), decompresses, and concatenates
them in array order to reconstruct the source.
Blob providers
| Spec | Backend | Notes |
| ----------------------------- | ----------------------------------- | --------------------------------------- |
| memory:// | In-process Map | Tests / ephemeral; not shared |
| file:///abs/path | Local filesystem | Path-traversal protected |
| https://host/prefix | Generic HTTP(S) blob server | GET/HEAD; PUT/DELETE if allowWrite |
| s3://bucket/prefix | AWS S3 | Requires @aws-sdk/client-s3 |
| azure://acct/cont/prefix | Azure Blob Storage | Requires @azure/storage-blob |
import { createBlobProvider } from "runtime-artifacts";
const p = await createBlobProvider("s3://my-bucket/apps/", {
region: "us-east-1",
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});When to use this (and when not to)
Use it when:
- You run a long-lived Node process and want to ship code updates to it without restart (plugins, hot-fixes, A/B tests).
- You need to run semi-trusted third-party code and want actual sandboxing (not
vm). - You're bandwidth-constrained (edge / IoT) and want differential updates between versions.
- You want to ship raw TypeScript source without a build step.
- You need any two of: integrity verification, content-addressed caching, hot-swap, sandboxing, compression.
Don't use it when:
- You just need to bundle a Node app at build time → use
@vercel/nccoresbuild. - You just need to run untrusted JS safely → use
isolated-vmdirectly. - You're on AWS Lambda and just need code layers → use Lambda Layers.
- You need to package OS-level dependencies → use Docker.
- You're building a single deployable artifact for a CI pipeline → use a tarball +
npm install.
Install
npm install runtime-artifacts
# Pick the optional peer deps you need:
npm install esbuild # for TS/JSX transpilation
npm install isolated-vm # for real sandboxing (native module)
npm install @aws-sdk/client-s3 # for S3BlobProvider
npm install @azure/storage-blob # for AzureBlobProviderRequires Node.js ≥ 18.17. Loader hooks require Node ≥ 20.6 (for module.register()).
License
MIT
