Files
vac-optimizer/src/server.ts
T
nessarandClaude Opus 5.5 f74d744f9b feat(server): serve the client-only build with Bun
Angular's server build needs Node loader hooks that Bun does not apply,
and every route is client-rendered, so Angular now builds a client-only
bundle and a small Bun.serve server delivers it: hashed assets with the
one-year cache, index.html revalidated, extension-less routes falling
back to index.html, missing assets 404, traversal and malformed paths
rejected, and other methods 405. Express, @angular/ssr and the unused
hydration provider are removed. Both Docker stages use the pinned
oven/bun:1.4.3-alpine image without Node, and the health checks use
Bun's fetch.

Refs #292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:50:05 +02:00

72 lines
2.2 KiB
TypeScript

import { extname, join, resolve, sep } from 'node:path';
const IMMUTABLE = 'public, max-age=31536000';
/**
* Fetch handler for a client-only Angular build in `root`: existing files are served as-is,
* extension-less paths fall back to `index.html` (client routes) and missing assets get a 404.
*/
export function createHandler(root: string) {
const base = resolve(root);
const index = join(base, 'index.html');
const isFile = (path: string) =>
Bun.file(path)
.stat()
.then(
(stats) => stats.isFile(),
() => false,
);
// index.html is not content-hashed, so it must be revalidated on every deployment.
const send = (path: string) =>
new Response(Bun.file(path), {
headers: { 'Cache-Control': path === index ? 'no-cache' : IMMUTABLE },
});
return async (request: Request): Promise<Response> => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'GET, HEAD' },
});
}
let path: string;
try {
path = decodeURIComponent(new URL(request.url).pathname);
} catch {
return new Response('Bad Request', { status: 400 });
}
const file = resolve(base, `.${path}`);
if (path.includes('\0') || (file !== base && !file.startsWith(base + sep))) {
return new Response('Bad Request', { status: 400 });
}
// shortcut: symlinks inside the build are followed; fine while the build output is trusted.
if (await isFile(file)) return send(file);
// A path with an extension names an asset: a missing one must not turn into HTML.
if (extname(file)) return new Response('Not Found', { status: 404 });
// A missing index.html is a broken deployment: fail loudly (500) instead of a silent 404.
if (!(await isFile(index))) throw new Error(`Missing ${index}`);
return send(index);
};
}
export function createServer({ root, port }: { root: string; port: number }) {
return Bun.serve({
port,
fetch: createHandler(root),
error(error) {
console.error(error);
return new Response('Internal Server Error', { status: 500 });
},
});
}
if (import.meta.main) {
const server = createServer({
root: join(import.meta.dir, '../browser'),
port: Number(process.env['PORT'] || 4000),
});
console.log(`Server listening on ${server.url}`);
}