refactor: migrate pages and API routes to App Router

This commit is contained in:
2026-11-05 23:20:34 +08:00
parent 982fa188e2
commit 6c4d6100b7
18 changed files with 287 additions and 174 deletions
+13 -3
View File
@@ -1,6 +1,6 @@
# 啡跃 Endoleap
本地 Next.js Pages Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
本地 Next.js App Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
项目目录:`/Users/zzyang/endoleap`。
@@ -19,6 +19,14 @@ npm run dev
- 真实工作台:/dashboard
- 本地演示:/dashboard?demo=1
## 路由结构
- `src/app/layout.tsx`:根布局、中文语言标记、全局样式与页面元信息。
- `src/app/page.tsx`:首页。
- `src/app/login/page.tsx`、`src/app/auth/callback/page.tsx`:账户页与登录回调。
- `src/app/dashboard/page.tsx`:工作台入口,以 Suspense 包裹读取查询参数的客户端工作台。
- `src/app/api/**/route.ts`:Foundry 代理与 OAuth 跳转,使用 App Router Route Handlers。
## 配置
复制 .env.example 为 .env.local,按实际部署填写:
@@ -48,9 +56,11 @@ npm run build
node tests/api-smoke.mjs
```
API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局。
API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。冒烟测试还覆盖所有页面地址、元信息、OAuth 跳转、请求体限制及退出会话。此前的浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局;本次迁移的验证结果见 VALIDATION.md。
项目尚未创建远程仓库或部署。
若执行环境限制 Turbopack 的 CSS 工作进程绑定本地端口,可使用官方 Webpack 模式:`npm run build -- --webpack`。
项目已同步至 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endoleap.git`;尚未部署。
性能分析、实测耗时和修复说明见 [PERFORMANCE.md](PERFORMANCE.md)。
+12
View File
@@ -16,3 +16,15 @@
## 真实账号创建验收
已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。
## App Router 迁移验收(2026-11-05)
- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。
- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。
- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。
- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。
- `npm run typecheck`:通过。
- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。
- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。
- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。
- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。
+1 -1
View File
@@ -4,4 +4,4 @@ import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+122
View File
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
async function readBody(req: NextRequest) {
const reader = req.body?.getReader();
if (!reader) return {};
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > bodyLimit) {
await reader.cancel();
throw new Error("body_too_large");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
if (!size) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
const json = (body: unknown, status: number) =>
NextResponse.json(body, { status, headers: responseHeaders });
const requestOrigin = req.headers.get("origin");
if (req.method !== "GET" && requestOrigin) {
try {
if (new URL(requestOrigin).host !== req.headers.get("host"))
return json({ error: "invalid_origin" }, 403);
} catch {
return json({ error: "invalid_origin" }, 403);
}
}
const path = (await params).path.join("/");
if (!allowed.test(path)) return json({ error: "not_found" }, 404);
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
}
}
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
const headers: Record<string, string> = { "Content-Type": "application/json" };
const session = req.cookies.get("foundry_session")?.value;
if (session) headers.Authorization = "Bearer " + session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
const values = req.nextUrl.searchParams.getAll(key);
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const started = performance.now();
try {
const upstream = await fetch(
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
(query.size ? "?" + query.toString() : ""),
{
method: req.method,
headers,
body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
redirect: "manual",
cache: "no-store",
},
);
const headersReceived = performance.now();
const body = upstream.status === 204 ? null : await upstream.json();
responseHeaders.set(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
if (body?.access_token) {
responseHeaders.set(
"Set-Cookie",
`foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`,
);
delete body.access_token;
}
if (path === "auth/logout" && upstream.ok)
responseHeaders.set(
"Set-Cookie",
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
if (upstream.status === 204)
return new NextResponse(null, { status: 204, headers: responseHeaders });
return json(body || { error: "upstream_unavailable" }, upstream.status);
} catch (error) {
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
const timeout = error instanceof Error && error.name === "TimeoutError";
return json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" }, timeout ? 504 : 502);
}
}
function methodNotAllowed() {
return NextResponse.json({ error: "method_not_allowed" }, {
status: 405,
headers: { "Cache-Control": "no-store", Allow: "GET, POST, PATCH, DELETE" },
});
}
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
) {
const { provider } = await params;
const challenges = req.nextUrl.searchParams.getAll("challenge");
const challenge = challenges[0];
if (
!["google", "slack"].includes(provider) ||
challenges.length !== 1 ||
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
)
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
return NextResponse.redirect(
origin + "/projects/" + encodeURIComponent(project) + "/auth/" + provider + "?challenge=" + challenge,
{ status: 302, headers: { "Cache-Control": "no-store" } },
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../login/page";
@@ -1,5 +1,7 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useRouter } from "next/router";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowUpRight,
@@ -68,7 +70,7 @@ const initialBoards: Board[] = [
];
export default function Dashboard() {
const router = useRouter();
const demo = router.query.demo === "1";
const demo = useSearchParams().get("demo") === "1";
const mutationVersion = useRef(0);
const [boardsLoading, setBoardsLoading] = useState(false);
const [spacesLoaded, setSpacesLoaded] = useState(false);
@@ -93,7 +95,6 @@ export default function Dashboard() {
[nodeText, setNodeText] = useState(""),
[note, setNote] = useState("");
useEffect(() => {
if (!router.isReady) return;
let active = true;
if (demo) {
setProfile({
@@ -137,7 +138,7 @@ export default function Dashboard() {
active = false;
controller.abort();
};
}, [router.isReady, demo, router]);
}, [demo, router]);
useEffect(() => {
if (demo && ready)
localStorage.setItem(
@@ -245,7 +246,7 @@ export default function Dashboard() {
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
await router.push("/login");
router.push("/login");
} catch (e) {
setError(errorText(e));
}
+16
View File
@@ -0,0 +1,16 @@
import { Suspense } from "react";
import { Brand } from "@/components/brand";
import Dashboard from "./dashboard";
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="loading-page">
<Brand />
<p>正在打开你的创作空间…</p>
</div>
}>
<Dashboard />
</Suspense>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "啡跃 Endoleap · 让灵感有形",
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Eye,
@@ -65,7 +67,7 @@ export default function Login() {
});
if (register)
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
else await router.push("/dashboard");
else router.push("/dashboard");
} catch (e) {
setError(errorText(e));
} finally {
+2
View File
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
-18
View File
@@ -1,18 +0,0 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "@/styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>啡跃 Endoleap · 让灵感有形</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。"
/>
</Head>
<Component {...pageProps} />
</>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="zh-CN">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
-99
View File
@@ -1,99 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.setHeader("Cache-Control", "no-store");
if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || ""))
return res.status(405).json({ error: "method_not_allowed" });
if (req.method !== "GET" && req.headers.origin) {
try {
if (new URL(req.headers.origin).host !== req.headers.host)
return res.status(403).json({ error: "invalid_origin" });
} catch {
return res.status(403).json({ error: "invalid_origin" });
}
}
const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/");
if (!allowed.test(path)) return res.status(404).json({ error: "not_found" });
const origin =
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (req.cookies.foundry_session)
headers.Authorization = "Bearer " + req.cookies.foundry_session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"])
if (typeof req.headers[h] === "string")
headers[h] = req.headers[h] as string;
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
const v = req.query[key];
if (typeof v === "string" && v.length < 300) query.set(key, v);
}
const started = performance.now();
const disconnected = new AbortController();
const onClose = () => {
if (!res.writableEnded) disconnected.abort();
};
res.once("close", onClose);
try {
const upstream = await fetch(
origin +
"/projects/" +
encodeURIComponent(project) +
"/" +
path +
(query.size ? "?" + query.toString() : ""),
{
method: req.method,
headers,
body: req.method === "GET" ? undefined : JSON.stringify(req.body || {}),
signal: AbortSignal.any([
AbortSignal.timeout(15000),
disconnected.signal,
]),
redirect: "manual",
},
);
const headersReceived = performance.now();
const body = upstream.status === 204 ? null : await upstream.json();
res.setHeader(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
const session = body?.access_token;
if (session) {
res.setHeader(
"Set-Cookie",
`foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`,
);
delete body.access_token;
}
if (path === "auth/logout" && upstream.ok)
res.setHeader(
"Set-Cookie",
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
);
if (upstream.status === 204) return res.status(204).end();
return res
.status(upstream.status)
.json(body || { error: "upstream_unavailable" });
} catch (error) {
if (res.destroyed) return;
res.setHeader(
"Server-Timing",
`total;dur=${(performance.now() - started).toFixed(1)}`,
);
const timeout = error instanceof Error && error.name === "TimeoutError";
return res
.status(timeout ? 504 : 502)
.json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" });
} finally {
res.off("close", onClose);
}
}
export const config = { api: { bodyParser: { sizeLimit: "1mb" } } };
-26
View File
@@ -1,26 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const p = req.query.provider,
c = req.query.challenge;
if (
req.method !== "GET" ||
!["google", "slack"].includes(String(p)) ||
typeof c !== "string" ||
!/^[-_a-zA-Z0-9]{43}$/.test(c)
)
return res.status(400).json({ error: "invalid_request" });
const origin =
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
res.setHeader("Cache-Control", "no-store");
res.redirect(
302,
origin +
"/projects/" +
encodeURIComponent(project) +
"/auth/" +
p +
"?challenge=" +
c,
);
}
-1
View File
@@ -1 +0,0 @@
export { default } from "../login";
+48 -2
View File
@@ -6,7 +6,12 @@ import { fileURLToPath } from "node:url";
const host = "http://127.0.0.1:3005";
const upstream = createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url.endsWith("/auth/login")) {
if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204;
res.end();
} else if (req.url.includes("/workspaces/space/materials")) {
res.end(JSON.stringify({ url: req.url, method: req.method }));
} else if (req.url.endsWith("/auth/login")) {
res.end(
JSON.stringify({
access_token: "test-only-session",
@@ -64,6 +69,16 @@ try {
await new Promise((r) => setTimeout(r, 100));
}
assert(ready, "Frontend did not start");
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1"]) {
const page = await fetch(host + path);
assert.equal(page.status, 200, path);
const html = await page.text();
assert.match(html, /<html lang="zh-CN"/);
assert.match(html, /啡跃 Endoleap · 让灵感有形/);
assert.match(html, /name="description"/);
assert.match(html, /name="viewport"/);
}
assert.equal((await fetch(host + "/missing-page")).status, 404);
const res = await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: host },
@@ -103,8 +118,39 @@ try {
(await fetch(host + "/api/oauth/slack?challenge=bad")).status,
400,
);
assert.equal(res.headers.get("cache-control"), "no-store");
const secure = await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "https" },
body: "{}",
});
assert.match(secure.headers.get("set-cookie"), /; Secure/);
for (const [body, status] of [["{", 400], [JSON.stringify({ text: "x".repeat(1024 * 1024) }), 413]]) {
const invalid = await fetch(host + "/api/foundry/auth/login", {
method: "POST", headers: { "Content-Type": "application/json" }, body,
});
assert.equal(invalid.status, status);
}
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
});
assert.deepEqual(await materials.json(), {
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
});
for (const provider of ["google", "slack"]) {
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
assert.equal(redirect.status, 302);
assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`);
}
const logout = await fetch(host + "/api/foundry/auth/logout", {
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
});
assert.equal(logout.status, 204);
assert.equal(await logout.text(), "");
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
console.log(
"PASS: session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
"PASS: App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
} finally {
const exited = once(app, "exit");
+24 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -14,9 +18,24 @@
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
}
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}