vite-plugin-singlefile-offline
v0.1.0
Published
Vite plugin that inlines a Vite build into a single self-contained HTML file that runs offline under file:// by transforming ES modules to CommonJS with a shared __require loader.
Maintainers
Readme
vite-plugin-singlefile-offline
Inline a Vite build into one self-contained HTML file that runs offline under
file://by transforming every ES module chunk to CommonJS and evaluating it through a shared__requireloader.
Why
Vite's build output is ES modules loaded via <script type="module">. Under the file:// protocol (and minimal HTTP servers with no SPA fallback), native ESM is blocked and the app won't load. This plugin post-processes the build into a single HTML file with no external dependencies, so it opens by double-clicking — no server, no npx serve.
Unlike vite-plugin-singlefile, which keeps ESM and avoids splitting by configuring Vite's inlining limits, this plugin converts ESM → CommonJS with an AST-based transform and a shared module registry. That's the difference that makes the result work under file:// regardless of how Vite split the chunks (e.g. lazy-loaded routes, dynamic imports, __vitePreload).
Install
npm i -D vite-plugin-singlefile-offline
# peer: vite ^5 || ^6 || ^7 || ^8Usage
// vite.config.ts
import { defineConfig } from 'vite'
import { singleFileOffline } from 'vite-plugin-singlefile-offline'
export default defineConfig({
plugins: [singleFileOffline()],
build: { /* your normal config */ },
})vite build
# → dist/index.html with everything inlined, runs under file://Options
| Option | Default | Description |
| --- | --- | --- |
| deleteOriginalAssets | true | Remove orphaned JS/CSS/font asset files after inlining. |
| maxImageSize | 10_000_000 | Max bytes for a remote image to inline as a data URL. |
| hashRouterPolyfill | true | Rewrite absolute-path pushState/replaceState URLs to hash fragments (SPAs under file://). |
| inlineCSS | true | Inline all CSS into <style> tags. |
| inlineImages | true | Download remote images and inline them as data URLs. |
| storagePolyfill | true | In-memory localStorage/sessionStorage fallback for file://. |
| urlPolyfill | true | new URL(u, base) resolves a missing base against location.href. |
| vueRouterHashPatch | false | Patch vue-router's createWebHistory to hash routing. Opt-in — relies on build-time detection of vue-router's minified internals. |
How it works
Pipeline runs in writeBundle (after Vite's HTML post-processing):
- AST-transform each JS chunk to CommonJS using acorn + acorn-walk + magic-string. No
eval/new Functionfor module loading; only surgical source rewrites ofimport/export/import()→__require/module.exports. - Register every chunk in a shared
__modulesregistry keyed by its emittedfileName, so cross-chunk imports resolve. - Inline all CSS into
<style>tags (CSS code splitting is disabled atconfigtime). - Inject
file://runtime polyfills before the loader script. - Replace the entry
<script type="module" src="…">with one inline loader<script>. Entry requires run onDOMContentLoaded(the inline script lives in<head>and blocks the parser before<div id="app">exists). - Inline remote images (data URLs) and local fonts.
- Delete the now-orphaned asset files.
The module-cache correctness fix
The shared loader caches the module object, not its exports:
function __require(p){
if(__moduleCache[p]) return __moduleCache[p].exports; // return cached exports
var m = { exports: {} };
__moduleCache[p] = m; // cache the OBJECT
__modules[p](m, m.exports, __require);
return m.exports;
}Caching m.exports before the module body runs captures the original empty {}. When a module reassigns module.exports = y, the cache still points at the empty object and later __require() callers get {} — silently dropping Vue component default exports and rendering slides/components as <!---->. Caching m (and returning __moduleCache[p].exports) fixes this; it's covered by a regression test (__require loader semantics).
Stability & maintenance
The transform rewrites Vite's emitted output, including minified helpers like __vitePreload and __vite__mapDeps. These are Vite-internal and can change between Vite/Rolldown versions. When they do, the plugin may need adjustment. Supported Vite versions are declared in peerDependencies; bump them deliberately.
The vueRouterHashPatch option is the most fragile (it detects minified vue-router internals) and is therefore off by default.
Testing
npm test # vitest unit tests for the AST transform + loader semantics
npm run typecheckLicense
MIT © Jacobinwwey
