feat(routing): add App Router skeleton for splash/chat/auth/sidebar + proxy.ts
Wire the Flutter route map (lib/router/app_router.dart) to Next.js 16 App
Router. HTTP data layer and persistence layer are already complete; this
commit provides the UI surface and route guard that consumes them.
Routes (5 new page segments + 4 special files):
- /splash Client: useAuthGate -> auto-redirect to /chat when authed
- /auth Client: useAuthGate -> auto-redirect to /chat when authed
- /chat Client placeholder (no auth gate; guest mode allowed)
- /sidebar Client placeholder with back-to-chat button
- /chat/deviceid/[deviceId] Server Component (await PageProps) + Client
DeepLinkPersist child that writes deviceId+fbid to AuthStorage then
router.replace('/chat')
Special files (root segment):
- loading.tsx Server: centered spinner fallback
- error.tsx Client boundary using v16 `unstable_retry` prop
- not-found.tsx Server: 404 with link back to /splash
- layout.tsx MOD: wrap children in <RootProviders>, add
suppressHydrationWarning
- page.tsx MOD: redirect('/splash') from server
Shared infrastructure:
- src/lib/routes.ts typed ROUTES, ROUTE_BUILDERS,
AUTH_ONLY_ROUTES, PUBLIC_ROUTES, Route union
- src/lib/auth/route-guards.ts pure resolveAuthedRedirect/isAuthOnlyRoute
- src/lib/auth/use-auth-gate.tsx useSyncExternalStore (not useEffect +
setState) per React 19 react-hooks/set-state-in-effect rule
- src/lib/index.ts hand-written barrel (skips use-auth-gate
to keep Client-only code out of Server bundles)
- src/providers/root-providers.tsx Client wrapper + <div id="toast-portal" />
- src/proxy.ts v16 proxy (NOT deprecated middleware.ts);
cookie-only optimistic redirect from /splash,/auth to /chat when
login_token cookie is present. No-op today (tokens live in localStorage);
skeleton ready for future HttpOnly cookie migration per the
auth_storage.ts TODO(security).
Conventions:
- AGENTS.md: read node_modules/next/dist/docs/ before writing code; v16
breaking changes: middleware -> proxy, params/searchParams are Promises,
PageProps<'/literal'> global helper, error boundary prop renamed
`reset` -> `unstable_retry`.
- All placeholder UIs use the design tokens from src/app/globals.css
(no new hex colors).
- No new runtime dependencies; no test framework added.
- src/lib/ NOT added to barrelsby.json (avoids pulling Client-only
use-auth-gate into Server bundles).
Out of scope (handled separately):
- Real chat UI / auth form / sidebar widgets (deferred until repository
layer is in place)
- HttpOnly cookie migration (auth_storage.ts TODO)
- Unification of sync/async AuthStorage (deep-link writes deviceId via
sync singleton, fbid via StorageKeys.facebookId directly)
- Pre-existing data-layer barrel collision at src/data/storage/index.ts:10
(acknowledged in replicated-mapping-creek.md)
Verified:
- pnpm next typegen: OK
- pnpm exec tsc --noEmit on new files: 0 errors
- pnpm lint: 0 errors, 0 warnings
- pnpm exec next build: blocked by pre-existing src/data/storage/index.ts
barrel ambiguity (unrelated, see replicated-mapping-creek.md)
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 纯函数路由守卫
|
||||
*
|
||||
* 不依赖 React/Next.js,可被 `src/proxy.ts`(边缘运行时)与
|
||||
* `src/lib/auth/use-auth-gate.tsx`(客户端 hook)共用。
|
||||
*
|
||||
* 对齐 Flutter `lib/router/app_router.dart` 中 `_handleAuthRedirect` 的语义:
|
||||
* 已登录用户访问 `/splash` 或 `/auth` 时,重定向到 `/chat`。
|
||||
*/
|
||||
|
||||
import { AUTH_ONLY_ROUTES, ROUTES, type StaticRoute } from "../routes";
|
||||
|
||||
/**
|
||||
* 判定路径是否为"仅未登录态可见"的入口路由。
|
||||
*/
|
||||
export function isAuthOnlyRoute(pathname: string): pathname is StaticRoute {
|
||||
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定路径是否为公开路由(游客态也允许访问)。
|
||||
*/
|
||||
export function isPublicRoute(pathname: string): pathname is StaticRoute {
|
||||
return pathname === ROUTES.chat || pathname === ROUTES.sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录用户访问入口路由时返回应跳转到的目标;其余情况返回 null。
|
||||
*
|
||||
* 当前实现仅做"splash/auth → chat"的重定向;未来若新增入口路由(如 onboarding),
|
||||
* 只需在此函数追加分支。
|
||||
*/
|
||||
export function resolveAuthedRedirect(pathname: string): string | null {
|
||||
if (isAuthOnlyRoute(pathname)) return ROUTES.chat;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 客户端鉴权门 hook
|
||||
*
|
||||
* 单一真值 = `authStorage`(与 `src/data/api/interceptor/token_interceptor.ts` 同源)。
|
||||
* 故意不引入 Context/Zustand:本轮只有 splash/auth 两处使用,直接 hook 调用更直白。
|
||||
*
|
||||
* 实现要点(React 19 / Next.js 16 约定):
|
||||
* - 用 `useSyncExternalStore` 而非 `useEffect + setState`,避免 React 19
|
||||
* `react-hooks/set-state-in-effect` 规则的级联渲染告警。
|
||||
* - `getServerSnapshot` 返回 `false` → SSR 与客户端水合首帧都拿到一致值;
|
||||
* 水合完成后 React 自动重读 `getSnapshot()`(localStorage 真值),触发单次
|
||||
* 自然重渲染 → 调用方 `useEffect([isAuthed], ...)` 即捕获"已登录"事件。
|
||||
* - subscribe 用空函数(no-op):localStorage 在本应用生命周期内不会主动变化
|
||||
* (登录跳转走的是另一条 navigation 路径,重新进入 splash 时会重读)。
|
||||
*
|
||||
* 使用模式:
|
||||
* const { isAuthed } = useAuthGate();
|
||||
* useEffect(() => { if (isAuthed) router.replace(ROUTES.chat); }, [isAuthed]);
|
||||
* if (!isAuthed) return <Placeholder />; // SSR / 水合首帧 / 未登录 都会落在这
|
||||
*
|
||||
* 注:本轮不暴露 `ready` 标志——`useSyncExternalStore` 自身的 SSR / 客户端差异
|
||||
* 处理已经能保证水合首帧拿到 `false`(与 SSR 一致),无需调用方再判 ready。
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { authStorage } from "@/data/storage/auth_storage";
|
||||
|
||||
export interface AuthGateState {
|
||||
/** 已登录(持有非空 `cozsweet.loginToken`) */
|
||||
isAuthed: boolean;
|
||||
}
|
||||
|
||||
// 静态 subscribe:localStorage 在本应用生命周期内不会主动变化。
|
||||
const subscribe = (): (() => void) => () => {};
|
||||
|
||||
const getSnapshot = (): boolean => authStorage.hasLoginToken();
|
||||
|
||||
const getServerSnapshot = (): boolean => false;
|
||||
|
||||
export function useAuthGate(): AuthGateState {
|
||||
const isAuthed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return { isAuthed };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* `src/lib/` 手写 barrel
|
||||
*
|
||||
* 注意:故意**不**把 `src/lib/auth/use-auth-gate.tsx`(Client-only)放进来,
|
||||
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
||||
* 如需导入 hook,请直接 `import { useAuthGate } from "@/lib/auth/use-auth-gate";`。
|
||||
*/
|
||||
|
||||
export * from "./routes";
|
||||
export * from "./auth/route-guards";
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 类型化路由常量
|
||||
*
|
||||
* 集中管理应用中使用的路由路径,与 Flutter `lib/router/app_router.dart` 的
|
||||
* `AppRoutes` 一一对应。被服务端组件、客户端组件、`src/proxy.ts` 三端共用。
|
||||
*
|
||||
* 关键设计:
|
||||
* - `as const` 让每个值都是字符串字面量类型,可用于 `PageProps<'/literal'>`。
|
||||
* - 不导出 Client-only 模块(`use-auth-gate` 走独立路径导入),保证 Server bundle 不被污染。
|
||||
* - 动态深链通过 `ROUTE_BUILDERS` 函数生成,避免手拼字符串。
|
||||
*/
|
||||
|
||||
/** 静态路由字面量 */
|
||||
export const ROUTES = {
|
||||
root: "/",
|
||||
splash: "/splash",
|
||||
chat: "/chat",
|
||||
auth: "/auth",
|
||||
sidebar: "/sidebar",
|
||||
} as const;
|
||||
|
||||
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
|
||||
/** 动态深链构造器 */
|
||||
export const ROUTE_BUILDERS = {
|
||||
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||
} as const;
|
||||
|
||||
/** 仅未登录态可见的路由(命中后已登录用户应跳走) */
|
||||
export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
ROUTES.auth,
|
||||
] as const;
|
||||
|
||||
/** 公开路由(游客态也允许访问) */
|
||||
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.chat,
|
||||
ROUTES.sidebar,
|
||||
] as const;
|
||||
|
||||
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
|
||||
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
ROUTES.chat,
|
||||
ROUTES.auth,
|
||||
ROUTES.sidebar,
|
||||
] as const;
|
||||
|
||||
/** 联合路由类型,包含静态路由与已知动态路由 */
|
||||
export type Route = StaticRoute | `/chat/deviceid/${string}`;
|
||||
Reference in New Issue
Block a user