Files
Endoleap/src/lib/canvas-runtime/recovery-json.ts
T

53 lines
4.5 KiB
TypeScript

/**
* 把已捕获的恢复图像编码成精确 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<string>{
if(value.length<=4096){yield JSON.stringify(value);return;}
yield '"';
for(let start=0;start<value.length;){let end=Math.min(start+4096,value.length);const a=value.charCodeAt(end-1),b=value.charCodeAt(end);if(a>=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<string>{
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<value.length;i++){if(i)yield ',';yield* tokens(value[i]);}yield ']';return;}
yield '{';let first=true;for(const key of Object.keys(value)){if(!first)yield ',';first=false;yield* stringTokens(key);yield ':';yield* tokens((value as Record<string,unknown>)[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<Blob>{
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;
}