/** * 把已捕获的恢复图像编码成精确 JSON UTF-8 Blob,按 token/片段分批让出。 * 序列化不发布部分 Root;只能对 codec-owned 规范化 JSON 使用分片快速路径,外部图像保留原生防御路径。 * 最终 Blob 大小必须匹配编码器的精确证明,避免悄悄变更检查点字节表示。 */ import type {RecoveryImage} from './storage-port.ts'; import {isSealedRecoveryImage,sealedRecoveryByteLength,encodedRecoveryByteLength} from './recovery-encoding.ts'; import {createImportBudget,type ImportScheduling} from './import-budget.ts'; import {fault} from '../canvas-core/values.ts'; /** 按最多 4096 UTF-16 单元切原字符串,跨边界代理对一起留到下段;孤立代理由原生 JSON 转义。 * 长串只输出一对外层引号,各段去掉 stringify 引号后拼接,保持完整字符串的转义/字节语义。 * 段上限不是转义后字符/UTF-8 字节上限,单段 stringify 仍是同步工作。 */ function* stringTokens(value:string):Generator{ if(value.length<=4096){yield JSON.stringify(value);return;} yield '"'; for(let start=0;start=0xd800&&a<=0xdbff&&b>=0xdc00&&b<=0xdfff)end--;yield JSON.stringify(value.slice(start,end)).slice(1,-1);start=end;} yield '"'; } /** Only traverse codec-owned normalized JSON. Small owned subtrees use native stringify. */ /** 只遍历已封存的规范 JSON:数组按索引、对象按 Object.keys 顺序输出,显式生成分隔符。 * codec 有精确 UTF-8 大小事实且 ≤16KiB 的对象/数组直接原生 stringify;没有大小事实不猜其成本。 * 不自行解释 toJSON/getter、稀疏/undefined 或执行深度校验;这些应在封存捕获阶段已规范化/约束。 */ function* tokens(value:unknown):Generator{ if(value&&typeof value==='object'){ const size=encodedRecoveryByteLength(value);if(size!==undefined&&size<=16384){yield JSON.stringify(value);return;} if(Array.isArray(value)){yield '[';for(let i=0;i)[key]);}yield '}';return; } if(typeof value==='string')yield* stringTokens(value);else yield JSON.stringify(value); } /** Preserve exact JSON.stringify bytes; scheduling never exposes a partial recovery image. */ /** 返回完整 application/json Blob,不发布 Root、不写存储或持续产出可消费的字节流。 * 私有 sealed 品牌才走 token 分片并核对精确字节;外部 image 同步原生 stringify,不因 Object.freeze 获得快速路径。 * check 由宿主实现 leader/身份围栏,在入口、聚合片段及 await 前后/末尾调用;内部 signal 从不主动 abort。 * 所有 BlobPart 累积到结束,合作调度只限制部分同步工作,不是全文件零缓冲或整体内存硬预算。 * 任一错误不返回部分 Blob;此处不再 validate RecoveryImage/schema,输入有效性由 codec/存储边界保证。 */ export async function serializeRecoveryImage(image:RecoveryImage,options:ImportScheduling&{check?:()=>void}={}):Promise{ options.check?.(); // 外部输入不信任所有权/尺寸缓存,继续走原生同步 stringify,不能冒称这一路也全部可分片。 if(!isSealedRecoveryImage(image)){const bytes=new Blob([JSON.stringify(image)],{type:'application/json'});options.check?.();return bytes;} // token 先聚合到至少 8192 个输出字符串 UTF-16 单元再 flush,单 token 可使片段超过目标;不是 8KiB 字节上限。 // 每次 flush 给预算记 128 单位,粗时钟也能按累积工作让出;不对每个标点单独创建 BlobPart/读取时钟。 const budget=createImportBudget(new AbortController().signal,options),parts:BlobPart[]=[];let pending:string[]=[],length=0; for(const token of tokens(image)){ pending.push(token);length+=token.length; // 检查身份后让出,恢复执行再检查;避免异步期间已退役的 writer 继续采用结果。 if(length>=8192){parts.push(pending.join(''));pending=[];length=0;options.check?.();await budget.step(128);options.check?.();} } if(pending.length)parts.push(pending.join(''));options.check?.(); const bytes=new Blob(parts,{type:'application/json'});if(bytes.size!==sealedRecoveryByteLength(image))throw fault('recovery_encoding_size_mismatch');return bytes; }