144 lines
6.7 KiB
JavaScript
144 lines
6.7 KiB
JavaScript
/**
|
||
* dsh-worktable 客户端工厂求值门禁(供 release-prep 调用;独立成模块便于失败测试)。
|
||
*
|
||
* 定位:这是「工厂求值门禁」,不是客户端端到端验收——只验证 bundle 在
|
||
* window.__ModuleLoader__.load 握手下能被真实求值一次,能抓住:
|
||
* - 顶层 TDZ / 求值异常(如 const Ge=Ge)
|
||
* - ModuleLoader ID 登记错误
|
||
* - 工厂引用了未授权的外部模块
|
||
* - 导出结构错误(apply 非函数 / inject 内容不对)
|
||
* 抓不到:React 真实渲染异常、apply(ctx) 与真实宿主服务的兼容、浏览器环境差异——
|
||
* 这些属于真实 DSH 冷启动验收(独立步骤)。
|
||
*/
|
||
import { createContext, runInNewContext } from 'node:vm'
|
||
|
||
/** 客户端 external 精确白名单——当前真实 bundle 只 require react / react/jsx-runtime。
|
||
* 新增任何宿主依赖必须显式审核后加入此清单(防引用已删除宿主模块导致白屏)。 */
|
||
export const CLIENT_EXTERNAL_ALLOW = [
|
||
'react',
|
||
'react/jsx-runtime',
|
||
]
|
||
|
||
/** 递归万能桩:任何属性访问/调用/构造都返回自身同型桩(覆盖 react 导出树与 jsx 调用)。 */
|
||
function makeStub() {
|
||
const stub = function () { return makeStub() }
|
||
return new Proxy(stub, {
|
||
get: (_t, k) => {
|
||
if (k === Symbol.toPrimitive) return () => 'stub'
|
||
return makeStub()
|
||
},
|
||
apply: () => makeStub(),
|
||
construct: () => makeStub(),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 求值一个 client bundle 源码:捕获 window.__ModuleLoader__.load 的注册结果并执行工厂。
|
||
* @param source - lib/client.js 全文
|
||
* @param expectedId - 期望的 ModuleLoader ID('dsh-worktable')
|
||
* @param expectedInject - 期望的客户端 inject 数组
|
||
* @returns {{ exports: any, id: string }} 工厂执行结果
|
||
* @throws {Error} 任何门禁失败(TDZ/ID 错误/未知依赖/导出结构错误)
|
||
*/
|
||
export function gateClientFactory(source, expectedId, expectedInject) {
|
||
let captured = null
|
||
let loadCalls = 0
|
||
const actualRequires = new Set()
|
||
const localStorageStub = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
|
||
const docStub = new Proxy(function () { return makeStub() }, {
|
||
get: (_t, k) => {
|
||
if (k === 'body' || k === 'documentElement') return makeStub()
|
||
if (k === 'createElement') return () => makeStub()
|
||
if (k === 'querySelector' || k === 'querySelectorAll' || k === 'getElementById') return () => null
|
||
if (k === 'addEventListener' || k === 'removeEventListener') return () => {}
|
||
if (k === Symbol.toPrimitive) return () => 'stub'
|
||
return makeStub()
|
||
},
|
||
apply: () => makeStub(),
|
||
})
|
||
const sandbox = {
|
||
window: {},
|
||
console: { log() {}, warn() {}, error() {} },
|
||
setTimeout, clearTimeout,
|
||
localStorage: localStorageStub,
|
||
document: docStub,
|
||
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
|
||
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
||
Blob: function Blob() {},
|
||
URL: { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} },
|
||
MutationObserver: function MutationObserver() { return { observe: () => {}, disconnect: () => {} } },
|
||
ResizeObserver: function ResizeObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
|
||
IntersectionObserver: function IntersectionObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
|
||
requestAnimationFrame: (cb) => 0,
|
||
cancelAnimationFrame: () => {},
|
||
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0', platform: 'Win32', language: 'zh-CN' },
|
||
fetch: () => Promise.reject(new Error('fetch unavailable in gate sandbox')),
|
||
indexedDB: undefined,
|
||
}
|
||
sandbox.window.__ModuleLoader__ = {
|
||
load: (reg) => {
|
||
loadCalls++
|
||
if (loadCalls > 1) throw new Error('window.__ModuleLoader__.load called more than once')
|
||
captured = reg
|
||
},
|
||
}
|
||
sandbox.window.addEventListener = () => {}
|
||
sandbox.window.removeEventListener = () => {}
|
||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} })
|
||
sandbox.window.localStorage = localStorageStub
|
||
sandbox.window.document = docStub
|
||
sandbox.self = sandbox.window // 浏览器全局 self === window
|
||
createContext(sandbox)
|
||
try {
|
||
// VM 超时:错误/恶意 bundle 的顶层无限循环不能卡死发布流程(10s)
|
||
runInNewContext(source, sandbox, { filename: 'client.js', timeout: 10000 })
|
||
} catch (e) {
|
||
throw new Error('client factory evaluation failed: ' + (e && e.message ? e.message : String(e)))
|
||
}
|
||
if (loadCalls === 0) {
|
||
throw new Error('window.__ModuleLoader__.load was never called')
|
||
}
|
||
if (!captured || typeof captured !== 'object') {
|
||
throw new Error('window.__ModuleLoader__.load was never called')
|
||
}
|
||
if (captured.id !== expectedId) {
|
||
throw new Error('ModuleLoader ID mismatch: got ' + JSON.stringify(captured.id) + ', expected ' + expectedId)
|
||
}
|
||
if (typeof captured.factory !== 'function') {
|
||
throw new Error('registered factory is not a function')
|
||
}
|
||
const requireStub = (id) => {
|
||
if (typeof id !== 'string') throw new Error('require called with non-string id')
|
||
actualRequires.add(id)
|
||
if (CLIENT_EXTERNAL_ALLOW.includes(id)) return makeStub()
|
||
throw new Error('unexpected external require: ' + id)
|
||
}
|
||
// 工厂调用放进受 timeout 控制的 vm 执行(防死循环卡死发布流程)
|
||
const runSandbox = { __factory: captured.factory, __requireStub: requireStub }
|
||
createContext(runSandbox)
|
||
let exportsObj
|
||
try {
|
||
exportsObj = runInNewContext('__factory(__requireStub)', runSandbox, { timeout: 10000 })
|
||
} catch (e) {
|
||
const msg = e && e.message ? e.message : String(e)
|
||
if (/timed out|script execution/i.test(msg)) throw new Error('factory evaluation timed out (>10s)')
|
||
throw new Error('factory threw during evaluation: ' + msg)
|
||
}
|
||
// 实际 require 集合必须严格等于白名单中已声明且被使用的项(按需子集);空集也放行
|
||
for (const id of actualRequires) {
|
||
if (!CLIENT_EXTERNAL_ALLOW.includes(id)) throw new Error('unexpected external require: ' + id)
|
||
}
|
||
if (!exportsObj || typeof exportsObj !== 'object') {
|
||
throw new Error('factory did not return module.exports object')
|
||
}
|
||
if (typeof exportsObj.apply !== 'function') {
|
||
throw new Error('client exports.apply is not a function (got ' + typeof exportsObj.apply + ')')
|
||
}
|
||
const inj = exportsObj.inject
|
||
if (!Array.isArray(inj)) throw new Error('client exports.inject is not an array')
|
||
const want = JSON.stringify(expectedInject)
|
||
const got = JSON.stringify(inj)
|
||
if (want !== got) throw new Error('client inject mismatch: got ' + got + ', expected ' + want)
|
||
return { id: captured.id, exports: exportsObj, actualRequires: [...actualRequires].sort() }
|
||
}
|