From 456bb056d81278b57951a24a7882bea602678078 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 31 Mar 2026 12:00:12 +0200 Subject: [PATCH] bun sfe embedded assets support --- CHANGELOG.md | 4 + README.md | 120 ++++++++++- demo/README.md | 4 +- res-x-vite-plugin.mjs | 288 ++++++++++++++++++------- test/StaticAssetRoutes.test.js | 382 ++++++++++++++++++++++++++++++++- 5 files changed, 710 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cdb46..efd04cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # master +- Add Bun single-file executable support to the Vite plugin via `staticAssetRoutes.mode`, including an `embedded` mode for `bun build --compile` and normalized build-time asset URLs for generated browser assets like `resXClient_js`. + +# 1.3.0 + - Fix dev reload experience of the Vite dev setup. - Expose `RequestController` and `Handlers` as record-of-functions APIs so user code can migrate from `requestController->RequestController.setStatus(404)` to `requestController.setStatus(404)` and from `handler->ResX.Handlers.handleRequest({...})` to `handler.handleRequest({...})`; the old free-function surface is still available but deprecated. - BREAKING: Remove `ResX.BunUtils.serveStaticFile`; static assets now go through generated `ResXAssets.staticAssetRoutes`. diff --git a/README.md b/README.md index 7c6571e..749e56f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,10 @@ With ResX and Bun, the practical options are: The demo app in `demo/` contains a working example of the first option. It includes a minimal Docker setup that builds a Bun single-file executable and runs it from a small Alpine image. -Important detail: if you use the ResX asset pipeline, the executable is not completely standalone. The generated static asset routes serve files from `./dist`, so you need to deploy the built `dist/` directory alongside the executable and run the process from the directory that contains that `dist/` folder. +If you use the ResX asset pipeline, there are now two deploy modes: + +- `staticAssetRoutes.mode: "filesystem"` is the default. Generated static routes read from `./dist`, so you need to deploy the built `dist/` directory alongside the executable and run the process from the directory that contains that `dist/` folder. +- `staticAssetRoutes.mode: "embedded"` generates Bun embedded-file imports instead. That mode is intended for `bun build --compile`, and lets the executable serve generated ResX assets without a sidecar `dist/` tree at runtime. In the demo: @@ -38,7 +41,101 @@ In the demo: - `demo/Dockerfile` shows the minimal Alpine image setup - `demo/README.md` documents the full Docker and direct-SFE flow -The Docker path is the safest default because it builds the Linux executable in-container and packages the executable together with the required `dist/` assets. +The Docker path is still the safest default because it builds the Linux executable in-container. In filesystem mode it also packages the required `dist/` assets; in embedded mode the executable can stand on its own. + +## Bun Single-File Executables + +ResX works well with Bun single-file executables built via `bun build --compile`. + +The important detail is that ResX has two static-asset deployment modes: + +- `staticAssetRoutes.mode: "filesystem"` is the default. Generated static routes read from `./dist` at runtime. +- `staticAssetRoutes.mode: "embedded"` generates `with { type: "file" }` imports instead, so Bun can embed the generated assets into the executable itself. + +If you want a truly standalone executable, use `"embedded"`. + +### 1. Configure the Vite Plugin + +```js +// vite.config.js +import { defineConfig } from "vite"; +import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs"; + +export default defineConfig(({ command }) => { + const staticAssetRouteMode = + command === "build" ? "embedded" : "filesystem"; + + return { + plugins: [ + resXVitePlugin({ + clientDirs: ["client"], + staticAssetRoutes: { + mode: staticAssetRouteMode, + }, + }), + ], + }; +}); +``` + +This is the most ergonomic setup for Bun SFEs: production builds switch to `embedded`, while local `vite serve` stays on the familiar filesystem-backed setup. + +### 2. Build the App Normally First + +Build the Vite output and ReScript output before compiling the executable: + +```json +{ + "scripts": { + "start": "NODE_ENV=production bun run src/App.js", + "build": "NODE_ENV=production bun run build:vite && bun run build:res", + "build:vite": "vite build", + "build:res": "rescript", + "build:sfe": "bun run build && mkdir -p build && NODE_ENV=production bun build --compile --outfile ./build/app ./src/App.js" + } +} +``` + +Important details: + +- Compile the generated JavaScript server entrypoint such as `src/App.js`, not the `.res` source file. +- Run the normal build first so ResX has already generated the final asset URLs and static route module. +- `staticAssetRoutes.mode` only affects build output. Dev mode stays on the normal filesystem-backed workflow. + +### 3. Build the Executable + +```sh +bun run build:sfe +``` + +That produces an executable such as: + +- `build/app` + +In filesystem mode you should also expect to deploy: + +- `dist/` + +### 4. Deploy It + +For `staticAssetRoutes.mode: "embedded"`: + +- Deploy the executable by itself. +- Start it with something like `PORT=4444 NODE_ENV=production ./build/app`. +- The generated ResX static assets are served from the executable, so the original `dist/` tree does not need to be present at runtime. + +For `staticAssetRoutes.mode: "filesystem"`: + +- Deploy the executable together with `dist/`. +- Start the executable from the directory that contains `dist/`, or configure your service working directory accordingly. +- ResX will serve generated static assets from the files in `dist/`. + +### 5. Practical Notes + +- Bun single-file executables are target-platform specific. Build on the same OS/architecture you plan to deploy, or build inside a matching container. +- Docker is still a good default when you want a reproducible Linux build artifact. +- `embedded` only changes generated static asset routes. Your application server code still mounts `ResXAssets.staticAssetRoutes` the same way. +- Browser-facing asset URLs such as `ResXAssets.assets.resXClient_js` still work the same way from application code. ## Publishing @@ -147,6 +244,7 @@ There! If you want, you can also set up a bunch of scripts in `package.json` tha "build": "NODE_ENV=production bun run build:vite && bun run build:res", "build:vite": "vite build", "build:res": "rescript", + "build:sfe": "bun run build && mkdir -p build && NODE_ENV=production bun build --compile --outfile ./build/app ./src/App.js", "clean:res": "rescript clean", "dev:res": "rescript watch", "dev:server": "bun --watch run src/App.js", @@ -279,6 +377,8 @@ let server = Bun.serve({ }) ``` +In build output, `ResXAssets.assets.*` always resolves to normal browser-facing URLs. That includes package-owned browser assets like `ResXAssets.assets.resXClient_js`, which are emitted under your asset namespace instead of leaking raw `/node_modules/...` paths. + If you want to add your own Bun static routes, `staticAssetRoutes` is a regular `Dict.t`, so you can merge it the same way you would merge any other ReScript dict: ```rescript @@ -307,10 +407,11 @@ If you want to configure how these generated static asset routes behave, pass `s import { defineConfig } from "vite"; import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs"; -export default defineConfig({ +export default defineConfig(({ command }) => ({ plugins: [ resXVitePlugin({ staticAssetRoutes: { + mode: command === "build" ? "embedded" : "filesystem", headers: { "/assets/**": { "Cache-Control": "public, max-age=31536000, immutable", @@ -322,9 +423,18 @@ export default defineConfig({ }, }), ], -}); +})); ``` +`staticAssetRoutes.mode` controls how ResX materializes generated server-side asset routes: + +- `"filesystem"` is the default and keeps the current `Bun.file("./dist/...")` behavior. +- `"embedded"` generates `with { type: "file" }` imports so the routes work with `bun build --compile`. + +If you are building a Bun single-file executable and want it to run without the original `dist/` tree on disk, use `"embedded"`. + +Using `command === "build" ? "embedded" : "filesystem"` is a good default convention. It keeps the production build standalone-friendly while making the dev intent explicit, even though ResX already keeps dev on the normal filesystem-backed path. + `staticAssetRoutes.headers` is an object where: - Each key is a route pattern for generated static asset routes. @@ -810,7 +920,7 @@ These functions should only be used in exceptional cases where you need to: ResX also ships with a tiny client side library that will help you do basic client side tasks fully declaratively. It's quite basic at the moment, but will be extended (tastefully) as we discover more places where it can help you avoid having to use a full blown client side framework to accomplish fairly basic tasks. -The browser bundle for this is shipped with `rescript-x`, so you can reference `ResXAssets.assets.resXClient_js` directly without adding your own `extraClientEntries` config. +The browser bundle for this is shipped with `rescript-x`, so you can reference `ResXAssets.assets.resXClient_js` directly without adding your own `extraClientEntries` config. In production builds that URL is emitted as a normal generated asset URL under `/assets/...`, not as a raw package path. To use ResX client, make sure you include its script: diff --git a/demo/README.md b/demo/README.md index 155f3ef..a7cb248 100644 --- a/demo/README.md +++ b/demo/README.md @@ -67,7 +67,7 @@ That produces: - `build/demo-app` - `dist/` -Deploy both of those together. The executable is not fully self-contained because the generated static routes serve files from `./dist`. +Deploy both of those together. This demo currently uses the default `staticAssetRoutes.mode: "filesystem"`, so the executable is not fully self-contained and still serves generated assets from `./dist`. Run it like this: @@ -83,6 +83,8 @@ Important runtime notes: - Files from `demo/public/` are also emitted into `dist/`, so you do not need to deploy `public/` separately. - If you want to offload assets to a CDN or another static host, you need to change the generated asset URLs and static route strategy. The current setup expects the app process to serve `dist/` itself. +If you want a truly standalone executable instead, switch the demo Vite config to `staticAssetRoutes: { mode: "embedded" }` before building. That makes ResX generate embedded-file-backed static routes for `bun build --compile`, so the resulting executable can serve its generated assets without a sidecar `dist/` tree. + Platform note: - Bun single-file executables are target-platform specific. If you want to deploy the SFE directly to Linux, build it on Linux for the correct architecture. The Docker image is the safest path because it already builds the Linux executable in-container. diff --git a/res-x-vite-plugin.mjs b/res-x-vite-plugin.mjs index 6ddd47b..bff4931 100644 --- a/res-x-vite-plugin.mjs +++ b/res-x-vite-plugin.mjs @@ -42,6 +42,7 @@ const rescriptRecordFieldKeywords = new Set([ ]); const defaultStaticAssetRoutesConfig = Object.freeze({ + mode: "filesystem", headers: Object.freeze({}), }); @@ -99,7 +100,8 @@ export default function resXVitePlugin(options = {}) { assetDir, publicDir, resolvedExtraClientEntries.resXClient_js, - staticAssetRoutes + staticAssetRoutes, + projectRoot ) ); @@ -344,37 +346,35 @@ export default function resXVitePlugin(options = {}) { } }); - const assets = manifest.reduce((acc, entry) => { - const generatedPath = - entry.kind === "asset" - ? assetFileNameByBuildId.get(entry.buildId) - : clientFileNameByFieldName.get(entry.fieldName); - - if (generatedPath != null) { - acc[entry.fieldName] = `/${generatedPath}`; - } - - return acc; - }, {}); - assetWrapperChunkFileNames.forEach(fileName => { delete bundle[fileName]; }); + const resolvedOutDir = resolveConfiguredPath(projectRoot, outDir); + const resolvedPublicDir = resolveConfiguredPath(projectRoot, publicDir); const bundleFileNames = Object.values(bundle) .map(output => output.fileName) .filter(Boolean); + const generatedBuildManifest = getGeneratedBuildManifest({ + assetFileNameByBuildId, + bundleFileNames, + clientFileNameByFieldName, + manifest, + outDir: resolvedOutDir, + publicDir: resolvedPublicDir, + staticAssetRoutes, + }); writeIfChanged( getAssetJsFileLoc(projectRoot, generated), - getGeneratedAssetModule(assets) + getGeneratedAssetModule(generatedBuildManifest.assets) ); writeIfChanged( getStaticAssetRoutesJsFileLoc(projectRoot, generated), getBuildStaticAssetRoutesFile({ - bundleFileNames, - outDir, - publicDir, + generatedFilePath: getStaticAssetRoutesJsFileLoc(projectRoot, generated), + projectRoot, + serverAssetEntries: generatedBuildManifest.serverAssetEntries, staticAssetRoutes, }) ); @@ -949,6 +949,13 @@ function createStaticAssetRoutesConfig(staticAssetRoutes = {}) { ); } + const mode = staticAssetRoutes.mode ?? defaultStaticAssetRoutesConfig.mode; + if (mode !== "filesystem" && mode !== "embedded") { + throw new Error( + "`staticAssetRoutes.mode` must be either \"filesystem\" or \"embedded\"." + ); + } + const headers = staticAssetRoutes.headers || {}; if (typeof headers !== "object" || Array.isArray(headers)) { @@ -970,6 +977,7 @@ function createStaticAssetRoutesConfig(staticAssetRoutes = {}) { }); return { + mode, headers, }; } @@ -978,73 +986,107 @@ function getDummyStaticAssetRoutesFile( assetDir, publicDir, resXClientLocation, - staticAssetRoutes + staticAssetRoutes, + projectRoot = process.cwd() ) { + const resolvedAssetDir = resolveConfiguredPath(projectRoot, assetDir); + const resolvedPublicDir = resolveConfiguredPath(projectRoot, publicDir); const assetRouteBase = path.basename(normalizeFsPath(assetDir)); return getStaticAssetRoutesFileContent({ exactEntries: buildStaticAssetRouteEntries({ publicEntries: getPublicRouteEntries({ - baseDir: publicDir, - fileDir: publicDir, + baseDir: resolvedPublicDir, + fileDir: resolvedPublicDir, }), - assetEntries: getAssetDirContent(assetDir).map(assetPath => ({ + assetEntries: getAssetDirContent(resolvedAssetDir).map(assetPath => ({ + kind: "asset", routePath: toRoutePath(path.join(assetRouteBase, assetPath)), - filePath: toBunFilePath(path.join(assetDir, assetPath)), + sourcePath: path.resolve(resolvedAssetDir, assetPath), })), exactEntries: resXClientLocation == null ? [] : [ { + kind: "client", routePath: toRoutePath(resXClientLocation), - filePath: toBunFilePath(resXClientLocation), + sourcePath: resolveConfiguredPath(projectRoot, resXClientLocation), }, ], headers: staticAssetRoutes.headers, }), + projectRoot, }); } -function getBuildStaticAssetRoutesFile( - assetMapOrOptions, - publicDirArg, - outDirArg, - staticAssetRoutesArg -) { - if ( - typeof assetMapOrOptions === "object" && - assetMapOrOptions != null && - Array.isArray(assetMapOrOptions.bundleFileNames) - ) { - const { bundleFileNames, outDir, publicDir, staticAssetRoutes } = - assetMapOrOptions; - - return getStaticAssetRoutesFileContent({ - exactEntries: buildStaticAssetRouteEntries({ - publicEntries: getPublicRouteEntries({ - baseDir: publicDir, - fileDir: outDir, - }), - assetEntries: bundleFileNames.map(fileName => ({ - routePath: toRoutePath(fileName), - filePath: toBunFilePath(path.join(outDir, fileName)), - })), - headers: staticAssetRoutes.headers, - }), - }); - } +function getGeneratedBuildManifest({ + assetFileNameByBuildId, + bundleFileNames, + clientFileNameByFieldName, + manifest, + outDir, + publicDir, + staticAssetRoutes, +}) { + const exposedAssetEntries = manifest.flatMap(entry => { + const generatedPath = + entry.kind === "asset" + ? assetFileNameByBuildId.get(entry.buildId) + : clientFileNameByFieldName.get(entry.fieldName); + + if (generatedPath == null) { + return []; + } - const assetMap = assetMapOrOptions; - const bundleFileNames = Object.values(assetMap).map(assetUrl => - stripLeadingSlash(assetUrl) - ); + return [ + { + fieldName: entry.fieldName, + kind: entry.kind, + routePath: toRoutePath(generatedPath), + sourcePath: path.resolve(outDir, generatedPath), + }, + ]; + }); - return getBuildStaticAssetRoutesFile({ - bundleFileNames, - outDir: outDirArg, - publicDir: publicDirArg, - staticAssetRoutes: staticAssetRoutesArg, + const serverAssetEntries = buildStaticAssetRouteEntries({ + publicEntries: getPublicRouteEntries({ + baseDir: publicDir, + fileDir: outDir, + }), + assetEntries: bundleFileNames.map(fileName => ({ + kind: "bundle", + routePath: toRoutePath(fileName), + sourcePath: path.resolve(outDir, fileName), + })), + exactEntries: exposedAssetEntries, + headers: staticAssetRoutes.headers, + }); + const assets = serverAssetEntries.reduce((acc, entry) => { + if (entry.fieldName != null) { + acc[entry.fieldName] = entry.routePath; + } + + return acc; + }, {}); + + return { + assets, + serverAssetEntries, + }; +} + +function getBuildStaticAssetRoutesFile({ + generatedFilePath, + projectRoot, + serverAssetEntries, + staticAssetRoutes, +}) { + return getStaticAssetRoutesFileContent({ + exactEntries: serverAssetEntries, + generatedFilePath, + mode: staticAssetRoutes.mode, + projectRoot, }); } @@ -1056,8 +1098,8 @@ function buildStaticAssetRouteEntries({ }) { return dedupeStaticAssetRouteEntries([ ...publicEntries, - ...exactEntries, ...assetEntries, + ...exactEntries, ]).map(entry => ({ ...entry, headers: getStaticAssetRouteHeaders(headers, entry.routePath), @@ -1076,8 +1118,9 @@ function dedupeStaticAssetRouteEntries(entries) { function getPublicRouteEntries({ baseDir, fileDir }) { return getPublicDirContent(baseDir).map(publicPath => ({ + kind: "public", routePath: toRoutePath(publicPath), - filePath: toBunFilePath(path.join(fileDir, publicPath)), + sourcePath: path.resolve(fileDir, publicPath), })); } @@ -1160,10 +1203,18 @@ function splitStaticAssetRoutePath(routePath) { .filter(segment => segment.length > 0); } -function getStaticAssetRoutesFileContent({ exactEntries }) { +function getStaticAssetRoutesFileContent({ + exactEntries, + generatedFilePath, + mode = defaultStaticAssetRoutesConfig.mode, + projectRoot = process.cwd(), +}) { const sharedHeaderNames = new Map(); const headerDefinitions = []; let nextHeaderId = 0; + const sharedImportNames = new Map(); + const importDefinitions = []; + let nextImportId = 0; const getHeaderName = headers => { if (headers == null) { @@ -1187,20 +1238,62 @@ function getStaticAssetRoutesFileContent({ exactEntries }) { return headerName; }; + const getImportName = sourcePath => { + const importKey = JSON.stringify([ + resolveAssetSourcePath(sourcePath, projectRoot), + generatedFilePath, + ]); + const existingImportName = sharedImportNames.get(importKey); + + if (existingImportName != null) { + return existingImportName; + } + + if (generatedFilePath == null) { + throw new Error( + "`generatedFilePath` is required when generating embedded static asset routes." + ); + } + + const importName = `staticAssetFile${nextImportId}`; + nextImportId += 1; + sharedImportNames.set(importKey, importName); + importDefinitions.push({ + name: importName, + specifier: toFileImportSpecifier({ + fromFilePath: generatedFilePath, + projectRoot, + toFilePath: sourcePath, + }), + }); + return importName; + }; + const exactRoutes = exactEntries .sort((left, right) => left.routePath.localeCompare(right.routePath)) - .map(({ routePath, filePath, headers }) => { + .map(({ routePath, sourcePath, headers }) => { const headerName = getHeaderName(headers); const responseOptions = headerName == null ? "" : `, { headers: ${headerName} }`; - - return ` ${JSON.stringify(routePath)}: {\n GET: new Response(Bun.file(${JSON.stringify( - filePath - )})${responseOptions}),\n HEAD: new Response(Bun.file(${JSON.stringify( - filePath - )})${responseOptions}),\n }`; + const fileExpression = + mode === "embedded" + ? `Bun.file(${getImportName(sourcePath)})` + : `Bun.file(${JSON.stringify( + toRuntimeBunFilePath(sourcePath, projectRoot) + )})`; + + return ` ${JSON.stringify(routePath)}: {\n GET: new Response(${fileExpression}${responseOptions}),\n HEAD: new Response(${fileExpression}${responseOptions}),\n }`; }); + const importStatements = + importDefinitions.length === 0 + ? "" + : `${importDefinitions + .map( + ({ name, specifier }) => + `import ${name} from ${JSON.stringify(specifier)} with { type: "file" };` + ) + .join("\n")}\n\n`; const headerConstants = headerDefinitions.length === 0 ? "" @@ -1213,19 +1306,55 @@ function getStaticAssetRoutesFileContent({ exactEntries }) { return `// Generated by ResX, do not edit manually -${headerConstants}export const staticAssetRoutes = { +${importStatements}${headerConstants}export const staticAssetRoutes = { ${exactRoutes.join(",\n")} } `; } +function resolveAssetSourcePath(sourcePath, projectRoot) { + if (path.isAbsolute(sourcePath)) { + return normalizeFsPath(sourcePath); + } + + return resolveConfiguredPath(projectRoot, sourcePath); +} + +function toRuntimeBunFilePath(sourcePath, projectRoot) { + if (!path.isAbsolute(sourcePath)) { + return toBunFilePath(sourcePath); + } + + const projectRelativePath = getProjectRelativePath(projectRoot, sourcePath); + if (projectRelativePath != null) { + return toBunFilePath(projectRelativePath); + } + + return toBunFilePath(sourcePath); +} + +function toFileImportSpecifier({ fromFilePath, projectRoot, toFilePath }) { + return toImportSpecifier( + resolveConfiguredPath(projectRoot, fromFilePath), + resolveAssetSourcePath(toFilePath, projectRoot) + ); +} + function normalizePath(filePath) { return filePath.replaceAll(path.sep, "/"); } function toBunFilePath(filePath) { const normalized = normalizePath(filePath); - return path.isAbsolute(filePath) ? normalized : "./" + normalized; + if (path.isAbsolute(filePath)) { + return normalized; + } + + if (normalized.startsWith("./") || normalized.startsWith("../")) { + return normalized; + } + + return "./" + normalized; } function toRoutePath(filePath) { @@ -1367,6 +1496,18 @@ function toImportPath(fromFileName, toFileName) { return `./${relativePath}`; } +function toImportSpecifier(fromFilePath, toFilePath) { + const relativePath = toPosix( + path.relative(path.dirname(fromFilePath), toFilePath) + ); + + if (relativePath.startsWith(".")) { + return relativePath; + } + + return `./${relativePath}`; +} + function getDefaultEntryGlobs(clientEntryExtensions) { return clientEntryExtensions.map(extension => `*${extension}`); } @@ -1384,6 +1525,7 @@ export const __test = { buildStaticAssetRouteEntries, getClientEntryWrapperModule, getDevSocketProxyTarget, + getGeneratedBuildManifest, getGeneratedDevAssetMap, getManifest, getBuildStaticAssetRoutesFile, diff --git a/test/StaticAssetRoutes.test.js b/test/StaticAssetRoutes.test.js index 41e4839..0cd762b 100644 --- a/test/StaticAssetRoutes.test.js +++ b/test/StaticAssetRoutes.test.js @@ -1,6 +1,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); +const { spawn } = require("child_process"); const { pathToFileURL } = require("url"); const { describe, expect, test } = require("bun:test"); const TestUtils = require("./TestUtils.js"); @@ -47,6 +48,35 @@ async function withStaticAssetServer(routes, run) { } } +async function waitForUrl(url, options = {}) { + const { timeoutMs = 15000 } = options; + const start = Date.now(); + let lastError = null; + + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(url); + if (response.status > 0) { + return response; + } + } catch (error) { + lastError = error; + } + + await Bun.sleep(100); + } + + throw lastError ?? new Error(`Timed out waiting for ${url}`); +} + +async function waitForChildExit(child) { + if (child.exitCode != null || child.signalCode != null) { + return; + } + + await new Promise(resolve => child.once("exit", resolve)); +} + describe("static asset routes", () => { test("matches static asset route patterns", async () => { const { matchesStaticAssetRoutePattern } = await getPluginTestHelpers(); @@ -106,17 +136,17 @@ describe("static asset routes", () => { publicEntries: [ { routePath: "/robots.txt", - filePath: "./public/robots.txt", + sourcePath: "public/robots.txt", }, ], assetEntries: [ { routePath: "/assets/a.css", - filePath: "./dist/assets/a.css", + sourcePath: "dist/assets/a.css", }, { routePath: "/assets/b.css", - filePath: "./dist/assets/b.css", + sourcePath: "dist/assets/b.css", }, ], headers: createStaticAssetRoutesConfig().headers, @@ -125,22 +155,51 @@ describe("static asset routes", () => { expect(exactEntries).toEqual([ { routePath: "/robots.txt", - filePath: "./public/robots.txt", + sourcePath: "public/robots.txt", headers: null, }, { routePath: "/assets/a.css", - filePath: "./dist/assets/a.css", + sourcePath: "dist/assets/a.css", headers: null, }, { routePath: "/assets/b.css", - filePath: "./dist/assets/b.css", + sourcePath: "dist/assets/b.css", headers: null, }, ]); }); + test("defaults static asset route mode to filesystem", async () => { + const { createStaticAssetRoutesConfig } = await getPluginTestHelpers(); + + expect(createStaticAssetRoutesConfig()).toEqual({ + mode: "filesystem", + headers: {}, + }); + expect( + createStaticAssetRoutesConfig({ + mode: "embedded", + }) + ).toEqual({ + mode: "embedded", + headers: {}, + }); + }); + + test("rejects invalid static asset route modes", async () => { + const { createStaticAssetRoutesConfig } = await getPluginTestHelpers(); + + expect(() => + createStaticAssetRoutesConfig({ + mode: "zipfile", + }) + ).toThrow( + "`staticAssetRoutes.mode` must be either \"filesystem\" or \"embedded\"." + ); + }); + test("rejects the removed assets config", async () => { const { createStaticAssetRoutesConfig } = await getPluginTestHelpers(); @@ -283,7 +342,7 @@ describe("static asset routes", () => { exactEntries: [ { routePath: "/robots.txt", - filePath: robotsPath, + sourcePath: robotsPath, headers: { "Cache-Control": "public, max-age=300", }, @@ -314,7 +373,7 @@ describe("static asset routes", () => { exactEntries: [ { routePath: "/empty.txt", - filePath: emptyPath, + sourcePath: emptyPath, headers: null, }, ], @@ -336,7 +395,7 @@ describe("static asset routes", () => { exactEntries: [ { routePath: "/robots.txt", - filePath: "./dist/robots.txt", + sourcePath: "dist/robots.txt", headers: { "Cache-Control": "public, max-age=300", }, @@ -354,4 +413,309 @@ describe("static asset routes", () => { ) ).toBe(true); }); + + test("embedded routes import files instead of using filesystem-relative Bun.file paths", async () => { + const { getStaticAssetRoutesFileContent } = await getPluginTestHelpers(); + + await withTempDir(async tempDir => { + const generatedDir = path.join(tempDir, "src", "__generated__"); + const assetPath = path.join(tempDir, "dist", "assets", "app.css"); + const modulePath = path.join(generatedDir, "res-x-static-routes.js"); + + fs.mkdirSync(path.dirname(assetPath), { recursive: true }); + fs.mkdirSync(generatedDir, { recursive: true }); + fs.writeFileSync(assetPath, "body { color: red; }"); + + const content = getStaticAssetRoutesFileContent({ + exactEntries: [ + { + routePath: "/assets/app.css", + sourcePath: assetPath, + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + ], + generatedFilePath: modulePath, + mode: "embedded", + projectRoot: tempDir, + }); + + expect(content.includes('with { type: "file" }')).toBe(true); + expect(content.includes('"../../dist/assets/app.css"')).toBe(true); + expect(content.includes('Bun.file("./dist/assets/app.css")')).toBe(false); + + fs.writeFileSync(modulePath, content); + const { staticAssetRoutes } = await importGeneratedModule(modulePath); + const response = staticAssetRoutes["/assets/app.css"].GET; + + expect(response.headers.get("cache-control")).toBe( + "public, max-age=31536000, immutable" + ); + expect(await response.text()).toBe("body { color: red; }"); + }); + }); + + test("build manifest keeps browser-facing asset urls normalized", async () => { + const { createStaticAssetRoutesConfig, getGeneratedBuildManifest } = + await getPluginTestHelpers(); + + await withTempDir(async tempDir => { + const outDir = path.join(tempDir, "dist"); + const publicDir = path.join(tempDir, "public"); + const assetsDir = path.join(outDir, "assets"); + + fs.mkdirSync(assetsDir, { recursive: true }); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, "robots.txt"), "User-agent: *"); + fs.writeFileSync(path.join(outDir, "robots.txt"), "User-agent: *"); + fs.writeFileSync( + path.join(assetsDir, "styles_css-123.css"), + "body { color: red; }" + ); + fs.writeFileSync( + path.join(assetsDir, "resXClient_js_loader-123.js"), + 'console.log("loader");' + ); + fs.writeFileSync( + path.join(assetsDir, "resXClient_js-456.js"), + 'console.log("inner");' + ); + + const generatedBuildManifest = getGeneratedBuildManifest({ + assetFileNameByBuildId: new Map([ + ["@res-x-asset-entry:styles_css", "assets/styles_css-123.css"], + ]), + bundleFileNames: [ + "assets/styles_css-123.css", + "assets/resXClient_js_loader-123.js", + "assets/resXClient_js-456.js", + ], + clientFileNameByFieldName: new Map([ + ["resXClient_js", "assets/resXClient_js_loader-123.js"], + ]), + manifest: [ + { + buildId: "@res-x-asset-entry:styles_css", + fieldName: "styles_css", + kind: "asset", + }, + { + fieldName: "resXClient_js", + kind: "client", + }, + ], + outDir, + publicDir, + staticAssetRoutes: createStaticAssetRoutesConfig({ + headers: { + "/assets/**": { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + }), + }); + + expect(generatedBuildManifest.assets).toEqual({ + styles_css: "/assets/styles_css-123.css", + resXClient_js: "/assets/resXClient_js_loader-123.js", + }); + expect( + generatedBuildManifest.serverAssetEntries.some( + entry => + entry.routePath === "/assets/resXClient_js-456.js" && + entry.kind === "bundle" + ) + ).toBe(true); + expect( + generatedBuildManifest.serverAssetEntries.find( + entry => entry.fieldName === "resXClient_js" + ) + ).toMatchObject({ + routePath: "/assets/resXClient_js_loader-123.js", + kind: "client", + }); + expect( + generatedBuildManifest.serverAssetEntries.find( + entry => entry.routePath === "/robots.txt" + ) + ).toMatchObject({ + kind: "public", + sourcePath: path.join(outDir, "robots.txt"), + }); + }); + }); + + test("embedded build routes serve from a standalone Bun executable", async () => { + const { + createStaticAssetRoutesConfig, + getBuildStaticAssetRoutesFile, + getGeneratedBuildManifest, + } = await getPluginTestHelpers(); + + await withTempDir(async tempDir => { + const outDir = path.join(tempDir, "dist"); + const publicDir = path.join(tempDir, "public"); + const assetsDir = path.join(outDir, "assets"); + const generatedDir = path.join(tempDir, "src", "__generated__"); + const modulePath = path.join(generatedDir, "res-x-static-routes.js"); + const serverPath = path.join(tempDir, "server.js"); + const buildBinaryPath = path.join(tempDir, "resx-static-server"); + const runtimeDir = fs.mkdtempSync( + path.join(os.tmpdir(), "resx-embedded-runtime-") + ); + const runtimeBinaryPath = path.join(runtimeDir, "resx-static-server"); + const staticAssetRoutes = createStaticAssetRoutesConfig({ + mode: "embedded", + headers: { + "/assets/**": { + "Cache-Control": "public, max-age=31536000, immutable", + }, + }, + }); + const [port, releasePort] = TestUtils.getPort(); + + fs.mkdirSync(assetsDir, { recursive: true }); + fs.mkdirSync(publicDir, { recursive: true }); + fs.mkdirSync(generatedDir, { recursive: true }); + + fs.writeFileSync(path.join(publicDir, "robots.txt"), "User-agent: *"); + fs.writeFileSync(path.join(outDir, "robots.txt"), "User-agent: *"); + fs.writeFileSync( + path.join(assetsDir, "styles_css-123.css"), + "body { color: red; }" + ); + fs.writeFileSync( + path.join(assetsDir, "resXClient_js_loader-123.js"), + 'console.log("loader");' + ); + fs.writeFileSync( + path.join(assetsDir, "resXClient_js-456.js"), + 'console.log("inner");' + ); + + const generatedBuildManifest = getGeneratedBuildManifest({ + assetFileNameByBuildId: new Map([ + ["@res-x-asset-entry:styles_css", "assets/styles_css-123.css"], + ]), + bundleFileNames: [ + "assets/styles_css-123.css", + "assets/resXClient_js_loader-123.js", + "assets/resXClient_js-456.js", + ], + clientFileNameByFieldName: new Map([ + ["resXClient_js", "assets/resXClient_js_loader-123.js"], + ]), + manifest: [ + { + buildId: "@res-x-asset-entry:styles_css", + fieldName: "styles_css", + kind: "asset", + }, + { + fieldName: "resXClient_js", + kind: "client", + }, + ], + outDir, + publicDir, + staticAssetRoutes, + }); + + fs.writeFileSync( + modulePath, + getBuildStaticAssetRoutesFile({ + generatedFilePath: modulePath, + projectRoot: tempDir, + serverAssetEntries: generatedBuildManifest.serverAssetEntries, + staticAssetRoutes, + }) + ); + fs.writeFileSync( + serverPath, + `import { staticAssetRoutes } from "./src/__generated__/res-x-static-routes.js"; + +const server = Bun.serve({ + port: Number(process.env.PORT), + routes: staticAssetRoutes, + fetch: request => new Response("app:" + new URL(request.url).pathname), +}); + +console.log(server.port); +` + ); + + try { + const buildResult = Bun.spawnSync([ + "bun", + "build", + serverPath, + "--compile", + "--outfile", + buildBinaryPath, + ], { + cwd: tempDir, + stderr: "pipe", + stdout: "pipe", + }); + if (buildResult.exitCode !== 0) { + throw new Error( + Buffer.from(buildResult.stderr || []).toString() || + "bun build --compile failed" + ); + } + + fs.copyFileSync(buildBinaryPath, runtimeBinaryPath); + fs.chmodSync(runtimeBinaryPath, 0o755); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.rmSync(publicDir, { recursive: true, force: true }); + fs.rmSync(path.join(tempDir, "src"), { recursive: true, force: true }); + + const child = spawn(runtimeBinaryPath, [], { + cwd: runtimeDir, + env: { + ...process.env, + PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", chunk => { + stderr += chunk.toString(); + }); + + try { + await waitForUrl(`http://127.0.0.1:${port}/robots.txt`); + + const robotsResponse = await fetch(`http://127.0.0.1:${port}/robots.txt`); + expect(robotsResponse.status).toBe(200); + expect(await robotsResponse.text()).toBe("User-agent: *"); + + const styleResponse = await fetch( + `http://127.0.0.1:${port}/assets/styles_css-123.css` + ); + expect(styleResponse.status).toBe(200); + expect(styleResponse.headers.get("cache-control")).toBe( + "public, max-age=31536000, immutable" + ); + expect(await styleResponse.text()).toBe("body { color: red; }"); + + const clientResponse = await fetch( + `http://127.0.0.1:${port}/assets/resXClient_js_loader-123.js` + ); + expect(clientResponse.status).toBe(200); + expect(await clientResponse.text()).toBe('console.log("loader");'); + } finally { + child.kill("SIGKILL"); + await waitForChildExit(child); + } + + expect(stderr).toBe(""); + } finally { + releasePort(); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + } + }); + }); });