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>
72 lines
2.2 KiB
TypeScript
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}`);
|
|
}
|