docs(service): explain asset receipts and model stream boundaries

This commit is contained in:
2026-11-12 15:42:24 +08:00
parent 27311e831d
commit 43af2aeceb
4 changed files with 40 additions and 17 deletions
+17 -7
View File
@@ -18,13 +18,16 @@ function object(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
/** 交叉检查冻结请求与元数据载荷:一个上传操作、无canvas写入版本、固定资源身份和原字节属性。
* 最大原件20MiB;请求body只是可恢复元数据,原始Blob通过上传FormData单独携带。 */
* 元数据 byte_length 必须为1字节至20MiB;body 只是固定请求声明,原 Blob 经 FormData 单独携带。
* 不改写 request.body,不在这里检查实际 Blob;JSON.parse 等异常沿调用路径处理,不执行文件保存或任务编译。 */
function payload(request: FrozenRequest) {
const raw = object(JSON.parse(request.body));
if (request.kind !== 'asset.upload' || request.protocolVersion !== 2 || raw.protocol_version !== 2 || raw.request_id !== request.id || !uuid.test(request.id) || typeof raw.resource_id !== 'string' || !uuid.test(raw.resource_id) || request.operationIds.length !== 1 || raw.operation_id !== request.operationIds[0] || request.canvasIds.length || Object.keys(request.expectedVersions).length || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256) || typeof raw.byte_length !== 'number' || !Number.isSafeInteger(raw.byte_length) || raw.byte_length < 1 || raw.byte_length > 20 * 1024 * 1024 || typeof raw.name !== 'string' || !raw.name || typeof raw.mime_type !== 'string') throw fault('invalid_asset_request');
return raw;
}
/** 成功状态须匹配协议/请求身份;未知回执仍为 unresolved,普通拒绝也不能证明先前上传未提交。 */
/** applied 只在2xx+协议/requestId 匹配时成立,捕获完整 JSON 回执,资源字段再由 accept 核对。
* 查询404+matching unknown 是可再查的 unresolved;其它4xx 可 blocked,但不能因此制造 not-applied。
* 状态解释不解决 ledger 或更新资源,它只返回 scheduler 所需的结果描述。 */
function status(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus {
const body = object(response.body), matching = body.protocol_version === 2 && body.request_id === request.id;
if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) {
@@ -39,7 +42,9 @@ function status(request: FrozenRequest, response: ServiceResponse, query: boolea
/** Immutable bytes and a preallocated resource ID survive response loss and view changes. */
/** 校验规范HTTPS源站,构造工作区资源服务与授权域。
* 上传与查询复用固定身份;本地资源是否保存、任务如何排队由engine负责。 */
* 上传与查询复用固定身份;本地文件保存与任务调度由 engine 负责,工厂不自建队列或重试 timer。
* origin 只用于规范 sourceURL/authority;实际 HTTP 目的地由 transport 配置,不能据此参数推断 fetch 去向。
* options.project/transport 等沿用宿主对象读取合同,应保持配置稳定,不能运行中改 project 破坏授权域/URL 一致性。 */
export function createFoundryAssets(options: { workspaceId: Id; transport: ServiceTransport; project?: string; origin?: string; displayURL?: (id: Id) => string }): AssetPort {
if (!uuid.test(options.workspaceId)) throw fault('invalid_service_scope');
const origin = new URL(options.origin ?? 'https://api.foundry.entroia.com');
@@ -48,19 +53,23 @@ export function createFoundryAssets(options: { workspaceId: Id; transport: Servi
/** 可持久化的规范原件地址;每段编码,默认项目endoleap,不与宿主代理预览URL混用。 */
const sourceURL = (id: Id) => `${origin.origin}/projects/${encodeURIComponent(options.project ?? 'endoleap')}/${path}/${encodeURIComponent(id)}`;
/** 验证已应用回执和本地资源事实一致,包括操作/摘要/长度/规范URL、尺寸和允许MIME。
* 限制正整数尺寸及4000万像素,复制并冻结结果后才能登记StoredAsset。 */
* 限制正整数尺寸及4000万像素,捕获/冻结 asset 后返回;不在此更新 runtime、清本地原图或写共享仓库。
* 接受服务器检测的允许图片 MIME,不要求它与原上传 mimeType 完全相同;实际像素不在此再次解码验证。 */
const accept = (request: FrozenRequest, result: OperationStatus, resource: ResourceRecord): StoredAsset => {
const raw = payload(request), receipt = object(result.canonical), asset = object(receipt.asset);
if (result.outcome !== 'applied' || receipt.protocol_version !== 2 || receipt.request_id !== request.id || receipt.status !== 'applied' || resource.id !== raw.resource_id || resource.operationId !== raw.operation_id || resource.digest !== raw.sha256 || resource.byteLength !== raw.byte_length || asset.id !== resource.id || asset.url !== sourceURL(resource.id) || asset.bytes !== resource.byteLength || typeof asset.width !== 'number' || typeof asset.height !== 'number' || !Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || asset.width <= 0 || asset.height <= 0 || asset.width * asset.height > 40_000_000 || !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(asset.mime_type as string)) throw fault('asset_receipt_mismatch');
return freeze(JSON.parse(JSON.stringify(asset)) as StoredAsset);
};
/** bytes存在走POST上传,否则GET原操作回执;执行错误保留unresolved,不擅自重置资源身份。 */
/** 带 bytes 则 POST,否则 GET 原 requestId 回执;只做单次尝试,不换资源/操作/request 身份。
* 参数/网络/回执异常按 Error 风格 code 转 unresolved;有明确 code(除 invalid_asset_response)才附 blocked。
* signal 传给 transport,不能抢占这里原生 Blob.arrayBuffer/crypto.digest 的执行。 */
async function execute(request: FrozenRequest, bytes: Blob | undefined, signal: AbortSignal) {
try {
const raw = payload(request);
let body: FormData | undefined;
if (bytes) {
// 上传前再核对真实Blob长度和SHA;这里整体读取ArrayBuffer,受20MiB上传上限,不是流式摘要。
// 元数据长度上限已校验,但实际 bytes.size 在整体 arrayBuffer+SHA 后才比对,不是本函数读取前的硬限。
// 正常 engine 上传路径先检查实际长度;这里不做流式摘要或内存分块,调用者不能传任意超大 Blob 期待提前拒绝。
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
if (bytes.size !== raw.byte_length || digest !== raw.sha256) throw fault('resource_bytes_mismatch');
// FormData只携带原图和文件名;幂等请求ID及资源ID通过transport专用头发送。
@@ -75,7 +84,8 @@ export function createFoundryAssets(options: { workspaceId: Id; transport: Servi
}
/** 跨项目复用的服务授权端口。resolve读取版本化元数据并核对ID/规范URL,
* read获取原件字节;字节SHA与授权元数据的一致性由调用它的AssetDepot继续验证。
* scope绑定源站/项目/工作区,缓存命中不能替代一次新的领用授权。 */
* scope绑定源站/项目/工作区,缓存命中不能替代一次新的领用授权。
* resolve 返回冻结元数据,read 返回原字节响应;不在此重新哈希/解码,权限身份与字节预算还依赖 transport 合同。 */
const authority=Object.freeze({scope:`${origin.origin}/projects/${encodeURIComponent(options.project??'endoleap')}/${path}`,async resolve(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/metadata/${encodeURIComponent(id)}`,signal}),raw=object(response.body);if(response.status!==200)throw fault(typeof raw.error==='string'?raw.error:'asset_authorization_failed');if(raw.metadata_version!==1)throw fault('asset_metadata_interpreter_unavailable');const value={version:1 as const,authority:authority.scope,digest:raw.sha256 as string,asset:raw.asset as StoredAsset};validateSharedAsset(value);if(value.asset.id!==id||value.asset.url!==sourceURL(id))throw fault('asset_authorization_mismatch');return freeze(value);},async read(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/${encodeURIComponent(id)}`,signal,responseFormat:'asset-bytes-v1'});if(response.status!==200||!response.bytes)throw fault('asset_bytes_unavailable');return response.bytes;}});
// 默认显示地址走同源预览代理;upload/query只委托统一执行器,不复制另一套任务状态。
return Object.freeze({ authority, sourceURL, displayURL: options.displayURL ?? ((id: Id) => `/api/foundry/${path}/${encodeURIComponent(id)}?variant=preview`), accept,
+1
View File
@@ -1,6 +1,7 @@
/**
* Foundry 服务适配层的公共入口,暴露文档协议、资源服务、生成任务和模型加载器。
* 这些模块解释服务端数据与请求,不拥有 editor 状态根、history 或调度队列。
* 此入口只重导出工厂/类型,不自动连接服务、请求模型或创建 project;宿主负责注入 transport 和生命周期。
*/
// 底层 transport 与文档增量协议;资源上传和生成各有独立适配器。
export * from './foundry.ts';
+8 -4
View File
@@ -10,13 +10,16 @@ import { loadQueryKey, type LoadPort } from '../canvas-runtime/loader.ts';
import { loadFoundryModel, type ServiceTransport } from './foundry.ts';
/** Negotiate real streaming; older servers may explicitly return complete JSON. */
/** 绑定工作区和 registry,返回异步分块源;只接受 Foundry canonical-project 查询范围。 */
/** 创建惰性异步生成器端口;开始迭代才请求,匹配 workspace/format/source/viewKey 后读取 scope.canvasId 的项目模型。
* scope/options 按不可变合同借用;authEpoch/queryKey 标记来自宿主 scope,不能代替 transport 的账号/权限检查。
* 同一次响应二选一解释 stream 或 JSON,不建立缓存/共享在途任务;这些由 runtime ScopedLoader 持有。 */
export function createFoundryModelLoader(options: { workspaceId: Id; registry: Registry; transport: ServiceTransport }): LoadPort<CanonicalReadUpdate> {
return async function* (scope, signal) {
// query scope 是缓存/权限隔离依据,不能用某工作区的 transport 为另一种查询来源提供结果。
if (scope.workspaceId !== options.workspaceId || scope.format !== 'canonical-model-v1' || scope.source !== 'foundry' || scope.viewKey !== 'canonical-project') throw fault('invalid_model_load_scope');
const response=await options.transport({method:'GET',path:`workspaces/${encodeURIComponent(options.workspaceId)}/materials/${encodeURIComponent(scope.canvasId)}/canvas/model`,signal,responseFormat:'model-stream-v1'});
// 流解析器控制序列、来源 token 和 EOF;适配器再补权限代次与 queryKey,供共享 loader 拒绝过期结果。
// 若 response.stream 存在即进入流路径(须200),解析失败不再偷偷改用 body 或发第二次请求。
// decoder 控序列/token/EOF,适配器逐块补 scope.authEpoch/queryKey 和 versions 外壳,供上层做迟到结果准入。
if(response.stream){
if(response.status!==200)throw fault('invalid_model_stream');
for await(const chunk of decodeModelStream(response.stream,scope.canvasId,options.registry,signal))yield Object.freeze({...chunk,serverVersions:Object.freeze(Object.fromEntries(chunk.versions)),permissionEpoch:scope.authEpoch,queryKey:loadQueryKey(scope)});
@@ -26,14 +29,15 @@ export function createFoundryModelLoader(options: { workspaceId: Id; registry: R
const value = await loadFoundryModel(async()=>response, options.workspaceId, scope.canvasId, options.registry, signal);
signal.throwIfAborted();
const projectId = value.status === 'ready' ? value.document.projectId : value.rootId;
// 对版本排序以稳定完整 JSON 回退的快照标记;这是结果身份,不是原响应字节的密码学摘要。
// 对版本排序后用 [projectId,versions] 生成 token,不包含元素内容,不能把它当成源字节摘要/真实性证明。
const versions = Object.fromEntries([...(value.status === 'ready' ? value.versions : value.sourceVersions)].sort(([a], [b]) => a.localeCompare(b)));
const token = JSON.stringify([projectId, versions]);
const payload = value.status === 'ready' ? { projectId, canvases: [...value.document.canvases.values()], elements: [...value.document.elements.values()], tombstones: [...value.document.tombstones], versions } : { projectId, versions, migrationRequired: true };
// A serialized payload cost, not a claim about pre-parse network/heap limits.
// byteLength 是已解析后重新序列化的 payload 成本,不能据此宣称 JSON.parse 前已有网络/堆内存硬上限。
const byteLength = new TextEncoder().encode(JSON.stringify(payload)).byteLength;
// 完整 JSON 一次给出 complete;迁移结果使用 migration 覆盖标记,不伪造已加载 canvas 内容。
// JSON 完整结果一次 sequence=0/complete=true;迁移要求用 migration coverage 标记,仍保留其区别于 ready 的状态。
// 本端口不把内容发布进 editor,消费者还需模型预览/加载协调以及作用域检查。
yield Object.freeze({ queryKey: loadQueryKey(scope), projectId, snapshotToken: token, serverVersions: Object.freeze(versions), permissionEpoch: scope.authEpoch,
sequence: 0, coverage: Object.freeze(value.status === 'ready' ? [...value.document.canvases.keys()].map(id => `canvas:${id}`) : [`migration:${scope.canvasId}`]), complete: true, byteLength, value });
};
+14 -6
View File
@@ -18,7 +18,9 @@ const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
/** 外部记录必须是有限 JSON 对象,拒绝数组和非 JSON 值,不靠类型断言信任网络内容。 */
const object=(value:unknown):Record<string,unknown>=>{if(!value||typeof value!=='object'||Array.isArray(value)||!validJson(value))throw fault('invalid_model_stream');return value as Record<string,unknown>;};
/** Bounded NDJSON framing. EOF is required even after a terminal record. */
/** 有界 UTF-8 NDJSON 分帧。按字节找换行,fatal 解码拒绝损坏编码,最后一条也要求换行结束。 */
/** 按 LF 分帧,UTF8 decoder 跨 chunk 保留残字节,每行换行时 flush,最后一条也必须以 LF 结束。
* 单行预算不计 LF(CRLF 的 CR 仍计入),输出 byteLength 则含 LF;计整 chunk 后才逐行解析。
* 同步扫描/字符串拼接/JSON.parse 无合作调度;预算约束原始字节和行数,不代表整个进程堆内存硬限。 */
async function* lines(source:AsyncIterable<Uint8Array>,signal:AbortSignal){
const decoder=new TextDecoder('utf-8',{fatal:true});
/** 统一把解码错误转为协议错误,保持错误码不依赖浏览器具体异常文本。 */
@@ -41,14 +43,17 @@ async function* lines(source:AsyncIterable<Uint8Array>,signal:AbortSignal){
signal.throwIfAborted();pending+=decode();if(pending||lineBytes)throw fault('incomplete_model_stream');
}
/** 解释一条固定快照流。sequence 从0连续增长,snapshotToken 全程相同;
* header/元素批次可先 yield,终结结果暂存到 EOF,之后多一条记录也判非法。 */
* header/元素批次可先 yield,complete 暂存到真正 EOF,之后多一条记录也判非法。
* receiving 只校验该阶段可确定的 ID/owner/数量等条件,完整 schema/图引用在终结时再验证。
* 消费者须迭代到终结才能得到 ready;放弃迭代不等于源已完整,signal 也不能强制打断不响应取消的 source.next。 */
export async function* decodeModelStream(source:AsyncIterable<Uint8Array>,canvasId:Id,registry:Registry,signal:AbortSignal){
let sequence=0,token='',projectId='',expected=0,ended=false;
// terminal 暂存而不立即 yield,保证 complete 之后的恶意/错误尾部仍能阻止 ready。
let terminal: {sequence:number;snapshotToken:string;projectId:string;versions:ReadonlyMap<Id,number>;complete:boolean;byteLength:number;coverage:string[];value:CanonicalReadUpdate}|undefined;
let canvases:CanvasRecord[]=[],versions:ReadonlyMap<Id,number>=new Map(),tombstones:ReadonlyMap<Id,number>=new Map();
let elementOwners:ElementOwners|undefined;let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map<Id,number>();
// 元素累积用于最终构建文档;流式显示不等于恒定内存,完整模型仍需保存在最终文档中。
// elements 累积全部记录,ids 去重,ownerCounts 累计接收归属;这些是本次读取的私有暂存,不是已发布文档。
// receiving 分批显示只减重复传播,不使总内存恒定;已 yield 的前缀也不会因后续报错自动回滚消费者状态。
const elements:ElementRecord[]=[],ids=new Set<Id>(),canvasIds=new Set<Id>();
for await(const line of lines(source,signal)){
const raw=object(line.raw);
@@ -66,7 +71,8 @@ export async function* decodeModelStream(source:AsyncIterable<Uint8Array>,canvas
if(!Array.isArray(raw.removedCanvasIds)||new Set(raw.removedCanvasIds).size!==raw.removedCanvasIds.length||raw.removedCanvasIds.some(id=>typeof id!=='string'||canvasIds.has(id)||!versionMap.has(id))||versionMap.size!==canvasIds.size+raw.removedCanvasIds.length)throw fault('invalid_model_stream');
for(const [id,value]of Object.entries(object(raw.tombstones))){if(!id||!Number.isSafeInteger(value)||(value as number)<1)throw fault('invalid_model_stream');dead.set(id,value as number);}
versions=readMap(versionMap);tombstones=readMap(dead);
// 数量/所有权证据是可选协议能力;elementOwners 依赖 counts,必须与总数及各 canvas 数量匹配。
// 可选 counts/owners 先查声明内部一致性;owners 必须配 counts,声明不代替后续实际记录的数量/归属核验。
// header 的 canvas 记录此时尚未用完整文档校验树;预览层若要采用必须继续执行自己的阶段校验。
if(Object.hasOwn(raw,'canvasElementCounts')){validateCanvasElementCounts(raw.canvasElementCounts,[...canvasIds],expected);canvasElementCounts=freeze({...raw.canvasElementCounts});}
if(Object.hasOwn(raw,'elementOwners')){if(!canvasElementCounts)throw fault('invalid_model_identity');validateElementOwners(raw.elementOwners,canvasElementCounts,expected);elementOwners=freeze({...raw.elementOwners});}
const value:CanonicalReadUpdate=freeze({status:'receiving',...(elementOwners?{elementOwners}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),projectId,canvases,elements:[],tombstones,versions,receivedElements:0,totalElements:expected});
@@ -78,10 +84,12 @@ export async function* decodeModelStream(source:AsyncIterable<Uint8Array>,canvas
if(raw.kind==='elements'){
if(!Array.isArray(raw.elements)||!raw.elements.length||raw.elements.length>64||elements.length+raw.elements.length>expected)throw fault('invalid_model_stream');
for(const item of raw.elements){const element=object(item) as unknown as ElementRecord;if(typeof element.id!=='string'||!element.id||ids.has(element.id)||!canvasIds.has(element.ownerCanvasId))throw fault('invalid_model_stream');const count=(ownerCounts.get(element.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(element.ownerCanvasId,count);if(elementOwners&&(!Object.hasOwn(elementOwners,element.id)||elementOwners[element.id]!==element.ownerCanvasId))throw fault('invalid_model_identity');ids.add(element.id);elements.push(element);}
// 后续 receiving 只交本批新增元素,不重复传播整份前缀;保留累计进度供显示。
// receiving 只交本批新记录,canvases/墓碑留空,versions 与总数沿用 header;receivedElements 是累计接收数。
// 冻结这个 DTO 不是整批导入提交/可靠保存/所有端点已到齐的证明,显示层继续按依赖门禁采用。
const value:CanonicalReadUpdate=freeze({status:'receiving',projectId,canvases:[],elements:raw.elements as ElementRecord[],tombstones:readMap(new Map()),versions,receivedElements:elements.length,totalElements:expected});
yield{sequence:sequence-1,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:raw.elements.map(item=>`element:${(item as ElementRecord).id}`),value};
// 总元素数一致才构建并校验完整文档;只在校验成功后设 ended,随后必须等 EOF。
// complete 数量匹配后 createDocument 捕获全部记录、validateDocument 校树/归属/引用及已知 schema。
// terminal 只存候选;ended 后任何记录都拒绝,直到 lines 正常 EOF 才 yield 这份 ready。
}else if(raw.kind==='complete'){
if(raw.elementCount!==expected||elements.length!==expected)throw fault('incomplete_model_stream');
const document=createDocument(projectId,canvases,elements,tombstones);validateDocument(document,registry);ended=true;