fix(frontend): replace broken file-based API routes with SolidStart middleware

Vinxi 0.5.7 + @solidjs/start 1.3.2 has a build bug where file-based API
routes (src/routes/api/*) are registered in the page router tree but never
mounted as Nitro handlers in the production build, so every /api/* request
returns a framework 404.

Fix: register a SolidStart middleware (src/middleware.ts) via the
middleware config field. The middleware intercepts all /api/* paths and
proxies them to the Rust gateway, bypassing the broken page router.

Covers:
- /api/gateway/* (catch-all proxy to gateway)
- /api/kb/categories
- /api/kb/articles
- /api/kb/articles/:slug

Also tightens the dev-server vite proxy from /api to /api/kb so it
doesn't shadow the new middleware in dev.

Removes the dead src/routes/api/ tree (no longer used).
This commit is contained in:
Rimuru 2026-06-11 15:36:44 +05:30
parent aabfacc735
commit 6666cc5f67
6 changed files with 152 additions and 143 deletions

147
src/middleware.ts Normal file
View file

@ -0,0 +1,147 @@
/**
* SolidStart server middleware (runs on every request).
*
* Workaround for a Vinxi 0.5.7 + @solidjs/start 1.3.2 build issue where
* file-based API routes in `src/routes/api/*` are registered in the page
* router tree but never mounted as Nitro handlers, so every `/api/*`
* request returns a 404 from the SolidStart page renderer.
*
* This middleware intercepts `/api/*` paths at the SolidStart middleware
* layer (which IS in the request pipeline) and proxies them to the Rust
* gateway.
*
* Responsibilities:
* - /api/gateway/*path proxy to Rust gateway
* - /api/kb/categories proxy to Rust gateway
* - /api/kb/articles proxy to Rust gateway
* - /api/kb/articles/:slug proxy to Rust gateway
*
* Uses the @solidjs/start `createMiddleware` pattern, which Vinxi/h3 will
* actually invoke via the `onRequest` hook.
*/
import { createMiddleware } from "@solidjs/start/middleware";
const GATEWAY_URL = (
process.env.GATEWAY_URL || "http://nxtgauge-rust-gateway:9100"
).replace(/\/+$/, "");
const PUBLIC_API_URL = (
process.env.PUBLIC_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
`${GATEWAY_URL}/api`
).replace(/\/+$/, "");
function buildUpstream(path: string, query: string = ""): string {
// PUBLIC_API_URL ends with /api; path starts with /api/...
// Strip the /api prefix from path so we don't double up.
if (PUBLIC_API_URL.endsWith("/api")) {
const stripped = path.replace(/^\/api/, "");
return `${PUBLIC_API_URL}${stripped}${query}`;
}
return `${PUBLIC_API_URL}${path}${query}`;
}
async function proxyToGateway(fetchEvent: any, upstreamPath: string) {
const req = fetchEvent.request;
const method = req.method.toUpperCase();
const url = new URL(req.url);
const queryString = url.search || "";
// Read body for methods that have one
let body: BodyInit | undefined;
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
try {
body = await req.clone().text();
} catch {
body = undefined;
}
}
// Forward auth + content-type + cookie
const headers: Record<string, string> = {
"Content-Type":
req.headers.get("content-type") || "application/json",
};
const auth = req.headers.get("authorization");
if (auth) headers["Authorization"] = auth;
const cookie = req.headers.get("cookie");
if (cookie) headers["Cookie"] = cookie;
const upstream = buildUpstream(upstreamPath, queryString);
let response: Response;
try {
response = await fetch(upstream, {
method,
headers,
body,
cache: "no-store",
});
} catch (err: any) {
return new Response(
JSON.stringify({
success: false,
error: `Gateway unreachable: ${err?.message || "unknown"}`,
}),
{
status: 502,
headers: { "Content-Type": "application/json" },
},
);
}
// Copy response headers (skip hop-by-hop), ensure Content-Type
const respHeaders = new Headers();
response.headers.forEach((value, key) => {
const k = key.toLowerCase();
if (k === "server" || k === "transfer-encoding" || k === "connection") return;
respHeaders.set(key, value);
});
if (!respHeaders.get("content-type")) {
respHeaders.set("Content-Type", "application/json");
}
const respBody = await response.text();
return new Response(respBody, {
status: response.status,
statusText: response.statusText,
headers: respHeaders,
});
}
export default createMiddleware({
onRequest: async (fetchEvent) => {
const url = new URL(fetchEvent.request.url);
const path = url.pathname;
// Only handle /api/* paths
if (!path.startsWith("/api/")) return;
// Gateway proxy catch-all: /api/gateway/* → strip /api/gateway, send rest
if (path === "/api/gateway" || path.startsWith("/api/gateway/")) {
const subPath = path.slice("/api/gateway".length) || "/";
// Normalize to /api/... contract for the Rust gateway
const normalized =
subPath.startsWith("/api/") || subPath === "/api"
? subPath
: `/api${subPath}`;
return proxyToGateway(fetchEvent, normalized);
}
// Knowledge base routes
if (path === "/api/kb/categories") {
return proxyToGateway(fetchEvent, "/api/kb/categories");
}
if (path === "/api/kb/articles") {
return proxyToGateway(fetchEvent, "/api/kb/articles");
}
if (path.startsWith("/api/kb/articles/")) {
return proxyToGateway(fetchEvent, path);
}
// Everything else under /api/* — let it fall through.
// Returning undefined tells the framework to continue.
return;
},
});

