From 84c50fe59550350331f9bbbd825b17bb2e4cee14 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:57:33 +0800 Subject: [PATCH] docs(app): explain request and directory lifecycle boundaries --- src/lib/api.ts | 5 ++++- src/lib/board-directory.ts | 3 +++ src/lib/canvas-response.ts | 4 +++- src/lib/notifications.ts | 3 ++- src/lib/session-cookie.ts | 5 +++-- src/lib/utils.ts | 3 ++- src/lib/workspace.ts | 2 ++ 7 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 66e3189..3e7c5c9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -27,9 +27,11 @@ export async function api( path: string, options: RequestInit = {}, ): Promise { + // fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。 const res = await fetch("/api/foundry/" + path, { ...options, - // FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。 + // FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。 + // 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。 headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers }, }); let body: unknown; @@ -49,6 +51,7 @@ export async function api( const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error; throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after")); } + // 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。 return body as T; } /** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */ diff --git a/src/lib/board-directory.ts b/src/lib/board-directory.ts index 08b28d1..d2b71bb 100644 --- a/src/lib/board-directory.ts +++ b/src/lib/board-directory.ts @@ -10,6 +10,7 @@ type Request = (path: string, options?: RequestInit) => Promise; /** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。 * next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */ export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) { + // Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。 const boards = new Map(); let offset = 0; const seen = new Set(); @@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] }; /** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。 * 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */ export function boardDirectory(boards: Board[]): BoardBranch[] { + // 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。 const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }])); const roots: BoardBranch[] = []; for (const entry of entries.values()) { @@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo if (!term) return branches; return branches.flatMap((branch) => { if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch]; + // 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。 const children = filterBoardDirectory(branch.children, term); return children.length ? [{ ...branch, children }] : []; }); diff --git a/src/lib/canvas-response.ts b/src/lib/canvas-response.ts index a85bb09..b0fcc0b 100644 --- a/src/lib/canvas-response.ts +++ b/src/lib/canvas-response.ts @@ -14,6 +14,7 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim const abort = new AbortController(); // 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。 const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; + /** 三种时限共用内部取消源;只发送取消信号,实际唤醒等待依赖 request/reader 遵守取消契约。 */ const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError")); // 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。 let timer = setTimeout(timeout, limits.headersMs); @@ -38,7 +39,8 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim }; let idle: ReturnType | undefined; let finished = false; - /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */ + /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 + * 这里只清理调度资源,不 releaseLock;reader 的取消在中断/下游 cancel 路径另行执行。 */ const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); }; let interrupt: (reason: unknown) => void; /** 将组合signal的取消原因交给流start中安装的中断处理器。 */ diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 9530b52..7670949 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -7,7 +7,8 @@ import { toast } from "sonner"; /** Application adapter. Domain/editor packages must not import Sonner. */ -/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 */ +/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 + * level 的联合类型是调用约束,运行时不另行验证;调用方必须提供通知库支持的级别。 */ export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" }; /** 按指定level调用Sonner,默认info;相同id的合并/更新行为交给通知库。 */ export function notify({ id, title, description, level = "info" }: Notification) { diff --git a/src/lib/session-cookie.ts b/src/lib/session-cookie.ts index d6f20a8..8a7a2dc 100644 --- a/src/lib/session-cookie.ts +++ b/src/lib/session-cookie.ts @@ -2,7 +2,7 @@ * 服务端会话Cookie的纯字符串与寿命辅助,不读取或写入浏览器Cookie。 * 调用方决定何时采用上游过期提示、是否Secure,并把返回字符串放进响应Set-Cookie。 */ -/** 本应用默认空闲期上限30天,单位秒。 */ +/** 会话 Cookie 的默认寿命上限30天,单位秒;是否滑动续期由调用方重新设置 Cookie 决定。 */ export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60; /** 上游向代理传递剩余会话寿命的响应头名,不是Cookie名。 */ export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In"; @@ -12,7 +12,8 @@ export function sessionMaxAge(value: unknown, fallback = SESSION_IDLE_SECONDS) { const seconds = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN; return Number.isSafeInteger(seconds) && seconds >= 0 ? Math.min(seconds, SESSION_IDLE_SECONDS) : fallback; } -/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 */ +/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 + * maxAge 非法时使用默认30天,不自动推断为登出;明确清除须传0。此处不设置 Domain 或 Expires。 */ export function sessionCookie(value: string, maxAge: number, secure: boolean) { return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b8a3697..03e7598 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,7 +3,8 @@ */ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 */ +/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 + * 后项优先只适用于 twMerge 能识别的冲突组,不能据此保证任意自定义 CSS 都按参数顺序覆盖。 */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 2980c86..f0372a1 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -37,6 +37,7 @@ export async function loadWorkspaceHome( request: Request, signal?: AbortSignal, ) { + // 任一读取拒绝则整体拒绝,Promise.all 不会自动取消另一请求;共同 signal 由宿主管理。 const [profile, result] = await Promise.all([ request("profile", { signal }), request("workspaces", { signal }), @@ -62,6 +63,7 @@ export async function createBoard( body: JSON.stringify({ name: "个人空间" }), }); id = workspace.id; + // 回调在创建画布前同步执行;回调抛错也会阻止后续 POST,但已创建的空间仍存在。 onWorkspaceCreated(workspace); } const name = title.trim() || DEFAULT_BOARD_TITLE;