Appearance
Single-File Production Bundle
MEGSTAT POS packages both the Hono backend server and the compiled React frontend application into a single self-contained executable file: dist/server.mjs.
Why Single-File?
Traditional full-stack deployments often require:
- A reverse proxy (e.g., Nginx) serving static SPA files on port 80/443.
- An application server (e.g., Node.js or PM2) running the API on an internal port.
- Large
node_modulesfolders inside Docker images containing hundreds of megabytes of redundant dependencies.
MEGSTAT POS condenses the entire deployment into one ~2.5MB JavaScript file. The production Docker container ships without node_modules, reducing attack surfaces, image transfer times, and deployment complexity.
How the Bundler Works
The build script scripts/bundle-server.mjs executes the following sequence:
text
Step 1: Build React SPA
frontend/src/ ──(vite build)──▶ frontend/dist/ (HTML, JS, CSS, SVG)
Step 2: Read SPA Assets
scripts/bundle-server.mjs reads all assets from frontend/dist/
Step 3: Custom esbuild Plugin
Intercepts import '/spa-assets$/' in backend/src/http/static.ts
and injects in-memory asset map { "index.html": "...", "assets/app.js": "..." }
Step 4: esbuild Server Compilation
backend/src/index.ts ──(esbuild bundle)──▶ dist/server.mjs
• Inlines dotenv and runtime libraries
• external: [] (Zero node_modules required at runtime)
• Minifies outputDevelopment vs Production Asset Serving
MEGSTAT POS switches between development and production modes cleanly without configuration flags:
Development Mode
backend/src/http/spa-assets.tscontains an empty asset registry committed to git:tsexport const spaAssets: Record<string, { content: string; contentType: string }> = {};- When running
npm run dev:backend, the server leaves the registry empty. staticFiles()inbackend/src/http/static.tssees an empty registry and no-ops.- Vite serves the frontend on
http://localhost:5173and proxies API requests/api/*tohttp://localhost:8787.
Production Mode
scripts/bundle-server.mjsruns with a custom esbuildonResolveandonLoadplugin:tsbuild.onResolve({ filter: /spa-assets$/ }, args => ({ path: args.path, namespace: 'spa-assets-ns' }));- The plugin reads every file in
frontend/distand writes a populated JavaScript map directly into the bundle. - In
dist/server.mjs,app.use("*", staticFiles())serves the inlined assets directly from RAM with proper MIME types and aggressive caching headers. - If a user requests a path that is not an
/apiendpoint or static asset,staticFiles()servesindex.htmlto enable client-side HTML5 history routing.
Critical Bundler Constraints
Keep these hard constraints in mind before modifying the build script:
moduleResolution: "bundler"Requires esbuild:backend/tsconfig.jsonuses bundler module resolution.tscemits extensionless ESM imports that Node's native ESM loader rejects (ERR_MODULE_NOT_FOUND). Only the output ofesbuildcan execute under Node.- Missing Asset 404 Behavior: A missing
/assets/*file returns a JSON 404 instead of falling back toindex.html. Returning 200 HTML for a missing JavaScript or CSS chunk causes confusing runtime parse errors in the browser. - Specifier Matching in
onResolve: The esbuild plugin filter matches the specifier (/spa-assets$/), not an absolute file path. Filtering on an absolute path silently fails to trigger becauseonResolveexecutes before file resolution. - Neon Pure-JS WebSocket Driver: Because Neon uses
@neondatabase/serverlessover WebSockets, no native C++ bindings (such asbetter-sqlite3or Prisma query engines) are needed insidedist/server.mjs. The image requires zero C++ compilers (make,g++,python3).
