Summary
Perry standalone executables (perry compile) currently have no first-class way to embed static assets (HTML, JS, CSS, images, JSON configs, etc.) into the compiled binary. This makes it awkward to ship self-contained apps that serve a frontend bundle or other files at runtime without a separate dist/ directory on disk.
Bun's standalone executables solve this well with import attributes, compile-time glob patterns, and a small runtime API. Perry would benefit from a similar feature.
Motivation / use case
I am experimenting with compiling a TanStack Router SPA + static file server into a single native Perry binary. The flow works today:
vite build → dist/
- A build script walks
dist/ and generates a TypeScript module with an in-memory asset map
- A Fastify server imports that map and serves files from memory
perry compile server.ts → ~17MB standalone binary that runs from /tmp with no dist/ folder
This works, but it is a manual workaround. Every project needs its own embed script, MIME-type table, and string/base64 encoding logic. A native Perry embed API would remove that boilerplate and make SPA-in-a-binary a supported pattern.
Prior art: Bun
Bun supports embedding files directly into bun build --compile output:
1. Import attribute for single files
import icon from "./icon.png" with { type: "file" };
// During dev: "./icon.png"
// After compile: "$bunfs/icon-a1b2c3d4.png" (virtual path into the binary)
At build time Bun reads the file, embeds bytes into the executable, and replaces the import with an internal path. At runtime the file is readable via Bun.file() or Node fs APIs.
2. Directory / glob embedding via CLI or build API
bun build --compile ./index.ts ./public/**/*.png
3. Runtime introspection
import { embeddedFiles } from "bun";
for (const blob of embeddedFiles) {
console.log(`${blob.name} - ${blob.size} bytes`);
}
Bun.embeddedFiles returns all embedded assets as Blob objects (excluding bundled source). Useful for dynamically building static routes in an HTTP server.
4. Standalone detection
Bun.isStandaloneExecutable — cheap check without allocating blobs for every embedded file.
Proposal for Perry
Phase 1 — compile-time embedding
Import attribute (or equivalent Perry-native syntax):
import indexHtml from "./dist/index.html" with { type: "file" };
import logo from "./dist/assets/logo.png" with { type: "file" };
During perry compile, Perry would:
- Resolve and read the file(s) from disk
- Embed contents into the binary (e.g. a
.rodata section or dedicated asset table)
- Replace the import with a virtual path string (e.g.
$perryfs/...) or a typed handle
CLI / config for globs (for SPA dist/ folders):
perry compile server.ts --embed "./dist/**"
Or in package.json / perry.toml:
[compile]
embed = ["./dist/**"]
Phase 2 — runtime API
Expose a small stdlib surface, similar to Bun:
// List all embedded files (lazy / no full decode unless accessed)
Perry.embeddedFiles: ReadonlyArray<{ name: string; size: number; type?: string }>
// Read embedded file by virtual path or original relative path
Perry.readEmbedded(path: string): Uint8Array | string
// Optional: bridge to node:fs so existing servers work
// fs.readFileSync("$perryfs/dist/index.html") works in compiled binary
For HTTP servers, something like:
import { embeddedFiles } from "perry";
for (const asset of embeddedFiles) {
app.get(asset.route, (_, reply) => reply.type(asset.type).send(asset.bytes));
}
Or a built-in helper:
import { serveEmbeddedDir } from "perry/static";
serveEmbeddedDir(app, { prefix: "/", root: "dist/" });
Phase 3 — nice-to-haves
- Preserve original relative paths (Vite-style hashed asset names) via
--asset-naming "[path]/[name].[ext]"
- Content-hash suffix option (Bun default) vs stable names (for predictable routes)
Perry.isStandaloneExecutable for dev vs compiled behavior
- SQLite / binary blob embed variants (Bun has
type: "sqlite", embed: "true")
Current workaround
Today I use perry.codegen / a pre-compile script that generates something like:
export const EMBEDDED_ASSETS: Record<string, { encoding: 'utf8' | 'base64'; type: string; data: string }> = { ... }
Problems with this approach:
- Large generated TS files (slow compile, huge AST)
- All assets held as JS strings in the module graph
- Manual MIME types and route mapping
- Not discoverable — every user reinvents the same script
A native embed path would store bytes outside the JS module graph and avoid codegen bloat.
Expected outcome
After this feature, a typical SPA server workflow would be:
vite build
perry compile server.ts --embed "./dist/**" -o myapp
./myapp # serves dist/ from memory, no external files
References
Happy to share a minimal repro repo (TanStack Router SPA + Fastify + embed script) if that helps prioritize or design the API.
Summary
Perry standalone executables (
perry compile) currently have no first-class way to embed static assets (HTML, JS, CSS, images, JSON configs, etc.) into the compiled binary. This makes it awkward to ship self-contained apps that serve a frontend bundle or other files at runtime without a separatedist/directory on disk.Bun's standalone executables solve this well with import attributes, compile-time glob patterns, and a small runtime API. Perry would benefit from a similar feature.
Motivation / use case
I am experimenting with compiling a TanStack Router SPA + static file server into a single native Perry binary. The flow works today:
vite build→dist/dist/and generates a TypeScript module with an in-memory asset mapperry compile server.ts→ ~17MB standalone binary that runs from/tmpwith nodist/folderThis works, but it is a manual workaround. Every project needs its own embed script, MIME-type table, and string/base64 encoding logic. A native Perry embed API would remove that boilerplate and make SPA-in-a-binary a supported pattern.
Prior art: Bun
Bun supports embedding files directly into
bun build --compileoutput:1. Import attribute for single files
At build time Bun reads the file, embeds bytes into the executable, and replaces the import with an internal path. At runtime the file is readable via
Bun.file()or NodefsAPIs.2. Directory / glob embedding via CLI or build API
3. Runtime introspection
Bun.embeddedFilesreturns all embedded assets asBlobobjects (excluding bundled source). Useful for dynamically building static routes in an HTTP server.4. Standalone detection
Bun.isStandaloneExecutable— cheap check without allocating blobs for every embedded file.Proposal for Perry
Phase 1 — compile-time embedding
Import attribute (or equivalent Perry-native syntax):
During
perry compile, Perry would:.rodatasection or dedicated asset table)$perryfs/...) or a typed handleCLI / config for globs (for SPA
dist/folders):perry compile server.ts --embed "./dist/**"Or in
package.json/perry.toml:Phase 2 — runtime API
Expose a small stdlib surface, similar to Bun:
For HTTP servers, something like:
Or a built-in helper:
Phase 3 — nice-to-haves
--asset-naming "[path]/[name].[ext]"Perry.isStandaloneExecutablefor dev vs compiled behaviortype: "sqlite", embed: "true")Current workaround
Today I use
perry.codegen/ a pre-compile script that generates something like:Problems with this approach:
A native embed path would store bytes outside the JS module graph and avoid codegen bloat.
Expected outcome
After this feature, a typical SPA server workflow would be:
References
Bun.embeddedFiles--compileHappy to share a minimal repro repo (TanStack Router SPA + Fastify + embed script) if that helps prioritize or design the API.