fix(dev): proxy content assets to cli server

The CLI content server serves the content directory at the URL root, so
images and other content assets are requested as root-relative paths
(e.g. /adventures/map.png). The dev proxy only forwarded /content, so
those requests fell through to historyApiFallback and the browser got
index.html instead of the asset.

Forward any request that maps to a real file under content/ to the CLI
server, keeping /static/ bundles on the web dev server.
This commit is contained in:
2026-09-13 14:24:29 +08:00
parent 12f8d8542c
commit e0e7250180
3 changed files with 65 additions and 11 deletions
+56 -8
View File
@@ -3,6 +3,43 @@ import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginSolid } from "@rsbuild/plugin-solid";
import tailwindcss from "@tailwindcss/postcss";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
import { statSync } from "node:fs";
import { join, resolve } from "node:path";
// The CLI content server (`npm run dev:cli`, i.e. `tttk serve ./content`) keeps
// port 3000. It serves the content directory at the URL *root*, so content
// assets are requested as root-relative paths (e.g. `/adventures/map.png`),
// not under `/content`.
const CLI_TARGET = "http://localhost:3000";
const CONTENT_DIR = resolve(process.cwd(), "content");
/**
* True when a request targets a real file inside the content directory.
*
* Mirrors the CLI server's static handling: only files are served, so a
* directory path falls through to the SPA fallback. Requests for content
* assets must be proxied to the CLI; otherwise they reach
* `historyApiFallback` and the browser receives `index.html` instead of the
* asset (which is why images failed to render in dev).
*/
function isContentFile(pathname: string): boolean {
// The web dev server owns its own bundled assets under /static/.
if (pathname.startsWith("/static/")) return false;
let relativePath: string;
try {
relativePath = decodeURIComponent(pathname).replace(/^\/+/, "");
} catch {
return false;
}
if (!relativePath) return false;
try {
return statSync(join(CONTENT_DIR, relativePath)).isFile();
} catch {
return false;
}
}
export default defineConfig({
plugins: [
@@ -51,14 +88,25 @@ export default defineConfig({
// Dev web server runs on its own port; the CLI content server keeps
// port 3000 (see the proxy targets below).
port: 5173,
proxy: {
// Proxy content API requests to the CLI dev server (npm run tttk)
"/__CONTENT_INDEX.json": "http://localhost:3000",
"/__COMPLETIONS.json": "http://localhost:3000",
"/__CONTENT_REGISTRY.json": "http://localhost:3000",
"/content": "http://localhost:3000",
"/.ttrpg": "http://localhost:3000",
},
proxy: [
{
// Content API requests served by the CLI dev server (npm run dev:cli).
context: [
"/__CONTENT_INDEX.json",
"/__COMPLETIONS.json",
"/__CONTENT_REGISTRY.json",
"/.ttrpg",
],
target: CLI_TARGET,
},
{
// Content assets (images, css, csv, …) live at the URL root on the CLI
// server, so forward any request that maps to a real file under
// content/ instead of letting it hit the SPA fallback.
context: (pathname: string) => isContentFile(pathname),
target: CLI_TARGET,
},
],
historyApiFallback: true,
},
output: {