View file

@ -1,87 +0,0 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
/**
* Generic gateway proxy endpoint
* Forwards all requests to the Rust backend gateway with proper auth headers
* Usage: /api/gateway/api/companies/jobs forwards to gateway /api/companies/jobs
*/
export async function GET({ request, params }: { request: Request; params: any }) {
return proxyRequest('GET', request, params);
}
export async function POST({ request, params }: { request: Request; params: any }) {
return proxyRequest('POST', request, params);
}
export async function PUT({ request, params }: { request: Request; params: any }) {
return proxyRequest('PUT', request, params);
}
export async function DELETE({ request, params }: { request: Request; params: any }) {
return proxyRequest('DELETE', request, params);
}
export async function PATCH({ request, params }: { request: Request; params: any }) {
return proxyRequest('PATCH', request, params);
}
async function proxyRequest(method: string, request: Request, params: any) {
try {
// Handle different param structures
let pathArray = params.path;
if (!Array.isArray(pathArray)) {
pathArray = [pathArray];
}
const rawPath = `/${pathArray.join('/')}`;
// Normalize all forwarded routes to the Rust gateway's /api/* contract.
const path = rawPath.startsWith('/api/') || rawPath === '/api'
? rawPath
: `/api${rawPath}`;
// Preserve query string
const url = new URL(request.url);
const queryString = url.search ? url.search : '';
// Build request body if needed
let body: string | undefined;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
body = await request.text();
}
// Forward to gateway
const upstreamUrl = gatewayUrl(path + queryString);
const upstreamRequest = new Request(upstreamUrl, {
method,
headers: withAuthHeaders(request, {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
}),
body,
cache: 'no-store',
});
const response = await fetch(upstreamRequest);
// Copy response headers and return
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
if (!['server', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
responseHeaders.set('Content-Type', 'application/json');
const responseBody = await response.text();
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -1,19 +0,0 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const upstream = gatewayUrl('/api/kb/articles' + url.search);
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -1,18 +0,0 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET({ params }: { params: { slug: string } }) {
const upstream = gatewayUrl(`/api/kb/articles/${params.slug}`);
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -1,18 +0,0 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET() {
const upstream = gatewayUrl('/api/kb/categories');
try {
const res = await fetch(upstream, { cache: 'no-store' });
const body = await res.text();
return new Response(body, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Gateway error' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -2,6 +2,10 @@
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
// Register our Nitro middleware that handles all /api/* paths.
// Workaround for Vinxi 0.5.7 + @solidjs/start 1.3.2 build issue
// where file-based API routes are not mounted as Nitro handlers.
middleware: "./src/middleware.ts",
vite: {
plugins: [tailwindcss()],
server: {
@ -14,7 +18,7 @@ export default defineConfig({
.replace(/^\/api\/gateway\/api(\/|$)/, "/api$1")
.replace(/^\/api\/gateway(\/|$)/, "/api$1"),
},
"/api": {
"/api/kb": {
target: "http://localhost:9100",
changeOrigin: true,
},