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:
parent
aabfacc735
commit
6666cc5f67
6 changed files with 152 additions and 143 deletions
147
src/middleware.ts
Normal file
147
src/middleware.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue