Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 86 additions & 20 deletions src/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ export interface DevServerOptions {
exclude?: (string | RegExp)[];
injectClientScript?: boolean;
loadModule?: (server: ViteDevServer, entry: string) => Promise<any>;
/**
* 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<DevServerOptions> = {
entry: "./src/server.ts",
serverRoutes: ["/api/*"],
exclude: [
/.*\.tsx?$/,
/.*\.ts$/,
Expand All @@ -38,6 +45,48 @@ interface SrvxApp {
fetch: (request: Request) => Response | Promise<Response>;
}

/**
* 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<boolean> {
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,
Expand All @@ -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, ""));
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/srvx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading