diff --git a/src/lib/board-directory.ts b/src/lib/board-directory.ts index f5f4de2..08b28d1 100644 --- a/src/lib/board-directory.ts +++ b/src/lib/board-directory.ts @@ -1,7 +1,14 @@ +/** + * 工作区画布目录的读取与展示树派生。完整分页包含子分区,随后用parent_board_id构建侧栏关系。 + * 显示树可保守容纳孤儿/循环记录,不据此修改服务端owner,也不替代文档模型中的canvas树校验。 + */ import { matchesRef, type Board } from "./workspace.ts"; +/** 页面注入的通用JSON请求能力,传入signal供导航/卸载取消。 */ type Request = (path: string, options?: RequestInit) => Promise; /** Include children and finish pagination before building ownership paths. */ +/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。 + * next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */ export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) { const boards = new Map(); let offset = 0; @@ -16,7 +23,10 @@ export async function loadBoardDirectory(request: Request, workspace: string, si offset = page.next_offset; } } +/** 侧栏派生分支:保留原board记录引用,children是本次展示树容器。 */ export type BoardBranch = { board: Board; children: BoardBranch[] }; +/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。 + * 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */ export function boardDirectory(boards: Board[]): BoardBranch[] { const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }])); const roots: BoardBranch[] = []; @@ -33,12 +43,14 @@ export function boardDirectory(boards: Board[]): BoardBranch[] { } return roots; } +/** 按ID或slug找到当前项,包含自身向上收集祖先ID,重复ID即停,供侧栏自动展开。 */ export function activeBoardAncestors(boards: Board[], ref?: string) { const result = new Set(); let item = boards.find((board) => matchesRef(board, ref)); while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); } return result; } +/** 忽略首尾空白并做本地小写匹配;父标题命中保留整子树,否则保留命中后代及其路径祖先。 */ export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] { const term = query.trim().toLocaleLowerCase(); if (!term) return branches; diff --git a/src/lib/browser-canvas-migration.ts b/src/lib/browser-canvas-migration.ts index 6ccdf3b..1037bcd 100644 --- a/src/lib/browser-canvas-migration.ts +++ b/src/lib/browser-canvas-migration.ts @@ -1,30 +1,45 @@ +/** + * 迁移协调器的浏览器装配层。旧编辑视图持共享Web Lock,迁移操作取同一键的独占锁, + * 配合本地pending记录阻止已发出迁移却结果未知时继续旧格式写入。锁协调本浏览器,服务端版本/幂等校验另行负责。 + */ import type {Registry} from './canvas-core/types.ts'; import {fault} from './canvas-core/values.ts'; import {createFoundryTransport} from './canvas-service/foundry.ts'; import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts'; +/** 页面提供身份、可迁移根和完成后的重开回调;rootId缺失不代表可任意推测迁移范围。 */ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void}; +/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */ type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage}; +/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */ const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing'; +/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */ function ports(options:BrowserScope){ const locks=options.locks??globalThis.navigator?.locks; if(!locks)throw fault('migration_lock_unavailable'); return{locks,storage:options.storage??globalThis.localStorage}; } +/** 获取锁前后均检查身份/取消,旧页面不能借已等待的锁继续对新账户操作。 */ const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();}; /** Held for the entire lifetime of an editable legacy view, not just each PATCH. */ +/** 为旧编辑视图整个生命周期借共享锁,release返回Promise确认锁请求已结束。 + * ifAvailable不排长队;迁移占用或本canvas有未确认迁移时立即拒绝开放旧编辑。 */ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){ check(options);const{locks,storage}=ports(options); let resolve!:(value:{release():Promise})=>void,reject!:(error:unknown)=>void; + // ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。 const ready=new Promise<{release():Promise}>((yes,no)=>{resolve=yes;reject=no;}); const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{ try{ check(options);if(!lock)throw fault('migration_busy'); if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown'); check(options); + // 回调等待released以保持共享锁;不能在第一次PATCH完成后就把编辑门解除。 let unlock!:()=>void;const released=new Promise(yes=>{unlock=yes;}); + /** 解开锁回调并等待平台释放;resolve可重复调用,已释放后的再次release仍等同一请求结束。 */ const release=async()=>{unlock();await request;}; + /** 页面生命周期中止时释放锁,异步清理异常不制造未处理拒绝。 */ const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true}); resolve({release}); try{await released;}finally{options.signal.removeEventListener('abort',abort);} @@ -33,11 +48,13 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st void request.catch(reject);return ready; } +/** 连接localStorage、专属账户transport和UUID生成器;真正迁移在同一工作区独占锁内执行。 */ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){ check(options);const{locks,storage}=ports(options); return createCanvasMigration({scope:options.scope,registry:options.registry,storage, transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}), isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(), + // 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。 lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{ check(options);if(!lock)throw fault('migration_busy');return work(); }), diff --git a/src/lib/canvas-assets.ts b/src/lib/canvas-assets.ts index 076ba66..4e37adb 100644 --- a/src/lib/canvas-assets.ts +++ b/src/lib/canvas-assets.ts @@ -1,15 +1,26 @@ +/** + * 旧画布的图片上传辅助,与新版canvas-service/assets的冻结资源协议分开保留。 + * 同一次调用的重试沿用FormData和幂等键;这里只返回旧版素材记录,不维护可恢复上传账本。 + */ +/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */ type Request = (path: string, options?: RequestInit) => Promise; +/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */ export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string }; +/** 上传原文件大小上限20MiB,空文件同样拒绝。 */ export const ASSET_LIMIT = 20 * 1024 * 1024; +/** 仅网络TypeError或5xx且未取消时追加一次同键重试;其它错误直接交给调用方。 + * 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */ export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise { if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large"); const form = new FormData(); form.append("image", file, name); + /** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */ const send = () => request(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal }); let asset: CanvasAsset; try { asset = await send(); } catch (error) { if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error; asset = await send(); // The same bytes/key retry a possibly committed upload. } + // 成功HTTP不等于合法素材,继续核对ID、规范源站/路径及正有限尺寸,不接受任意远端URL。 if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response"); let url: URL; try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); } diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts index 51522ee..33a6d20 100644 --- a/src/lib/canvas-entry.ts +++ b/src/lib/canvas-entry.ts @@ -1,3 +1,7 @@ +/** + * 页面打开画布的协议分流入口。先读取 canonical 模型,再决定使用新版 engine 或旧版 snapshot 编辑器, + * 防止已迁移项目误走旧快照写入;迁移提示只说明当前可迁移,不能直接作为冻结迁移请求使用。 + */ import type {LiveModelPreview} from './canvas-runtime/model-preview.ts'; import type { Profile } from './api.ts'; import type { CanvasRequest } from './canvas-sync.ts'; @@ -8,16 +12,21 @@ import type { Registry } from './canvas-core/types.ts'; import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canvas-service/foundry.ts'; /** Read canonical first: a migrated project must never be opened through snapshot writers. */ +/** 并行解析画布引用与账户,再按实际工作区/画布ID读取模型。可注入共享readModel以取得渐进预览, + * 或注入基础transport做完整读取;ready 返回headless,其它允许回退的情形才读取旧snapshot。 */ export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) { const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`; + // slug/路由ref先解析成真实ID,画布kind、引用和工作区/账户信息必须匹配再选择服务作用域。 const [board, profile] = await Promise.all([options.request(path, { signal: options.signal }), options.request('profile', { signal: options.signal })]); options.signal?.throwIfAborted(); if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference'); if(!options.readModel&&!options.transport)throw new Error('model_loader_unavailable'); let migration:Extract|undefined; try { + // 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。 const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal); options.signal?.throwIfAborted(); + // 新模型一旦ready立即走新版,不能因旧数据端点仍存在就继续让旧writer写它。 if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model }; // Existing projects keep their current editor until the user explicitly migrates them. // This read describes eligibility, not a frozen write request: the migration @@ -25,8 +34,10 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa migration=model; } catch (error) { options.signal?.throwIfAborted(); + // 仅明确not_found允许旧端点回退,身份/权限/协议/网络错误继续抛出,避免掩盖新模型故障。 if ((error as { code?: string }).code !== 'not_found') throw error; } + // 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。 const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } })); return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) }; } diff --git a/src/lib/canvas-icon-loader.ts b/src/lib/canvas-icon-loader.ts index b4b6407..4b306a8 100644 --- a/src/lib/canvas-icon-loader.ts +++ b/src/lib/canvas-icon-loader.ts @@ -1,15 +1,25 @@ +/** + * 公开图标资源的分片定位、格式校验与共享读取。manifest给出构建产物路径,模块级资源加载器 + * 合并同分片请求并缓存解析结果;它不保存画布节点状态或私人资源授权。 + */ import manifest from "./canvas-icon-assets.json" with { type: "json" }; import { createResourceLoader } from "./resource-loader.ts"; +/** React绘制可用的SVG基础形状及属性列表,不接收任意HTML/SVG字符串。 */ export type IconPaths = [string, Record][]; +/** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */ export function iconAsset(name: string) { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`; return `${manifest.root}/${manifest.assets[key as keyof typeof manifest.assets]}`; } +// 限定基础图形标签,不提供script、foreignObject或任意节点构造能力。 const allowedTags = new Set(["path", "circle", "ellipse", "rect", "line", "polyline", "polygon"]); +// 属性名白名单配合值类型与外部引用模式检查,拒绝任意事件处理/URL内容。 const allowedAttributes = new Set(["key", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "x2", "y1", "y2", "width", "height", "points", "fill", "fillRule", "clipRule", "stroke", "strokeWidth", "strokeLinecap", "strokeLinejoin", "opacity", "transform"]); +/** 校验图标名、非空且最多100项的路径数组、标签与属性形状,并拒绝url()/javascript:/data:值。 + * 此处是特定分片结构验证,不宣称具有全文件字节预算或任意SVG净化能力。 */ export function validateIconShard(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid icon data"); for (const [id, paths] of Object.entries(value)) { @@ -23,12 +33,15 @@ export function validateIconShard(value: unknown): Record { } return value as Record; } +/** 单例分片加载器使用HTTP强缓存,响应失败或校验失败不写入成功缓存。 */ const resources = createResourceLoader(async (url, signal) => { const response = await fetch(url, { signal, cache: "force-cache" }); if (!response.ok) throw new Error("图标加载失败"); return validateIconShard(await response.json()); }); +/** 缓存命中只取指定图标路径,不发请求;未加载或分片没有该名均返回undefined。 */ export function peekIcon(name: string) { return resources.peek(iconAsset(name))?.[name]; } +/** 加入分片共享读取,取消只撤销本调用者;分片就绪但缺指定图标时给出明确错误。 */ export async function loadIcon(name: string, signal?: AbortSignal) { const paths = (await resources.request(iconAsset(name), signal))[name]; if (!paths) throw new Error("未找到图标"); diff --git a/src/lib/canvas-loader.ts b/src/lib/canvas-loader.ts index 230ae38..11f142d 100644 --- a/src/lib/canvas-loader.ts +++ b/src/lib/canvas-loader.ts @@ -1,7 +1,13 @@ +/** + * 旧版画布的联合读取工具,并行取得目录元数据与v2快照。 + * 它不协商canonical新模型,迁移感知的页面入口应使用canvas-entry中的分流流程。 + */ import { CANVAS_ACCEPT } from "./canvas-v2.ts"; import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts"; import { matchesRef, type Board } from "./workspace.ts"; +/** 逐段编码路由引用,校验board身份和工作区,再解码快照;AbortSignal传给两次请求。 + * 返回数据不直接更新UI,导航是否仍有效由调用方控制。 */ export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) { const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`; const [board, snapshot] = await Promise.all([request(path, { signal }), request(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]); diff --git a/src/lib/canvas-migration.ts b/src/lib/canvas-migration.ts index dba1e77..9c6e87b 100644 --- a/src/lib/canvas-migration.ts +++ b/src/lib/canvas-migration.ts @@ -1,43 +1,60 @@ +/** + * 旧snapshot到canonical模型的身份交接协议,发生在ProjectEngine建立前。 + * 每账户/工作区保存一份冻结迁移请求;未知结果保留pending并查询原身份,明确未生效才可重新准备。 + * 本地记录、浏览器互斥和服务端幂等/版本校验分别承担恢复、同端互斥和最终写入判定,不能互相替代。 + */ import type {Id, Json, Registry} from './canvas-core/types.ts'; import {equal, fault, freeze, validJson} from './canvas-core/values.ts'; import {loadFoundryModel, type ServiceResponse, type ServiceTransport} from './canvas-service/foundry.ts'; +/** 迁移记录按账户与工作区隔离;具体树范围由job.rootId及expected_versions约束。 */ export type CanvasMigrationScope = {readonly accountId:Id;readonly workspaceId:Id}; +/** version是本地记录版本,body是冻结的远端协议v2请求字符串;pending表示结果未结清。 */ export type CanvasMigrationJob = CanvasMigrationScope & { readonly version:1;readonly rootId:Id;readonly id:Id;readonly body:string; readonly phase:'pending'|'rejected';readonly rejection?:Json; }; +/** idle没有作业;applied/already-migrated可转新版;pending仍需锁住相关旧编辑;rejected有明确否定证据。 */ export type CanvasMigrationResult = | {readonly status:'idle'} | {readonly status:'applied'|'already-migrated';readonly rootId:Id} | {readonly status:'pending';readonly job:CanvasMigrationJob;readonly error:string} | {readonly status:'rejected';readonly job:CanvasMigrationJob;readonly error:string}; +/** 最小本地KV端口,不保存凭证;写入/删除后回读确认记录确实可见。 */ type Store = Pick; +// 与服务端一致的范围/请求UUID语法,避免恢复任意不受支持的身份。 const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; +/** 检查持久记录或回执的最外层对象,具体字段在后续解释器中校验。 */ const object=(value:unknown):Record=>{ if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_migration_record');return value as Record; }; +/** 未认识的字段按解释器不兼容处理,不静默删字段后继续一次可能改变身份的迁移。 */ const exact=(value:Record,keys:readonly string[])=>{if(Object.keys(value).some(key=>!keys.includes(key)))throw fault('migration_interpreter_unavailable');}; +/** 迁移版本向量包含根且最多256张canvas,版本必须为正安全整数并给+1结果留出空间。 */ function vector(value:unknown,root:Id){ const versions=object(value),ids=Object.keys(versions); if(!ids.length||ids.length>256||!Object.hasOwn(versions,root)||ids.some(id=>!uuid.test(id)||typeof versions[id]!=='number'||!Number.isSafeInteger(versions[id])||(versions[id] as number)<1||(versions[id] as number)>=Number.MAX_SAFE_INTEGER))throw fault('invalid_migration_record'); return versions as Record; } +/** 校验账户/工作区后产生稳定KV键,一工作区同一时刻只有一份身份迁移记录。 */ export function canvasMigrationStorageKey(scope:CanvasMigrationScope){ if(!uuid.test(scope.accountId)||!uuid.test(scope.workspaceId))throw fault('invalid_migration_scope'); // Only one identity migration per workspace. Unrelated projects remain editable. return `endoleap_canvas_migration_v1:${scope.accountId}:${scope.workspaceId}`; } +/** 解析冻结body并对照request_id/协议,返回后续阻塞范围与回执版本校验用的原始版本向量。 */ function payload(job:CanvasMigrationJob){ const raw=object(JSON.parse(job.body));exact(raw,['protocol_version','request_id','expected_versions']); if(raw.protocol_version!==2||raw.request_id!==job.id)throw fault('invalid_migration_record'); return vector(raw.expected_versions,job.rootId); } +/** 只有匹配协议/请求且terminal not-applied的有效JSON回执,才允许认定这次迁移明确未生效。 */ function negative(job:CanvasMigrationJob,value:unknown){ const body=object(value); if(!validJson(body)||body.protocol_version!==2||body.request_id!==job.id||body.status!=='not-applied'||body.terminal!==true||typeof body.error!=='string'||!body.error)throw fault('invalid_migration_receipt'); return body.error; } +/** 读取并严格验证本地记录、作用域、body及阶段证据;损坏/未知版本不当作“没有任务”丢弃。 */ export function readCanvasMigration(storage:Pick,scope:CanvasMigrationScope):CanvasMigrationJob|undefined{ const text=storage.getItem(canvasMigrationStorageKey(scope));if(text===null)return; let raw:Record; @@ -51,6 +68,7 @@ export function readCanvasMigration(storage:Pick,scope:Canvas else if(Object.hasOwn(raw,'rejection'))throw fault('invalid_migration_record'); return freeze(job); } +/** 只阻塞pending覆盖的根/子canvas;rejected已有否定证据,不继续用未知结果状态锁编辑。 */ export function migrationBlocksCanvas(job:CanvasMigrationJob|undefined,canvasId:Id){ return !!job&&job.phase==='pending'&&(job.rootId===canvasId||Object.hasOwn(payload(job),canvasId)); } @@ -60,22 +78,30 @@ export function migrationBlocksCanvas(job:CanvasMigrationJob|undefined,canvasId: * lock() must exclude other migrations for this account/workspace (Web Locks in * the browser). Storage contains no credentials and never rewrites a frozen body. */ +/** 注入互斥锁、存储与服务端口,提供只读状态、恢复查询和新迁移准备。 + * start必须先drain旧writer,再重新读取版本;resume从未确认的原body继续,不重新生成身份。 */ export function createCanvasMigration(options:{ scope:CanvasMigrationScope;storage:Store;transport:ServiceTransport;registry:Registry; isCurrentIdentity():boolean;createId():Id;lock(work:()=>Promise):Promise;signal?:AbortSignal; }){ const key=canvasMigrationStorageKey(options.scope);let active=false; + /** 每个异步边界重查账户与取消状态,避免迟到结果删除另一身份正在使用的记录。 */ const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();}; + /** 本实例读取也受当前身份护栏约束,原始readCanvasMigration仅负责数据格式。 */ const read=()=>{current();return readCanvasMigration(options.storage,options.scope);}; + /** 迁移/回执查询始终按冻结root寻址,不按当前页面任意子分区重定向。 */ const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`; + /** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */ function save(job:CanvasMigrationJob){ current();const encoded=JSON.stringify(job);options.storage.setItem(key,encoded); if(options.storage.getItem(key)!==encoded)throw fault('migration_not_durable'); } + /** 解释已返回结果。肯定/否定回执都要核身份;无法验证时返回pending,保留原请求供后续查证。 */ async function accept(job:CanvasMigrationJob,response:ServiceResponse):Promise{ current();let body:Record; try{body=object(response.body);}catch{return{status:'pending',job,error:'invalid_migration_receipt'};} const matching=body.protocol_version===2&&body.request_id===job.id; + // 明确终结否定证据单独保存为rejected,用户下次start可刷新基线并生成新请求。 if(matching&&body.status==='not-applied'&&body.terminal===true&&response.status>=200&&response.status<500){ let error:string;try{error=negative(job,body);}catch{return{status:'pending',job,error:'invalid_migration_receipt'};} const rejected=freeze({...job,phase:'rejected' as const,rejection:JSON.parse(JSON.stringify(body)) as Json});save(rejected); @@ -84,24 +110,29 @@ export function createCanvasMigration(options:{ if(matching&&body.status==='applied'&&response.status>=200&&response.status<300){ try{ const expected=payload(job); + // applied必须对应冻结源版本,且包含可验证的新模型及完整的新旧元素身份映射。 if(!equal(body.source_versions,expected)||!Array.isArray(body.identity_map))throw fault('invalid_migration_receipt'); const loaded=await loadFoundryModel(async()=>({status:200,body:{model:body.model}}),options.scope.workspaceId,job.rootId,options.registry); current();if(loaded.status!=='ready'||loaded.document.projectId!==job.rootId)throw fault('invalid_migration_receipt'); for(const[id,version]of Object.entries(expected))if(loaded.versions.get(id)!==version+1)throw fault('invalid_migration_receipt'); + // 源身份用canvas+结构+旧ID三元组,防止不同旧分区里的同名元素被错误合并。 const sources=new Set(),targets=new Set(); for(const value of body.identity_map){ const mapping=object(value),source=JSON.stringify([mapping.sourceCanvasId,mapping.structure,mapping.sourceId]),target=mapping.elementId; if(typeof mapping.sourceCanvasId!=='string'||!Object.hasOwn(expected,mapping.sourceCanvasId)||!['node','connection'].includes(mapping.structure as string)||typeof mapping.sourceId!=='string'||!mapping.sourceId||typeof target!=='string'||!uuid.test(target)||loaded.document.elements.get(target)?.structure!==mapping.structure||sources.has(source)||targets.has(target))throw fault('invalid_migration_receipt'); sources.add(source);targets.add(target); } + // 目标ID唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。 if(targets.size!==loaded.document.elements.size)throw fault('invalid_migration_receipt'); }catch(error){current();return{status:'pending',job,error:(error as {code?:string}).code??'invalid_migration_receipt'};} + // 只在整个成功回执通过后删除本地pending;删除也须回读确认,失败保留重试义务。 current();options.storage.removeItem(key); if(options.storage.getItem(key)!==null)throw fault('migration_not_durable'); return{status:'applied',rootId:job.rootId}; } return{status:'pending',job,error:typeof body.error==='string'?body.error:'migration_response_unknown'}; } + /** GET查原请求或POST精确原body;网络异常不等于未执行,只返回pending继续保留冻结身份。 */ async function send(job:CanvasMigrationJob,query:boolean):Promise{ current();let response:ServiceResponse; try{ @@ -111,18 +142,22 @@ export function createCanvasMigration(options:{ const body=response.body as Record|null; // The current canonical receipt endpoint omits protocol_version on unknown. // This is permission to retry the exact idempotent migration, never zero-write proof. + // 此协议允许unknown后重发同一幂等请求,绝不因此改requestId/body或宣称先前零写入。 if(query&&response.status===404&&(body?.protocol_version===undefined||body.protocol_version===2)&&body?.request_id===job.id&&body.status==='unknown')return send(job,false); return accept(job,response); } + /** active防实例内重入,注入lock防跨实例/页面冲突;所有路径finally撤销实例忙标记。 */ async function exclusive(work:()=>Promise){ if(active)throw fault('migration_busy');active=true; try{return await options.lock(async()=>{current();return work();});}finally{active=false;} } return Object.freeze({read, + /** 没有记录返回idle;rejected只回报既有证据,pending查询/续发原请求。 */ resume:()=>exclusive(async()=>{ const job=read();if(!job)return{status:'idle'}; return job.phase==='rejected'?{status:'rejected',job,error:negative(job,job.rejection)}:send(job,true); }), + /** pending优先恢复;无未决任务才drain并重读源版本。已是新模型直接返回already-migrated。 */ start:(rootId:Id,drain:()=>Promise)=>exclusive(async()=>{ if(!uuid.test(rootId))throw fault('invalid_migration_scope'); const previous=read(); @@ -134,6 +169,7 @@ export function createCanvasMigration(options:{ const source=await loadFoundryModel(options.transport,options.scope.workspaceId,rootId,options.registry,options.signal);current(); if(source.status==='ready')return{status:'already-migrated',rootId:source.document.projectId}; if(source.rootId!==rootId)throw fault('migration_scope_changed'); + // 新请求必须区别于上次已否定身份;冻结版本和body先写本地记录,再触发第一次网络副作用。 const expected=vector(Object.fromEntries(source.sourceVersions),rootId),id=options.createId(); if(!uuid.test(id)||id===previous?.id)throw fault('migration_identity_reused'); const job=freeze({...options.scope,version:1 as const,rootId,id,phase:'pending' as const,body:JSON.stringify({protocol_version:2,request_id:id,expected_versions:expected})}); diff --git a/src/lib/canvas-response.ts b/src/lib/canvas-response.ts index c8c15ec..a85bb09 100644 --- a/src/lib/canvas-response.ts +++ b/src/lib/canvas-response.ts @@ -1,12 +1,21 @@ +/** + * 画布响应的流转发与超时分层:响应头截止、整个body时限、单次等待下一块的空闲时限分别管理。 + * 不把大body一次读入内存或缓存,返回保留状态/头的新Response;外部断开与内部超时通过组合signal中止上游。 + */ /** Separate the response-header deadline from a large snapshot's transfer. * A client disconnect still aborts the upstream; bodies are never cached. */ +/** 单次传输诊断:头耗时、头后首块等待、body/总耗时与字节/块数;记录结果不是协议ACK。 */ export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string }; +/** 默认头15秒、body90秒、单次read30秒;request/report可注入。 + * report由调用方提供且应避免抛错;此处没有把报告异常完全隔离成独立旁路。 */ export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) { const started = performance.now(); const startedAt = new Date().toISOString(); const abort = new AbortController(); + // 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。 const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError")); + // 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。 let timer = setTimeout(timeout, limits.headersMs); let upstream: Response; try { upstream = await request(url, { ...options, signal }); } @@ -17,36 +26,45 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim throw error; } clearTimeout(timer); + // 无body的响应直接透传,不建立reader,也不产生body完成轨迹。 if (!upstream.body) return upstream; const headersAt = performance.now(); const reader = upstream.body.getReader(); let bytes = 0, chunks = 0, firstChunk: number | undefined; + /** 从性能时钟计算耗时,四舍五入到0.1ms;首块耗时相对headersAt,不相对最初发起时刻。 */ const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => { const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10; report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) }); }; let idle: ReturnType | undefined; let finished = false; + /** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */ const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); }; let interrupt: (reason: unknown) => void; + /** 将组合signal的取消原因交给流start中安装的中断处理器。 */ const interrupted = () => interrupt(signal.reason); const body = new ReadableStream({ + /** 流开始即启动body总时限,消费者背压暂停不暂停该总时限;中断使下游报错并尽力取消reader。 */ start(controller) { interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); }; signal.addEventListener("abort", interrupted, { once: true }); timer = setTimeout(timeout, limits.bodyMs); if (signal.aborted) interrupted(); }, + /** 按下游需求读下一块,idle只覆盖本次reader.read等待;consumer不pull时不运行idle计时。 */ async pull(controller) { if (finished) return; idle = setTimeout(timeout, limits.idleMs); try { const next = await reader.read(); clearTimeout(idle); + // read等待期间可能已取消/超时;结果回来后先查finished,不向已关闭controller追加字节。 if (finished) return; if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); } } catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } } }, + /** 下游主动取消记录cancelled,并中止fetch和reader;已结束状态不重复记录轨迹。 */ async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); }, }); + // 透传HTTP状态和头,业务JSON/NDJSON校验由调用方执行,不在代理流中重新编码内容。 return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers }); } diff --git a/src/lib/resource-loader.ts b/src/lib/resource-loader.ts index 6e0ee53..ee272a3 100644 --- a/src/lib/resource-loader.ts +++ b/src/lib/resource-loader.ts @@ -1,38 +1,54 @@ -/** Shared, bounded queue; unused requests are removed or aborted. */ +/** + * 按key合并重复读取的通用资源加载器,限制活跃并发与结果缓存数量,等待队列本身没有长度上限。 + * 多个调用者共享一次load但可独立取消;最后一个订阅离开才撤销底层请求。适用于可按key复用的资源, + * 不保存业务操作/请求回执,也不替代runtime可靠队列。concurrency/cacheSize由调用方提供有效范围。 + */ +/** 创建共享加载实例。默认并发6、最多缓存16个key,缓存按最近peek顺序淘汰。 + * undefined用作未命中标记,因此load返回undefined不会作为可复用命中。此工厂未运行时校验数值配置。 */ export function createResourceLoader(load: (key: string, signal: AbortSignal) => Promise, concurrency = 6, cacheSize = 16) { + // 每次request各有Promise与abort监听,取消一个调用者不能拒绝仍需要结果的其它调用者。 type Subscriber = { resolve: (value: T) => void; reject: (error: unknown) => void; cleanup: () => void }; + // 一个key的一次执行;controller属于共享job,订阅者signal不直接当底层signal。 type Job = { key: string; controller: AbortController; subscribers: Set }; + // cache保存完成值,jobs保存当前可加入的尝试,queue只保存尚未占并发槽的job。 const cache = new Map(); const jobs = new Map(); const queue: Job[] = []; let active = 0; + /** 命中后移到Map尾部,更新近用顺序;只读缓存,不启动网络。 */ function peek(key: string) { const value = cache.get(key); if (value !== undefined) { cache.delete(key); cache.set(key, value); } return value; } + /** 从队头取得有订阅的任务,占槽后异步load,最终归还槽并继续排队任务。 + * 在途任务即使被abort也要等Promise真正结束才释放并发名额。 */ function pump() { while (active < concurrency && queue.length) { const job = queue.shift()!; if (!job.subscribers.size) continue; active++; + // 发起前再查共享取消状态,队列任务可在进入微任务前失去全部订阅。 Promise.resolve().then(() => { if (job.controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); return load(job.key, job.controller.signal); }).then((value) => { if (!job.controller.signal.aborted) { cache.set(job.key, value); + // 超出缓存容量淘汰最久未触及key,不影响仍在运行的其它job。 while (cache.size > cacheSize) cache.delete(cache.keys().next().value!); for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.resolve(value); } } }, (error) => { for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.reject(error); } }).finally(() => { + // 旧取消任务结束时,同key可能已有新job;按对象身份删除,不能误删替代任务。 if (jobs.get(job.key) === job) jobs.delete(job.key); active--; pump(); }); } } + /** 已取消立即拒绝;缓存命中立即返回;否则加入同key的在途/排队job,必要时新建一次执行。 */ function request(key: string, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); const cached = peek(key); @@ -41,6 +57,7 @@ export function createResourceLoader(load: (key: string, signal: AbortSignal) if (!job) { job = { key, controller: new AbortController(), subscribers: new Set() }; jobs.set(key, job); queue.push(job); } const current = job; return new Promise((resolve, reject) => { + /** 只撤销本订阅并解除监听。最后一个离开时移除排队项/可加入索引,再abort底层执行。 */ const abort = () => { current.subscribers.delete(subscriber); subscriber.cleanup(); reject(new DOMException("Aborted", "AbortError"));