tgcloud-vendor-wasm
v0.2.2
Published
Pack a WebAssembly library into deployable modules for Telegram Serverless
Readme
vendor-wasm
Pack a WebAssembly library into deployable modules for Telegram Serverless
(V8 isolate: no npm, no filesystem, static imports only). Compatible with
@tgcloud/cli 0.2.0: everything lives under tgcloud/, project modules import
each other by relative path with the .js extension.
Why
The platform deploys only .js files (tgcloud/schema.js, tgcloud/lib/,
tgcloud/handlers/, tgcloud/endpoints/) — verified:
a real .wasm placed in lib/ is invisible to tgcloud status. And at runtime there is
no filesystem to read it from anyway. This tool embeds the binary as base64 chunks inside
.js modules and generates the wiring that the isolate actually supports:
- embedded
wasmBinary(no fetch of a.wasmURL — andURLdoesn't exist there anyway) - fully synchronous instantiation (
new WebAssembly.Module+new WebAssembly.Instance), because asyncWebAssembly.instantiatenever resolves in the isolate (hangs ~32s) - no
atob/Buffer(a dependency-free base64 decoder is generated) - static
imports only (import()throwsNot supported)
Requirements
Node.js 18+. A project scaffolded (or npx tgcloud upgraded) for @tgcloud/cli
0.2.0 — modules under tgcloud/.
Install
npm i -g tgcloud-vendor-wasmQuick start
Run from the project root:
npm i @sqlite.org/sqlite-wasm
vendor-wasm --from @sqlite.org/sqlite-wasm --out tgcloud/libFinds the .wasm, scores glue candidates, pulls relative deps transitively (including
package entries), auto-vendors clean ESM bare imports, prints the equivalent manual
command. Cross-package API layers still need a hand — see Limitations.
Manual mode (* globs allowed in paths — handy for hashed build files like chunk-*.mjs):
vendor-wasm --name mylib --wasm mod.wasm --loader glue.mjs \
--extra api.mjs:api.js \
--map "@pkg/types:./ffi.js" \
[--out tgcloud/lib] [--split 80000] [--mode emscripten|raw] [--modules node_modules]| Flag | Meaning |
| ---- | ------- |
| --from pkg | Auto-discover inside installed package (repeatable) |
| --name | Target folder: lib/<name>/ (required unless discovered) |
| --wasm | The .wasm binary → wasmP1..N.js + wasm.js aggregator (required) |
| --loader | ESM glue factory (Emscripten MODULARIZE) → loader.js (required in emscripten mode) |
| --extra src[:dest] | Extra ESM files, imports rewritten (repeatable) |
| --map from:to | Rewrite a bare import specifier; to is relative (./ffi.js). The pre-0.2 lib/<name>/ffi form is still accepted and normalized (repeatable) |
| --out | Output root (default tgcloud/lib) |
| --split | Base64 chars per chunk (default 80000) |
| --mode | emscripten (default) or raw (prints the module's imports so you can write stubs) |
| --modules | node_modules root (default node_modules) |
| --deflate | Deflate the wasm before base64 (u32LE len + raw-deflate, decoded in-isolate by a 9 KB vendored decoder) — ~2x smaller modules |
The tool aborts on CommonJS, on zero/ambiguous .wasm or glue matches (it lists candidates
instead of guessing), warns on require() / dynamic import() / .wasm path references /
a missing default export in the loader, syntax-checks every generated file and verifies
the base64 roundtrip.
How discovery scores glue
Content + filename heuristics: wasmBinary / WebAssembly.* / instantiate* /
default export / module|loader|glue|esm|browser|wrapper|factory score up; CJS markers,
node-ish names, worker (no Worker in the isolate), test/example files score down.
Ties break toward the plainer filename. Run with --loader to override the pick.
Using the output (emscripten mode)
From a handler or endpoint (relative import with .js, as CLI 0.2.0 requires):
// tgcloud/handlers/message.js
import { getModule } from '../lib/mylib/index.js';
const M = await getModule(); // singleton, sync-instantiated moduleThere is no directory-index resolution — import the entry by its full relative
path (../lib/mylib/index.js, not ../lib/mylib). Inside the vendored folder
every cross-file import is likewise relative (./loader.js, ./wasm.js).
With --deflate, index.js inflates the
payload first (framedToBytes); b64ToBytes stays exported for hand wiring
(e.g. passing wasmBinary to a factory that accepts it).
Isolate notes: relative imports with .js only; no URL, atob, setTimeout, performance,
import(); give the guest a compute budget (interrupt handler) and a memory limit
(see lib/code.js in this repo for a complete example with QuickJS).
Tested libraries
Shipped presets, all verified end-to-end on the pre-0.2 runtime (bare,
extensionless imports); the 0.2.0 output emits the same bytes as relative
./x.js imports — import graph, base64 roundtrip and factory wiring re-verified
locally, live-isolate re-check recommended after the platform update:
quickjs— QuickJS engine trio (503 KB wasm, 15 files)sqlite—@sqlite.org/sqlite-wasm,--fromjust works (869 KB, 18 files)tree-sitter— release build (210 KB, 7 files); no default export, soindex.jsneeds hand wiring (warned)wasqlite—wa-sqlitesync build +MemoryVFS/API extras (558 KB, 17 files;--deflate: 365 KB payload, verified live SQL in the isolate)unrar—node-unrar-jsESM chain with awasmBinaryAPI (208 KB, 13 files)sevenzip—7z-wasmfactory (--deflate: 988 KB total, instantiates live with workingFS/callMain; raw 1.65 MB doesn't fit the ~1 MB cap)qoi—@jsquash/qoiencode build (14 KB wasm, 6 files, ~57 KB; the--mapis load-bearing: the wrapper imports the glue by its source path, remapped to./loader.js). Encode-only; a decode twin is the same preset withenc→decandencode.js→decode.js. Two consumer paths, both verified:getModule()thenmod.encode(pixels, w, h), or the library's owninit(new WebAssembly.Module(b64ToBytes(wasmB64)))+ defaultencode({ data, width, height })(no fetch on either path; realqoifoutput)jpegdec+jpegenc—@jsquash/jpegdecode/encode pair, Emscripten builds that just work with the default entry (verified live pipeline: JPEG → RGBA → center-crop + grayscale in plain JS → JPEG q80 → decode again;ffd8throughout). ~270 KB together with--deflate(always pack this pair deflated). Consumer path (raw module calls — the wrappers'init()needsURL, which the isolate lacks):getModule(), thendec.decode(jpegBytes, false)/enc.encode(data, w, h, { ...defaultOptions, quality })withdefaultOptionsimported from the packedmeta.js; shimImageDatafirst (the glue polyfills it only under Node/CF Workers, butdecodeconstructs one)
Also checked, no preset: esbuild-wasm, @dqbd/tiktoken and wasm-flate pack but need
--mode raw + hand stub (Go / wasm-bindgen runtimes); @jsquash/png also needs
raw + stub (initSync + an ImageData shim — proven in Node, just not shipped);
@cf-wasm/photon packs with --deflate (976 KB, initSync hand-wire, JPEG-out
only — too close to the ~1 MB cap to ship); @imagemagick/magick-wasm packs but
is oversized for deploy (15 MB), as is wasm-vips (5 MB wasm + pthreads, no sync
path); hash-wasm and xxhash-wasm need no packing (self-contained
ESM — deploy directly). Dead ends by cause: CJS/UMD-only glue (sql.js, argon2-browser,
re2-wasm, jq-web, vscode-oniguruma, @squoosh/lib, wawoff2, @bokuweb/zstd-wasm);
fetch/URL/atob/WASI at init or multi-asset (pglite, rapier2d-compat, automerge,
lightningcss-wasm, loro-crdt, harper.js).
Rule of thumb: Emscripten MODULARIZE with a default-exported factory packs; CJS glue aborts;
Go/wasm-bindgen need raw + stub.
Limitations
- Total module space is capped at roughly 1 MB (measured pre-0.2 via
run: ~1006 KB works, ~1026 KB fails withInvalid module name). Keep packed output well under it — that is what--deflateis for (wa-sqlite: 836 → 505 KB, 7z: 2.2 MB → 988 KB and runnable; sqlite's 642 KB loader alone eats most of the budget, so it still doesn't fit). - API-layer files from other packages (like QuickJS's core chunk) are out of scope for
discovery — the tool lists shipped-but-unincluded files so you can add them via
--extra. - Single-threaded, non-WASI builds only: no threads, no
wasi_snapshot_preview1shims. Preferbrowser/worker-flavored ESM glue overnode/debug variants. - Debug builds score high (they contain every marker) — override with
--loaderif you want the release file. - Verified live in the isolate: wa-sqlite runs real SQL (SQLite 3.44.0) and 7z
instantiates with a working
FS/callMain. AsyncWebAssembly.instantiatehangs there (use the generated sync hook); there is noatob/URL/setTimeout/performance/crypto, andimport.metais an empty object. Keep handler modules free of top-levelawait.
Contributing presets
Shipped presets: quickjs, sqlite, tree-sitter, wasqlite, unrar, sevenzip, qoi, jpegdec, jpegenc.
Got a library that needs hand-written flags? Send a PR adding presets/<name>.json:
{
"name": "qjs",
"wasm": "@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm",
"loader": "@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs",
"extra": [
"quickjs-emscripten-core/dist/chunk-*.mjs:core.js",
"@jitl/quickjs-ffi-types/dist/index.mjs:ffi.js",
"@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs:ffiWasm.js"
],
"map": ["@jitl/quickjs-ffi-types:./ffi.js"]
}Paths are relative to --modules. Explicit flags win.
Use it via --preset <name> (or a direct JSON path). Please verify the output with the
tool's own checks plus a real --from/manual run before submitting.
Upgrading from vendor-wasm 0.1.x (pre-0.2 serverless)
- Run
npx tgcloud upgradeonce to moveschema.js/lib//handlers/intotgcloud/. - Re-run the tool with
--out tgcloud/lib(the new default). Generated imports changed from bare (lib/<name>/loader, extensionless) to relative (./loader.js, with extension), per the October 2026 platform update. - In your handlers/endpoints, import the entry relatively:
import { getModule } from '../lib/<name>/index.js'. - Old
--maptargets (lib/<name>/ffi) still work — they are normalized to./ffi.js— but prefer writing the relative form.
License
MIT — see LICENSE. Replace the copyright holder before publishing.
