diff --git a/.gitignore b/.gitignore index 5e8f0e6b..f9d3db88 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ firebase.json template.json samconfig.toml .docker +examples/*/dist *.pem *.sqlite *.log diff --git a/docs/public/html5.svg b/docs/public/html5.svg new file mode 100644 index 00000000..fa7014a5 --- /dev/null +++ b/docs/public/html5.svg @@ -0,0 +1 @@ + diff --git a/docs/src/components/AdapterGrid.jsx b/docs/src/components/AdapterGrid.jsx index 13fa7634..3e5a00fc 100644 --- a/docs/src/components/AdapterGrid.jsx +++ b/docs/src/components/AdapterGrid.jsx @@ -10,6 +10,7 @@ import DenoIcon from "../../public/deno.svg?react"; import AzureIcon from "../../public/azure.svg?react"; import FirebaseIcon from "../../public/firebase.svg?react"; import DockerIcon from "../../public/docker.svg?react"; +import Html5Icon from "../../public/html5.svg?react"; const adapters = [ { @@ -109,32 +110,52 @@ const adapters = [ ja: "Node.js、Bun、または Deno コンテナ", }, }, + { + name: "Singlefile", + href: "/deploy/singlefile", + icon: Html5Icon, + color: "#E34F26", + description: { + en: "Self-contained single HTML file", + ja: "自己完結型の単一HTMLファイル", + }, + experimental: true, + }, ]; export default function AdapterGrid() { const lang = useLanguage(); return (
- {adapters.map(({ name, href, icon: Icon, color, description }) => ( - - - - {name} - - - {description[lang] ?? description.en} - - - ))} + {adapters.map( + ({ name, href, icon: Icon, color, description, experimental }) => ( + + {experimental ? ( + + Experimental + + ) : null} + + {Icon ? ( + + ) : null} + {name} + + + {description[lang] ?? description.en} + + + ) + )}
); } diff --git a/docs/src/pages/en/(pages)/deploy/adapters.mdx b/docs/src/pages/en/(pages)/deploy/adapters.mdx index 162d0744..1ff660f6 100644 --- a/docs/src/pages/en/(pages)/deploy/adapters.mdx +++ b/docs/src/pages/en/(pages)/deploy/adapters.mdx @@ -21,7 +21,7 @@ You can use adapters to configure your app for different deployment environments ## Configuration -Add `adapter` entry to your `react-server.config.mjs` file. You can specify the name of a built-in adapter (`vercel`, `netlify`, `cloudflare`, `aws`, `bun`, `deno`, `azure`, `azure-swa`, `firebase`, or `docker`) as a string, or use an external adapter package. +Add `adapter` entry to your `react-server.config.mjs` file. You can specify the name of a built-in adapter (`vercel`, `netlify`, `cloudflare`, `aws`, `bun`, `deno`, `azure`, `azure-swa`, `firebase`, `docker`, or `singlefile`) as a string, or use an external adapter package. > **Note:** When running a production build with **Bun** or **Deno**, the corresponding adapter is automatically detected and used without any configuration. You can override this with an explicit `adapter` setting in your config or via `--adapter ` on the CLI. Use `--no-adapter` to disable auto-detection. diff --git a/docs/src/pages/en/(pages)/deploy/singlefile.mdx b/docs/src/pages/en/(pages)/deploy/singlefile.mdx new file mode 100644 index 00000000..5813c619 --- /dev/null +++ b/docs/src/pages/en/(pages)/deploy/singlefile.mdx @@ -0,0 +1,95 @@ +--- +title: Singlefile +category: Deploy +order: 9 +--- + +import Link from "../../../../components/Link.jsx"; + +# Singlefile + +> **Experimental:** This adapter is experimental and may change in future releases. It is intended for simple, single-page applications and static sites. + +The `singlefile` adapter bundles your entire statically exported React application into a single, self-contained HTML file. All CSS and JavaScript modules are inlined — no external resources are fetched at runtime. + +This is useful for: + +- **Offline-capable apps** that work from a `file://` URL or a single HTTP request +- **Portable demos** or prototypes you can share as a single file +- **Embedding** in environments where only one HTML file is allowed (e.g. email attachments, embedded webviews) + + +## Installation + + +No additional packages are needed — the adapter is built into `@lazarv/react-server`. + +Add the adapter to your `react-server.config.mjs` file: + +```mjs +export default { + adapter: "singlefile", +}; +``` + +Or pass it via the CLI: + +```sh +pnpm react-server build ./src/index.jsx --adapter singlefile +``` + + +## How it works + + +The singlefile adapter performs these steps during the build: + +1. **Static export** — The `"/"` route is statically exported to produce `index.html` +2. **CSS inlining** — All `` tags are replaced with inline `` + ); + + // 2. Replace ALL remaining references to the CSS path (in flight data, + // scripts, etc.) with a data: URI so React can still load it at runtime. + const cssDataUri = `data:text/css;base64,${Buffer.from(cssContent).toString("base64")}`; + // Use a global string replace (not regex) to catch all occurrences. + // The CSS path may appear as "/assets/root-X.css" in various contexts + // like :HL["/assets/root-X.css","style"] and {"href":"/assets/root-X.css"} + html = html.split(cssHref).join(cssDataUri); + } + success( + `${cssAssetFiles.length} stylesheet${cssAssetFiles.length !== 1 ? "s" : ""} inlined` + ); + + // --- Remove modulepreload links --- + html = html.replace(/]*rel=["']modulepreload["'][^>]*\/?>/g, ""); + + // --- Remove dev-time preconnect/live-reload link --- + html = html.replace(/]*id=["']live-io["'][^>]*\/?>/g, ""); + + // --- Build module source registry --- + banner("inlining client modules", { emoji: "⚡" }); + const moduleSources = {}; + const baseEscaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + for (const file of clientMjsFiles) { + let source = readFileSync(join(reactServerDir, file), "utf-8"); + + // Resolve relative imports to absolute paths first. + // For "client/src/App.HASH.mjs" with import "../react.HASH.mjs", + // we resolve relative to "/client/src/" → "/client/react.HASH.mjs" + const moduleDir = posix.dirname(`${base}${file}`); + + const resolveRelative = (match, prefix, relPath, suffix) => { + const resolved = posix.resolve(moduleDir, relPath); + return `${prefix}${resolved}${suffix}`; + }; + + // Static imports/re-exports: from "./X.mjs" or from "../X.mjs" + source = source.replace( + /(from\s*["'])(\.\.?\/[^"']+)(["'])/g, + resolveRelative + ); + + // Dynamic imports: import("./X.mjs") or import("../X.mjs") + source = source.replace( + /(import\s*\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g, + resolveRelative + ); + + // Side-effect imports: import "./X.mjs" or import "../X.mjs" + source = source.replace( + /(import\s+["'])(\.\.?\/[^"']+)(["'])/g, + resolveRelative + ); + + // Convert ALL absolute path imports to bare specifiers by stripping + // the leading base path (e.g. "/"). Blob: URLs can't resolve URL-like + // specifiers (/client/foo.mjs) through the import map because the + // specifier gets normalized against the blob URL, producing a mismatch. + // Bare specifiers (client/foo.mjs) are matched as raw strings in the + // import map, so they work regardless of the referrer's URL scheme. + const stripBase = new RegExp( + `((?:from|import)\\s*["'])${baseEscaped}([^"']+["'])`, + "g" + ); + source = source.replace(stripBase, "$1$2"); + + // Also handle dynamic import("...") with absolute paths + const stripBaseDynamic = new RegExp( + `(import\\s*\\(\\s*["'])${baseEscaped}([^"']+["'])`, + "g" + ); + source = source.replace(stripBaseDynamic, "$1$2"); + + // Use bare specifier as the key (no leading base path) + moduleSources[`${file}`] = source; + + message(` ${file}`); + } + success( + `${clientMjsFiles.length} module${clientMjsFiles.length !== 1 ? "s" : ""} inlined` + ); + + // --- Find module entry points from , etc. inside JS module code. + const moduleSourcesB64 = {}; + for (const [key, source] of Object.entries(moduleSources)) { + moduleSourcesB64[key] = Buffer.from(source).toString("base64"); + } + + // The b64 map only contains [A-Za-z0-9+/=] values — safe to JSON.stringify + // into a `; + + // Inject boot script at the very start of (before any other scripts). + // IMPORTANT: Use a replacer function to avoid $-pattern interpretation + // in module source code (e.g. $`, $', $& from JS template literals). + if (html.includes("]*)>/, + (match, attrs) => `${bootScript}` + ); + } else { + // Fallback: prepend to document + html = bootScript + html; + } + + // Write the single HTML file + await mkdir(outDir, { recursive: true }); + const outputPath = join(outDir, "index.html"); + await writeFile(outputPath, html); + success(relative(cwd, outputPath)); + }, +}); diff --git a/packages/react-server/package.json b/packages/react-server/package.json index d6c480ba..0d3a6c46 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -150,6 +150,10 @@ "types": "./adapters/adapter.d.ts", "default": "./adapters/docker/index.mjs" }, + "./adapters/singlefile": { + "types": "./adapters/adapter.d.ts", + "default": "./adapters/singlefile/index.mjs" + }, "./worker": { "types": "./worker/index.d.ts", "default": "./worker/index.mjs"