feat(host-storage): M-c批② idle离线存档三端——contract storage payload+GamePlayer受信边界四道闸+inject注入侧应答端;主agent逮修loadState三端协议错配
- contract.ts:+3 storage payload interface;GamePlayer handleStorage(键白名单idle:/4KB上限/JSON校验/wxgame:隔离);inject mountStorageBridge(requestId→resolve映射,5s超时resolve null绝不reject,幂等防重挂)+包装runtimeView传startRuntime;不触sdk/(Core红线),demo锚不动 - 【主agent核验逮修·承重接口】loadState三端协议错配:runtime自裁object签名(避§5.5零解析红线)但GamePlayer/inject照spec原草案回string→runtime读字段全undefined→离线补发恒0静默失效。统一到object(宿主受信边界解析):contract value string→Record(批②新增契约无存量消费者,同波定稿不破semver)+GamePlayer读分支接住parse结果回包对象+inject:130 resolve对象。runtime内部saveState/loadState字段(v/resource/autoYield/upgraded/leftAtMs)闭环自洽 HJ-MC-TPL-EXEC-002 §6.1/§6.2 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
88388f7b46
commit
f82d480dfd
@ -22,6 +22,7 @@ import {
|
||||
type GamePlayerProps,
|
||||
type GamePackage,
|
||||
type PostMessageEnvelope,
|
||||
type MessageDirection,
|
||||
type LifecyclePayload,
|
||||
type TelemetryPayload,
|
||||
type ErrorPayload,
|
||||
@ -247,8 +248,11 @@ function handleEnvelope(env: PostMessageEnvelope): void {
|
||||
case 'pay':
|
||||
handlePayRequest(env.payload as PayRequestPayload, env.requestId);
|
||||
break;
|
||||
case 'storage':
|
||||
handleStorage(env.payload, env.direction, env.requestId);
|
||||
break;
|
||||
default:
|
||||
// social/storage 等骨架阶段不处理(留契约挂点,不私造行为)
|
||||
// social 等骨架阶段不处理(留契约挂点,不私造行为)
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -288,6 +292,76 @@ function handleReject(reason: RejectReason, _raw: unknown, origin: string): void
|
||||
console.warn('[GamePlayer] postMessage 被拒绝并丢弃', { reason, origin });
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* storage 受信边界(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1)
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* storage 消息处理(idle 离线产出存档,HJ-MC-TPL-EXEC-002 §6.1)。
|
||||
* 宿主受信边界:键白名单 + 大小上限 + JSON 解析 try-catch + key 隔离落 localStorage。
|
||||
* runtime 侧不引可抛路径(守 player 五条 AND 之④「无错」);本函数任何异常静默吞、不崩宿主。
|
||||
*/
|
||||
function handleStorage(payload: unknown, direction: MessageDirection, requestId?: string): void {
|
||||
try {
|
||||
const p = payload as { key?: unknown; value?: unknown };
|
||||
const key = typeof p.key === 'string' ? p.key : '';
|
||||
// 闸①:key 白名单前缀(仅允许本局 idle 存档前缀 idle:gameId:versionId;tycoon 无离线态不发 storage,白名单不含 tycoon:)
|
||||
const allowedPrefix = 'idle:' + props.gameId + ':' + props.versionId;
|
||||
if (!key || key !== allowedPrefix) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] storage key 不在白名单,丢弃', { key });
|
||||
return;
|
||||
}
|
||||
const storageKey = 'wxgame:' + key; // 闸④:宿主侧命名空间隔离(wxgame: 前缀,避免污染其他 localStorage)
|
||||
if (direction === 'game_to_host' && !requestId) {
|
||||
// 写入(set):游戏→宿主,无 requestId(fire-and-forget)
|
||||
const value = typeof p.value === 'string' ? p.value : '';
|
||||
// 闸②:大小上限(4KB,防滥用 localStorage 配额;idle 存档实际 <200B)
|
||||
if (value.length > 4096) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] storage value 超 4KB 上限,丢弃', { len: value.length });
|
||||
return;
|
||||
}
|
||||
// 闸③:JSON 解析校验(确保是合法 JSON 再落;解析失败丢弃,不落脏数据)
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] storage value 非合法 JSON,丢弃');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
/* 配额满/隐私模式:静默降级,不崩 */
|
||||
}
|
||||
} else {
|
||||
// 读取(get):游戏→宿主(带 requestId)→ 宿主回包 storage 结果
|
||||
// 回包 = 宿主受信边界已解析好的对象(非原始字符串):解析在此(受信边界)做,
|
||||
// runtime 侧零 JSON.parse(守 runtime §5.5 红线);contract StorageResultPayload.value=对象|null。
|
||||
let value: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
// 闸③:回读校验 JSON 合法性(防手工篡改 localStorage 注入脏数据)+ 接住解析结果回包对象
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw);
|
||||
// 仅接受对象形态(防被篡改成数组/标量后 runtime 读字段出错);非对象按无存档处理
|
||||
value = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
} catch {
|
||||
value = null; // 解析失败/读失败(隐私模式 localStorage 不可用)→ null(runtime 侧按无存档处理)
|
||||
}
|
||||
bridge?.post('storage', { key, value }, traceId.value, requestId); // 回包(host_to_game,value=对象|null)
|
||||
}
|
||||
} catch (e) {
|
||||
// 受信边界铁律:任何意外异常静默吞,绝不向上抛、不崩宿主(守五条 AND 之④)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] handleStorage 异常(已吞)', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* ad/pay 桩:弹层 UI + requestId 配对回包
|
||||
* ========================================================================== */
|
||||
|
||||
@ -115,6 +115,34 @@ export interface PayResultPayload {
|
||||
paid: boolean;
|
||||
}
|
||||
|
||||
/* ----- storage 消息 payload(idle 离线产出存档;HJ-MC-TPL-EXEC-002 §6.1.1,semver 只增不改) ----- */
|
||||
|
||||
/** storage 写入请求 payload(游戏→宿主:保存键值) */
|
||||
export interface StorageSetPayload {
|
||||
/** 存档键(runtime 侧按 'idle:'+gameId+':'+versionId 约定,宿主侧白名单前缀校验) */
|
||||
key: string;
|
||||
/** 存档值(JSON 字符串,宿主侧落 localStorage 前校验大小上限) */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** storage 读取请求 payload(游戏→宿主:按 key 取值;带 requestId,宿主 handleStorage 读分支按同 requestId 回包,iframe 侧 inject 应答端 §6.2.1 按 requestId resolve) */
|
||||
export interface StorageGetPayload {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** storage 读取结果 payload(宿主→游戏:回包 value,无则 null) */
|
||||
export interface StorageResultPayload {
|
||||
key: string;
|
||||
/**
|
||||
* 命中的存档值 = 宿主受信边界已解析好的对象(非原始 JSON 字符串);未命中/校验失败为 null。
|
||||
* 解析在宿主侧做:runtime 侧零 JSON.parse(守 runtime §5.5 红线「runtime 内不引可抛解析路径」),
|
||||
* loadState resolve 出对象后 runtime 直接读字段。写入 payload(StorageSetPayload.value)仍是 JSON 字符串。
|
||||
* 注:storage 系批② 新增契约(HJ-MC-TPL-EXEC-002 §6.1.1),无存量消费者;本波内由草案 string 定稿为
|
||||
* 对象(消解 §6.2 草案 string 签名与 runtime §5.5 零解析红线的矛盾),不触发 semver 破坏。
|
||||
*/
|
||||
value: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 二、GamePackage 类型(对齐契约 #4 game-package.schema.json)
|
||||
* 供 Runtime 按 manifest/assets 加载,供宿主透传 manifest 给 iframe。
|
||||
|
||||
@ -103,6 +103,72 @@ export function buildIframeSrcdoc(
|
||||
// ===== 注入 Runtime 并启动 =====
|
||||
var startRuntime = ${runtimeFactorySrc};
|
||||
|
||||
// ===== idle 离线存档:iframe 侧 storage 应答端(HJ-MC-TPL-EXEC-002 §6.2.1)=====
|
||||
// 承重端落 inject 引导内(非 SDK Core):iframe 内无 bridge 对象,只能用本引导自建的
|
||||
// postToHost/onHostMessage 两原语(见上)。沿 SDK 内 ad/pay 的 pendingCallbacks 请求-应答范式:
|
||||
// 自管一套 requestId→resolve 映射,loadState 发请求登记 resolve、5s 超时 resolve(null) 绝不 reject。
|
||||
var storagePending = {}; // requestId → { resolve, timer }(iframe 侧请求-应答配对表)
|
||||
var storageMounted = false; // 幂等防重挂守卫(onHostMessage 处理器只挂一次)
|
||||
|
||||
function genStorageReqId(){ // 生成 requestId(沙箱可能无 crypto.randomUUID,降级时间戳+随机)
|
||||
try { var c = (typeof crypto!=='undefined') ? crypto : null;
|
||||
if (c && typeof c.randomUUID === 'function') return c.randomUUID(); } catch(e){}
|
||||
return 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function mountStorageBridge(){
|
||||
if (storageMounted) return; // 幂等:只挂一次,防重复注册造重复 resolve
|
||||
storageMounted = true;
|
||||
// 消费宿主 storage 回包(host_to_game 同 requestId)→ 找 resolve 兑现并清表清定时器
|
||||
onHostMessage(function(type, payload, requestId){
|
||||
if (type !== 'storage' || !requestId) return; // 只认带 requestId 的 storage 回包
|
||||
var entry = storagePending[requestId];
|
||||
if (!entry) return; // 无对应请求(超时已清/重复回包)忽略
|
||||
clearTimeout(entry.timer); delete storagePending[requestId];
|
||||
try {
|
||||
var v = (payload && typeof payload === 'object') ? payload.value : null;
|
||||
// 回包 value = 宿主受信边界已解析好的对象(非字符串):解析在宿主侧做,inject 仅透传对象;
|
||||
// 仅接受对象形态,其余(含字符串/数组/标量)→ null,runtime 按无存档处理(守 runtime §5.5 零解析)。
|
||||
entry.resolve(v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
||||
} catch(e){ try{ entry.resolve(null); }catch(_){} } // 兑现异常兜底 null,绝不抛
|
||||
});
|
||||
}
|
||||
|
||||
// 注入给 Runtime 的 storage 视图(fire-and-forget 写 + Promise 化读):
|
||||
// saveState:发 storage 写消息(game_to_host,无 requestId),宿主 handleStorage 写入分支落 localStorage(§6.1.2)
|
||||
// loadState:发 storage 读请求(game_to_host 带 requestId)→ 登记 resolve;5s 超时 resolve(null) 绝不 reject
|
||||
function makeStorageView(){
|
||||
mountStorageBridge();
|
||||
return {
|
||||
saveState: function(key, value){
|
||||
try { postToHost('storage', { key: key, value: value }); } // 无 requestId = 写(set)
|
||||
catch(e){ /* 写失败静默吞,runtime 侧不引可抛路径(守五条 AND 之④)*/ }
|
||||
},
|
||||
loadState: function(key){
|
||||
return new Promise(function(resolve){
|
||||
var rid;
|
||||
try { rid = genStorageReqId(); } catch(e){ resolve(null); return; }
|
||||
// 5s 超时兜底:到点强制 resolve(null) 并清表(绝不 reject、不阻塞 runtime)
|
||||
var timer = setTimeout(function(){ delete storagePending[rid]; try{ resolve(null); }catch(_){} }, 5000);
|
||||
storagePending[rid] = { resolve: resolve, timer: timer };
|
||||
try { postToHost('storage', { key: key }, rid); } // 带 requestId = 读(get)
|
||||
catch(e){ clearTimeout(timer); delete storagePending[rid]; resolve(null); } // 发送失败立即 resolve(null)
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 包装 RuntimeSDKView:在原 sdk 上叠加 saveState/loadState(不改 sdk 对象本身,只组合传给 startRuntime)
|
||||
var runtimeView = sdk;
|
||||
try {
|
||||
var sv = makeStorageView();
|
||||
// 浅合并:保留 sdk 的 track/reportError/__emit,叠加 storage 两回调(§6.2 RuntimeSDKView 扩展面)
|
||||
runtimeView = Object.assign({}, sdk, { saveState: sv.saveState, loadState: sv.loadState });
|
||||
// __emit 是挂在 sdk 上的私有键,Object.assign 会一并复制(同 iframe 内枚举可见);
|
||||
// 若个别环境 __emit 不可枚举,显式补一手,保 life() 生命周期不丢:
|
||||
if (typeof runtimeView.__emit !== 'function' && typeof sdk.__emit === 'function') runtimeView.__emit = sdk.__emit;
|
||||
} catch(e){ runtimeView = sdk; /* 组合失败:退回纯 sdk,idle 退化无离线态,不崩 */ }
|
||||
|
||||
// 宿主会在 iframe 就绪后下发 init;这里也兜底:DOM 就绪即自驱动启动(骨架阶段
|
||||
// 不强依赖宿主 init 时序,保证 demo 链路在 srcdoc 注入后即可自跑通)。
|
||||
function boot(){
|
||||
@ -113,7 +179,9 @@ export function buildIframeSrcdoc(
|
||||
canvas.height = Math.floor(canvas.clientHeight * dpr) || 640;
|
||||
// 先 init(发 sdk_ready),再起 Runtime(发 game_loaded → 等点击 game_start)
|
||||
sdk.init({ gameId: CTX.gameId, versionId: CTX.versionId, traceId: CTX.traceId });
|
||||
startRuntime(canvas, CTX.pkg, sdk);
|
||||
// 传 runtimeView(= sdk 叠加 saveState/loadState,§6.2.1)而非裸 sdk,保 idle 离线态可用;
|
||||
// 非 idle 模板 runtime 不调 storage 回调,runtimeView 与裸 sdk 行为等价(仅多两可选方法)。
|
||||
startRuntime(canvas, CTX.pkg, runtimeView);
|
||||
}
|
||||
// 收到宿主 init 也可触发(幂等由 SDK 内部保证);同时自启动兜底
|
||||
onHostMessage(function(type){ if (type === 'init') { /* 已自启动,幂等忽略 */ } });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user