Skip to content

Feature: Embed assets & files in standalone executables (inspired by Bun) #5731

Description

@ducan-ne

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:

  1. vite builddist/
  2. A build script walks dist/ and generates a TypeScript module with an in-memory asset map
  3. A Fastify server imports that map and serves files from memory
  4. 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:

  1. Resolve and read the file(s) from disk
  2. Embed contents into the binary (e.g. a .rodata section or dedicated asset table)
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions