TYNDPKG Format
Every Tynd binary is self-extracting. tynd build appends a packed section to the host executable; at launch, the host reads its own tail, extracts assets to a temp directory, and runs the app.
Wire format
┌──────────────────────────────────┐
│ host binary │ (tynd-full or tynd-lite)
│ ... │
├──────────────────────────────────┤
│ [file_count: u32 LE] │ ← TYNDPKG trailer starts here
│ per file: │
│ [path_len: u16 LE] │
│ [path: UTF-8] │
│ [data_len: u32 LE] │
│ [data: bytes] │
│ ... │
│ [section_size: u64 LE] │
│ [magic: "TYNDPKG\0"] │ ← last 8 bytes of the file
└──────────────────────────────────┘At startup, the host seeks to the last 8 bytes, verifies the TYNDPKG\0 magic, reads section_size to find the trailer offset, then iterates the file list.
What gets packed
Frontend assets
Read from frontendDir (default dist/). Text files auto-compressed with zstd:
- Extensions compressed:
html|htm|js|mjs|cjs|css|json|svg .zstis appended to the packedrelpath (index.html→index.html.zst)- Binary assets (
png,jpg,woff2,wasm, …) are packed raw
Bun uses node:zlib.zstdCompressSync at pack time. Rust decompresses with the zstd crate when the asset is first requested (result cached in memory per launch).
Backend bundle
lite—bundle.jspacked raw (not zstd). QuickJS reads it directly; a decompress step would add latency and memory.full—bundle.dist.jspacked raw as well (Bun reads it from the extracted path).
Bun (full mode only)
Two entries, in this order (order matters — Rust reads bun.version first to decide cache path before reading bun.zst):
bun.version— text file, the Bun version used at pack time.bun.zst— zstd-compressed Bun binary (Bun.versionfound at pack time on PATH).
At launch, the full host extracts bun.zst to a versioned cache dir (<data_dir>/bun/<version>/bun[.exe]), reusing the already-extracted copy on subsequent launches.
Sidecars
Declared in tynd.config.ts:
sidecars: [
{ name: "ffmpeg.exe", path: "bin/ffmpeg.exe" },
]Packed under the sidecar/ prefix (sidecar/ffmpeg.exe). At launch, the host extracts each one to <temp_dir>/sidecar/<name>, chmods it +755 on Unix, and registers the path in os::sidecar. Your TypeScript retrieves the path via sidecar.path("ffmpeg.exe").
Extraction flow
Full mode
- Read TYNDPKG trailer.
- Extract
bundle.dist.jsto<temp>/bundle.dist.js. - Extract Bun binary to
<data_dir>/bun/<version>/bun[.exe](cached across launches). - Extract frontend assets to an in-memory cache (pre-warmed on a background thread before the WebView is built).
- Extract sidecars to
<temp>/sidecar/<name>. - Spawn Bun with
bundle.dist.jsas the entry,TYND_ENTRY/TYND_FRONTEND_DIR/TYND_DEV_URLenv vars. - Wait for the backend’s
{ type: "tynd:config" }first line on stdout. - Build the WebView with the config.
Lite mode
- Read TYNDPKG trailer.
- Extract frontend assets to the in-memory cache.
- Extract sidecars to
<temp>/sidecar/<name>. - QuickJS eval’s
bundle.js(from memory — never written to disk). - Read
globalThis.__tynd_config__after eval to get window / menu / tray config. - Build the WebView.
Platform tweaks
Windows — PE patching
Two post-processing steps run on Windows .exe files:
patchPeSubsystem— flips the PE subsystem fromIMAGE_SUBSYSTEM_WINDOWS_CUI(console) toIMAGE_SUBSYSTEM_WINDOWS_GUI(no console window on launch).setWindowsExeIcon— embeds a multi-size ICO (16/32/48/256) as a Win32 resource via ResEdit. For full mode, the same ICO bytes are also embedded into the inner Bun copy before zstd compression, so Task Manager shows the app icon for the Bun subprocess.
Icon rendering
Single source of truth: one file in public/ (SVG preferred, PNG or ICO accepted).
- Windows ICO — sizes
[16, 32, 48, 256]viarenderIconPngSet→pngToIco. - macOS ICNS — sizes
[32, 128, 256, 512, 1024]. - Linux hicolor — sizes
[16, 32, 48, 64, 128, 256, 512]dropped intousr/share/icons/hicolor/<n>x<n>/apps/<name>.pngfor.deb/.rpm/.AppImage.
PNG source degrades to single-size (native resolution). ICO source passes through to Windows bundles and is skipped (with a warning) for macOS/Linux.
Non-square SVGs are wrapped in a square viewBox before rasterising — Windows PE and macOS ICNS reject or distort non-square inputs.
Reading the trailer programmatically
If you want to inspect a packed binary:
# Last 8 bytes must be TYNDPKG\0
tail -c 8 release/my-app.exe | xxd
# Full trailer layout — parse the last (8 + 8) bytes to get section_size,
# then read the last (8 + 8 + section_size) bytes and walk the file list.The Rust reader is packages/{full,lite}/src/embed.rs. Both runtimes share the wire format.
Size guidance
| Component | Size (zstd where applicable) |
|---|---|
| Host (lite or full, Windows x64 release) | ~6.4 MB |
| Bun binary (full mode, zstd-compressed) | ~37 MB |
| Typical frontend (React SPA, 200KB text) | ~80 KB packed |
| Typical backend bundle | ~30-200 KB depending on deps |
| Sidecars | whatever the binary weighs |
So a real lite app ships around ~6.5-10 MB; a full app ships around ~44-50 MB before sidecars.
What’s not in TYNDPKG
- Frontend dev server — in dev mode,
tynd://localhostproxies to the framework’s dev server (http://localhost:5173/, etc.). TYNDPKG is only used for builds. - User data —
storewrites to<config_dir>/<ns>/store.json,sql.open(path)takes an on-disk path. None of that lives inside the binary. - Installer metadata —
.app’sInfo.plist, NSIS.nsi, MSI.wxs,.desktopfiles — those are produced by the bundlers and live outside the raw binary.