From 1a6242e642aa86580ca6f2af3af14f397d13e1b6 Mon Sep 17 00:00:00 2001 From: Temuulen Ankhbayar Date: Sat, 7 Mar 2026 15:41:32 +0900 Subject: [PATCH] feat: serve Storybook via Next.js catch-all route handler Serves static storybook files from public/storybook/ through a Next.js route at /storybook, avoiding the need for separate nginx or server configuration. Co-Authored-By: Claude Opus 4.6 --- .../src/app/storybook/[[...path]]/route.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/portal/src/app/storybook/[[...path]]/route.ts diff --git a/apps/portal/src/app/storybook/[[...path]]/route.ts b/apps/portal/src/app/storybook/[[...path]]/route.ts new file mode 100644 index 00000000..4e089f8d --- /dev/null +++ b/apps/portal/src/app/storybook/[[...path]]/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs"; + +const MIME_TYPES: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ico": "image/x-icon", +}; + +function getStorybookRoot(): string { + // In standalone mode, public files are at ../public relative to server.js + // In dev mode, they're in the project's public directory + const candidates = [ + path.join(process.cwd(), "public", "storybook"), + path.join(process.cwd(), "apps", "portal", "public", "storybook"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return candidates[0]!; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const resolvedParams = await params; + const segments = resolvedParams.path; + const filePath = segments?.length ? segments.join("/") : "index.html"; + + const root = getStorybookRoot(); + const resolved = path.resolve(root, filePath); + + // Prevent path traversal + if (!resolved.startsWith(root)) { + return new NextResponse("Forbidden", { status: 403 }); + } + + if (!fs.existsSync(resolved)) { + return new NextResponse("Not found", { status: 404 }); + } + + const ext = path.extname(resolved); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + const content = fs.readFileSync(resolved); + + return new NextResponse(content, { + headers: { + "Content-Type": contentType, + "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable", + }, + }); +}