feat: centralize routes and navigation guards

This commit is contained in:
donghyeon-ka
2026-07-25 21:03:57 +09:00
parent bfc642875c
commit b221453c15
8 changed files with 317 additions and 11 deletions
+6 -11
View File
@@ -1,18 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
/** @param {{ environment: string }} props */
function BootstrapShell({ environment }) {
return (
<main>
<h1>Clean Architecture Frontend</h1>
<p>{environment} 런타임 계약이 검증되었습니다.</p>
</main>
);
}
/** @param {{ supportReference: string }} props */
function BootErrorShell({ supportReference }) {
return (
@@ -36,7 +28,10 @@ async function boot() {
const runtime = await loadRuntimeConfig();
root.render(
<StrictMode>
<BootstrapShell environment={runtime.config.APP_ENV} />
<AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
/>
</StrictMode>,
);
} catch (error) {
+63
View File
@@ -0,0 +1,63 @@
/**
* @typedef {{
* routeId: string,
* path: string,
* paramsSchema: string | null,
* searchSchema: string | null,
* access: "public" | "session-required" | "integration-defined",
* loadingSurface: string,
* errorSurface: string,
* chunkId: string
* }} RouteDefinition
*/
/** @param {RouteDefinition} definition */
const route = (definition) => Object.freeze(definition);
export const ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({
routeId: "APP_HOME",
path: "/",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
}),
SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST",
path: "/sample/resources",
paramsSchema: null,
searchSchema: "SampleResourceListQuery",
access: "integration-defined",
loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-sample-resources",
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
path: "*",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "none",
errorSurface: "not-found",
chunkId: "route-not-found",
}),
});
/** @param {string} routeId */
export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
+79
View File
@@ -0,0 +1,79 @@
import {
BrowserRouter,
Link,
Route,
Routes,
} from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { decideRouteAccess } from "./navigation-policy.js";
function HomePage() {
return (
<main>
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약이 검증되었습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
</main>
);
}
function SamplePlaceholder() {
return (
<main>
<h1>샘플 리소스</h1>
<p>계약 fixture를 준비하고 있습니다.</p>
</main>
);
}
function NotFoundPage() {
return (
<main>
<h1>페이지를 찾을 없습니다.</h1>
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
</main>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort
* }} props
*/
function GuardedSampleRoute({ authSession }) {
const decision = decideRouteAccess(
"SAMPLE_RESOURCE_LIST",
authSession.getState(),
);
if (!decision.allowed) {
return (
<main>
<h1>세션이 필요합니다.</h1>
<button type="button">로그인</button>
</main>
);
}
return <SamplePlaceholder />;
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string
* }} props
*/
export function AppRouter({ authSession, basename = "/" }) {
return (
<BrowserRouter basename={basename}>
<Routes>
<Route path={routePath("APP_HOME")} element={<HomePage />} />
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={<GuardedSampleRoute authSession={authSession} />}
/>
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
@@ -0,0 +1,37 @@
import { getRoute } from "../../contracts/routes.js";
/**
* @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId);
if (route.access === "public") return { allowed: true, action: "none" };
if (sessionState === "authenticated") {
return { allowed: true, action: "none" };
}
if (sessionState === "recovery-pending") {
return { allowed: false, action: "wait-for-session" };
}
return { allowed: false, action: "show-sign-in" };
}
export function createRedirectLoopGuard() {
const visitedPairs = new Set();
return Object.freeze({
/**
* @param {string} source
* @param {string} target
*/
allow(source, target) {
const pair = `${source}->${target}`;
if (source === target || visitedPairs.has(pair)) return false;
visitedPairs.add(pair);
return true;
},
reset() {
visitedPairs.clear();
},
});
}