Files

122 lines
4.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 严格 JSON 输出边界。
*
* 为什么要有这层:宿主要在渲染前校验工具结果,而 `JSON.stringify` 遇到
* BigInt 直接抛、遇到 undefined 静默丢字段、遇到循环引用抛、遇到 accessor
* 触发取值副作用。任何一条都可能让一个本来成功的操作变成 500,或者更糟——
* 让「看起来成功」的响应其实丢了关键字段。
*
* 规则(与 IG5 的 source/json_output.js 同一套判据):
* - 可选对象的 undefined 字段省略;null 保留
* - 数字必须有限,且不能是 -0
* - 拒绝:BigInt / Symbol / Function / 循环引用 / accessor / 非普通对象 /
* 数组空洞 / 带额外属性的数组 / 非可枚举字段
* - 深度与节点数有上限,超限抛错而不是截断
*
* 对外两个入口:
* projectJson(value, opts) —— 严格投影,非 JSON 就抛
* safeProject(value, opts) —— 投影失败时降级成带标记的对象,永不抛
*/
const MAX_DEPTH_DEFAULT = 64;
const MAX_NODES_DEFAULT = 200000;
function fail(at, reason) {
const error = new TypeError(`dsh-purge JSON at ${at} is not lossless: ${reason}`);
error.code = "INVALID_PLUGIN_OUTPUT";
error.at = at;
return error;
}
/**
* @param {unknown} value
* @param {{ maxDepth?: number, maxNodes?: number }} [opts]
*/
export function projectJson(value, opts = {}) {
const maxDepth = Number.isFinite(opts.maxDepth) ? opts.maxDepth : MAX_DEPTH_DEFAULT;
const maxNodes = Number.isFinite(opts.maxNodes) ? opts.maxNodes : MAX_NODES_DEFAULT;
const ancestors = new WeakSet();
let nodes = 0;
function visit(item, at, depth) {
if (depth > maxDepth) throw fail(at, `maximum nesting ${maxDepth} exceeded`);
if (++nodes > maxNodes) throw fail(at, `maximum node count ${maxNodes} exceeded`);
if (item === null) return null;
const type = typeof item;
if (type === "string" || type === "boolean") return item;
if (type === "number") {
if (!Number.isFinite(item) || Object.is(item, -0)) throw fail(at, "non-lossless number");
return item;
}
if (type !== "object") throw fail(at, `unsupported ${type}`);
if (ancestors.has(item)) throw fail(at, "circular reference");
ancestors.add(item);
try {
if (Array.isArray(item)) {
if (Object.getPrototypeOf(item) !== Array.prototype) throw fail(at, "non-plain array");
const own = Reflect.ownKeys(item);
if (own.length !== item.length + 1) throw fail(at, "sparse or decorated array");
const out = new Array(item.length);
for (let index = 0; index < item.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
if (!descriptor || !("value" in descriptor)) throw fail(`${at}[${index}]`, "missing or accessor item");
out[index] = visit(descriptor.value, `${at}[${index}]`, depth + 1);
}
return out;
}
const prototype = Object.getPrototypeOf(item);
if (prototype !== null && prototype !== Object.prototype) throw fail(at, "non-plain object");
const out = {};
for (const key of Reflect.ownKeys(item)) {
if (typeof key !== "string") throw fail(at, "symbol key");
const descriptor = Object.getOwnPropertyDescriptor(item, key);
if (!descriptor.enumerable) throw fail(`${at}.${key}`, "non-enumerable field");
if (!("value" in descriptor)) throw fail(`${at}.${key}`, "accessor field");
if (descriptor.value === undefined) continue;
Object.defineProperty(out, key, {
value: visit(descriptor.value, `${at}.${key}`, depth + 1),
enumerable: true,
writable: true,
configurable: true,
});
}
return out;
} finally {
ancestors.delete(item);
}
}
return visit(value, "$", 0);
}
/**
* 永不抛的版本:投影失败时返回 `{ __invalid: true, __reason, __at }`,
* 让 HTTP 层仍能给出可诊断的 200/500,而不是把异常冒到宿主。
*/
export function safeProject(value, opts = {}) {
try {
return { ok: true, value: projectJson(value, opts) };
} catch (error) {
return {
ok: false,
value: {
__invalid: true,
__reason: String(error && error.message ? error.message : error),
__at: String(error && error.at ? error.at : "$"),
},
};
}
}
/** 把任意值编码成可安全写出的 JSON 文本;绝不抛。 */
export function stringifySafe(value, opts = {}) {
const projected = safeProject(value, opts);
try {
return JSON.stringify(projected.value);
} catch (error) {
return JSON.stringify({ __invalid: true, __reason: String(error && error.message ? error.message : error) });
}
}