docs(app): explain request and directory lifecycle boundaries

This commit is contained in:
2026-11-12 15:57:33 +08:00
parent f78618be2b
commit 84c50fe595
7 changed files with 19 additions and 6 deletions
+4 -1
View File
@@ -27,9 +27,11 @@ export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// 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<T>(
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配置;不是密码或用户账户身份。 */
+3
View File
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
const boards = new Map<string, Board>();
let offset = 0;
const seen = new Set<number>();
@@ -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 }] : [];
});
+3 -1
View File
@@ -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<typeof setTimeout> | 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中安装的中断处理器。 */
+2 -1
View File
@@ -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) {
+3 -2
View File
@@ -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" : ""}`;
}
+2 -1
View File
@@ -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));
}
+2
View File
@@ -37,6 +37,7 @@ export async function loadWorkspaceHome(
request: Request,
signal?: AbortSignal,
) {
// 任一读取拒绝则整体拒绝,Promise.all 不会自动取消另一请求;共同 signal 由宿主管理。
const [profile, result] = await Promise.all([
request<Profile>("profile", { signal }),
request<Workspace[] | { items: Workspace[] }>("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;