diff --git a/src/dev-server.ts b/src/dev-server.ts index 7e5ef8c..6bf1f0f 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -8,10 +8,17 @@ 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 = { entry: "./src/server.ts", + serverRoutes: ["/api/*"], exclude: [ /.*\.tsx?$/, /.*\.ts$/, @@ -38,6 +45,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,39 +95,35 @@ 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; - } - } + // Strip query string for pattern matching + const urlPath = req.url?.split("?")[0] || "/"; - const exclude = options.exclude ?? defaultOptions.exclude ?? []; + // Check serverRoutes FIRST - these always go to the server regardless of exclude patterns + const isServerRouteMatch = serverRoutes && serverRoutes.length > 0 && isServerRoute(req.url || "/", serverRoutes); - for (const pattern of exclude) { - if (req.url) { + // Check excluded patterns (vite assets, source files, etc) - pass to Vite + // 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) { - if (pattern.test(req.url)) { + // 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 (req.url.startsWith(pattern)) { + if (urlPath.startsWith(pattern) || req.url?.startsWith(pattern)) { return next(); } } } } - if (req.url?.startsWith(base)) { + // Check if file exists in public dir - pass to Vite + // 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, "")); @@ -88,6 +133,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 (!isServerRouteMatch) { + // 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