From cf2a8d719773fbb45b60160d5d28366ad5d2879e Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 19 Jan 2026 10:14:49 -0800 Subject: [PATCH 1/4] feat: add serverRoutes option for SPA support When serverRoutes is specified, only those routes are forwarded to the server. All other routes serve index.html for SPA client-side routing. --- src/dev-server.ts | 88 ++++++++++++++++++++++++++++++++++++++--------- src/srvx.ts | 1 + 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/dev-server.ts b/src/dev-server.ts index 7e5ef8c..27fdcd9 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -8,6 +8,12 @@ export interface DevServerOptions { exclude?: (string | RegExp)[]; injectClientScript?: boolean; loadModule?: (server: ViteDevServer, entry: string) => Promise; + /** + * Routes that should be forwarded to the server. + * All other routes will serve index.html for SPA support. + * Set to undefined or empty array to forward all routes to server (old behavior). + */ + serverRoutes?: string[]; } export const defaultOptions: Partial = { @@ -38,6 +44,48 @@ interface SrvxApp { fetch: (request: Request) => Response | Promise; } +/** + * Check if a URL matches a route pattern. + * Supports wildcard (*) at the end of patterns. + */ +function matchesRoute(url: string, pattern: string): boolean { + if (pattern.endsWith("*")) { + const prefix = pattern.slice(0, -1); + return url.startsWith(prefix); + } + return url === pattern; +} + +/** + * Check if a URL matches any of the server routes. + */ +function isServerRoute(url: string, serverRoutes: string[]): boolean { + return serverRoutes.some((pattern) => matchesRoute(url, pattern)); +} + +/** + * Serve index.html with Vite transformations applied. + */ +async function serveIndexHtml( + server: ViteDevServer, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const indexPath = path.join(server.config.root, "index.html"); + if (fs.existsSync(indexPath)) { + const html = await server.transformIndexHtml( + req.url!, + fs.readFileSync(indexPath, "utf-8"), + ); + res.statusCode = 200; + res.setHeader("Content-Type", "text/html"); + res.setHeader("Content-Length", Buffer.byteLength(html)); + res.end(html); + return true; + } + return false; +} + function createMiddleware(server: ViteDevServer, options: DevServerOptions) { return async ( req: IncomingMessage, @@ -46,24 +94,10 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { ) => { const config = server.config; const base = config.base === "/" ? "" : config.base; + const serverRoutes = options.serverRoutes; - if (req.url === "/" || req.url === base || req.url === `${base}/`) { - const indexPath = path.join(config.root, "index.html"); - if (fs.existsSync(indexPath)) { - const html = await server.transformIndexHtml( - req.url, - fs.readFileSync(indexPath, "utf-8"), - ); - res.statusCode = 200; - res.setHeader("Content-Type", "text/html"); - res.setHeader("Content-Length", Buffer.byteLength(html)); - res.end(html); - return; - } - } - + // Check excluded patterns (vite assets, source files, etc) - pass to Vite const exclude = options.exclude ?? defaultOptions.exclude ?? []; - for (const pattern of exclude) { if (req.url) { if (pattern instanceof RegExp) { @@ -78,6 +112,7 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { } } + // Check if file exists in public dir - pass to Vite if (req.url?.startsWith(base)) { const publicDir = config.publicDir; if (publicDir && fs.existsSync(publicDir)) { @@ -88,6 +123,27 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { } } + // If serverRoutes is defined, check if this URL should go to the server + // If not a server route, serve index.html for SPA support + if (serverRoutes && serverRoutes.length > 0) { + if (!isServerRoute(req.url || "/", serverRoutes)) { + // Not a server route - serve index.html for SPA + if (await serveIndexHtml(server, req, res)) { + return; + } + // No index.html found, fall through to next middleware + return next(); + } + } else { + // No serverRoutes defined - old behavior: serve index.html for root + if (req.url === "/" || req.url === base || req.url === `${base}/`) { + if (await serveIndexHtml(server, req, res)) { + return; + } + } + } + + // Forward to server app let app: SrvxApp | undefined; try { diff --git a/src/srvx.ts b/src/srvx.ts index 62b1f11..6ea1d4f 100644 --- a/src/srvx.ts +++ b/src/srvx.ts @@ -30,6 +30,7 @@ export function srvx(options?: SrvxOptions): Plugin[] { exclude: mergedOptions.exclude, injectClientScript: mergedOptions.injectClientScript, loadModule: mergedOptions.loadModule, + serverRoutes: mergedOptions.serverRoutes, }), // Client build plugin From 4a5673dc53304fa0cdd9b3b3fbc87e9bdc441a64 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 19 Jan 2026 10:20:20 -0800 Subject: [PATCH 2/4] fix: strip query string when checking exclude patterns --- src/dev-server.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/dev-server.ts b/src/dev-server.ts index 27fdcd9..f4d50c4 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -96,18 +96,20 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { const base = config.base === "/" ? "" : config.base; const serverRoutes = options.serverRoutes; + // Strip query string for pattern matching + const urlPath = req.url?.split("?")[0] || "/"; + // Check excluded patterns (vite assets, source files, etc) - pass to Vite const exclude = options.exclude ?? defaultOptions.exclude ?? []; for (const pattern of exclude) { - if (req.url) { - if (pattern instanceof RegExp) { - if (pattern.test(req.url)) { - return next(); - } - } else if (typeof pattern === "string") { - if (req.url.startsWith(pattern)) { - return next(); - } + if (pattern instanceof RegExp) { + // Test both with and without query string for regex patterns + if (pattern.test(urlPath) || pattern.test(req.url || "")) { + return next(); + } + } else if (typeof pattern === "string") { + if (urlPath.startsWith(pattern) || req.url?.startsWith(pattern)) { + return next(); } } } From 5bcad353338ad5f8196796e778ea5b03096a6ed4 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 19 Jan 2026 17:31:37 -0800 Subject: [PATCH 3/4] chore: default serverRoutes to ['/api/*'] --- src/dev-server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dev-server.ts b/src/dev-server.ts index f4d50c4..3aff2fb 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -18,6 +18,7 @@ export interface DevServerOptions { export const defaultOptions: Partial = { entry: "./src/server.ts", + serverRoutes: ["/api/*"], exclude: [ /.*\.tsx?$/, /.*\.ts$/, From 73e6a327a557238bb19217dfe2aa46859f26f246 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 19 Jan 2026 17:32:12 -0800 Subject: [PATCH 4/4] fix: check serverRoutes before exclude patterns This ensures that URLs matching serverRoutes (e.g., /api/uploads/image.jpg) are forwarded to the server even if they match exclude patterns like /*.jpg$/ --- src/dev-server.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/dev-server.ts b/src/dev-server.ts index 3aff2fb..6bf1f0f 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -100,23 +100,30 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { // Strip query string for pattern matching const urlPath = req.url?.split("?")[0] || "/"; + // Check serverRoutes FIRST - these always go to the server regardless of exclude patterns + const isServerRouteMatch = serverRoutes && serverRoutes.length > 0 && isServerRoute(req.url || "/", serverRoutes); + // Check excluded patterns (vite assets, source files, etc) - pass to Vite - const exclude = options.exclude ?? defaultOptions.exclude ?? []; - for (const pattern of exclude) { - if (pattern instanceof RegExp) { - // Test both with and without query string for regex patterns - if (pattern.test(urlPath) || pattern.test(req.url || "")) { - return next(); - } - } else if (typeof pattern === "string") { - if (urlPath.startsWith(pattern) || req.url?.startsWith(pattern)) { - return next(); + // But skip this check if the URL matches a server route + if (!isServerRouteMatch) { + const exclude = options.exclude ?? defaultOptions.exclude ?? []; + for (const pattern of exclude) { + if (pattern instanceof RegExp) { + // Test both with and without query string for regex patterns + if (pattern.test(urlPath) || pattern.test(req.url || "")) { + return next(); + } + } else if (typeof pattern === "string") { + if (urlPath.startsWith(pattern) || req.url?.startsWith(pattern)) { + return next(); + } } } } // Check if file exists in public dir - pass to Vite - if (req.url?.startsWith(base)) { + // But skip this check if the URL matches a server route + if (!isServerRouteMatch && req.url?.startsWith(base)) { const publicDir = config.publicDir; if (publicDir && fs.existsSync(publicDir)) { const filePath = path.join(publicDir, req.url.replace(base, "")); @@ -129,7 +136,7 @@ function createMiddleware(server: ViteDevServer, options: DevServerOptions) { // If serverRoutes is defined, check if this URL should go to the server // If not a server route, serve index.html for SPA support if (serverRoutes && serverRoutes.length > 0) { - if (!isServerRoute(req.url || "/", serverRoutes)) { + if (!isServerRouteMatch) { // Not a server route - serve index.html for SPA if (await serveIndexHtml(server, req, res)) { return;