mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
import type {
|
||||
BrowserPageCallableExecution,
|
||||
BrowserTransformBuiltinOperation,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformExecution,
|
||||
BrowserTransformHeader,
|
||||
BrowserTransformNodeReference,
|
||||
BrowserTransformPacket,
|
||||
BrowserTransformValueEncoding,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_PATH_LENGTH = 512;
|
||||
const MAX_PATH_SEGMENTS = 64;
|
||||
const MAX_JSON_DEPTH = 64;
|
||||
const MAX_JSON_NODES = 100_000;
|
||||
const MAX_PIPELINE_NODES = 64;
|
||||
const BLOCKED_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const BUILTIN_OPERATIONS = new Set<BrowserTransformBuiltinOperation>([
|
||||
'value.literal',
|
||||
'json.stringify', 'json.parse', 'text.toString', 'url.encode', 'url.decode',
|
||||
'base64.encode', 'base64.decode', 'hex.encode', 'hex.decode',
|
||||
'object.pick', 'object.compose', 'form.compose',
|
||||
]);
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
type BodyFormat = 'empty' | 'json' | 'form' | 'text';
|
||||
|
||||
interface TransformContext {
|
||||
method?: string;
|
||||
url: string;
|
||||
statusCode?: number;
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string | string[]>;
|
||||
body: unknown;
|
||||
text: string;
|
||||
bodyBase64: string;
|
||||
}
|
||||
|
||||
export type PageCallableInvoker = (
|
||||
callableId: string,
|
||||
args: unknown[],
|
||||
) => Promise<BrowserPageCallableExecution>;
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
if (!value) return new Uint8Array();
|
||||
if (value.length > Math.ceil(MAX_BODY_BYTES / 3) * 4 + 8) {
|
||||
throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制');
|
||||
}
|
||||
let binary: string;
|
||||
try { binary = atob(value); } catch { throw new ExtensionError('transform_invalid_body', 'Base64 数据无效'); }
|
||||
if (binary.length > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '转换数据包 body 超过 8 MiB 限制');
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64(bytes: Uint8Array): string {
|
||||
if (bytes.byteLength > MAX_BODY_BYTES) throw new ExtensionError('transform_body_too_large', '页面转换结果超过 8 MiB 限制');
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 8_192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 8_192));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function headerRecord(headers: BrowserTransformHeader[]): Record<string, string> {
|
||||
const output = Object.create(null) as Record<string, string>;
|
||||
for (const header of headers) {
|
||||
output[header.name] = header.value;
|
||||
output[header.name.toLowerCase()] = header.value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function queryRecord(url: string): Record<string, string | string[]> {
|
||||
const output = Object.create(null) as Record<string, string | string[]>;
|
||||
try {
|
||||
for (const [key, value] of new URL(url).searchParams) {
|
||||
const previous = output[key];
|
||||
output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
||||
}
|
||||
} catch { /* URL is validated at the protocol boundary. */ }
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseForm(text: string): Record<string, string | string[]> {
|
||||
const output = Object.create(null) as Record<string, string | string[]>;
|
||||
for (const [key, value] of new URLSearchParams(text)) {
|
||||
const previous = output[key];
|
||||
output[key] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseBody(bytes: Uint8Array, headers: Record<string, string>): { text: string; body: unknown; format: BodyFormat } {
|
||||
const text = decoder.decode(bytes);
|
||||
if (!text.trim()) return { text, body: '', format: 'empty' };
|
||||
try { return { text, body: JSON.parse(text) as unknown, format: 'json' }; } catch { /* Not JSON. */ }
|
||||
const contentType = headers['content-type']?.toLowerCase() || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
return { text, body: parseForm(text), format: 'form' };
|
||||
}
|
||||
return { text, body: text, format: 'text' };
|
||||
}
|
||||
|
||||
function pathSegments(path: string): string[] {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === '$') return [];
|
||||
if (trimmed.length > MAX_PATH_LENGTH) throw new ExtensionError('transform_invalid_path', '转换值路径过长');
|
||||
const normalized = trimmed.startsWith('$.') ? trimmed.slice(2) : trimmed;
|
||||
const segments = normalized.split('.').filter(Boolean);
|
||||
if (segments.length > MAX_PATH_SEGMENTS || segments.some((segment) => BLOCKED_PATH_SEGMENTS.has(segment))) {
|
||||
throw new ExtensionError('transform_invalid_path', `不允许的转换值路径: ${path}`);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function readTransformValue(input: unknown, path: string): unknown {
|
||||
let current = input;
|
||||
for (const segment of pathSegments(path)) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(segment);
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index >= current.length) {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, segment)) {
|
||||
throw new ExtensionError('transform_value_missing', `转换值路径不存在: ${path}`);
|
||||
}
|
||||
current = record[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
interface JsonCloneState { nodes: number; seen: WeakSet<object> }
|
||||
|
||||
function cloneJsonBody(value: unknown, state: JsonCloneState = { nodes: 0, seen: new WeakSet<object>() }, depth = 0): unknown {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > MAX_JSON_NODES) throw new ExtensionError('transform_value_too_large', '转换上下文包含过多节点');
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (depth >= MAX_JSON_DEPTH) throw new ExtensionError('transform_value_too_deep', `转换上下文嵌套超过 ${MAX_JSON_DEPTH} 层`);
|
||||
if (state.seen.has(value)) throw new ExtensionError('transform_value_invalid', '转换上下文包含循环引用');
|
||||
state.seen.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) return value.map((item) => cloneJsonBody(item, state, depth + 1));
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!BLOCKED_PATH_SEGMENTS.has(key)) output[key] = cloneJsonBody(item, state, depth + 1);
|
||||
}
|
||||
return output;
|
||||
} finally {
|
||||
state.seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function writeObjectPath(input: unknown, path: string, value: unknown): unknown {
|
||||
const segments = pathSegments(path);
|
||||
if (!segments.length) return value;
|
||||
const root = cloneJsonBody(input);
|
||||
if (!root || typeof root !== 'object') throw new ExtensionError('transform_output_invalid', `目标 ${path} 需要结构化 body`);
|
||||
let current = root as Record<string, unknown> | unknown[];
|
||||
segments.forEach((segment, index) => {
|
||||
const last = index === segments.length - 1;
|
||||
if (Array.isArray(current)) {
|
||||
const arrayIndex = Number(segment);
|
||||
if (!Number.isSafeInteger(arrayIndex) || arrayIndex < 0 || arrayIndex >= current.length) {
|
||||
throw new ExtensionError('transform_output_invalid', `目标数组路径不存在: ${path}`);
|
||||
}
|
||||
if (last) current[arrayIndex] = value;
|
||||
else {
|
||||
if (!current[arrayIndex] || typeof current[arrayIndex] !== 'object') current[arrayIndex] = /^\d+$/.test(segments[index + 1]) ? [] : {};
|
||||
current = current[arrayIndex] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (last) current[segment] = value;
|
||||
else {
|
||||
if (!current[segment] || typeof current[segment] !== 'object') current[segment] = /^\d+$/.test(segments[index + 1]) ? [] : {};
|
||||
current = current[segment] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function bytesValue(value: unknown, encoding: BrowserTransformValueEncoding = 'auto'): Uint8Array {
|
||||
if (value && typeof value === 'object' && (value as Record<string, unknown>).type === 'bytes'
|
||||
&& typeof (value as Record<string, unknown>).base64 === 'string') {
|
||||
return decodeBase64(String((value as Record<string, unknown>).base64));
|
||||
}
|
||||
if (encoding === 'base64') {
|
||||
if (typeof value !== 'string') throw new ExtensionError('transform_output_invalid', 'Base64 值必须是字符串或字节值');
|
||||
return decodeBase64(value);
|
||||
}
|
||||
if (encoding === 'json') return encoder.encode(JSON.stringify(value));
|
||||
if (typeof value === 'string') return encoder.encode(value);
|
||||
return encoder.encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
if (value && typeof value === 'object' && (value as Record<string, unknown>).type === 'bytes') return decoder.decode(bytesValue(value));
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function hexEncode(bytes: Uint8Array): string {
|
||||
let output = '';
|
||||
for (const byte of bytes) output += byte.toString(16).padStart(2, '0');
|
||||
return output;
|
||||
}
|
||||
|
||||
function hexDecode(value: string): Uint8Array {
|
||||
if (!/^(?:[0-9a-f]{2})*$/i.test(value)) throw new ExtensionError('transform_builtin_invalid', 'Hex 输入无效');
|
||||
const output = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < output.length; index += 1) output[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
return output;
|
||||
}
|
||||
|
||||
function byteResult(bytes: Uint8Array): { type: 'bytes'; byteLength: number; base64: string } {
|
||||
return { type: 'bytes', byteLength: bytes.byteLength, base64: encodeBase64(bytes) };
|
||||
}
|
||||
|
||||
function optionStrings(options: Record<string, unknown> | undefined, key: string, max = 64): string[] {
|
||||
const value = options?.[key];
|
||||
if (!Array.isArray(value) || value.length > max || value.some((item) => typeof item !== 'string')) {
|
||||
throw new ExtensionError('transform_builtin_invalid', `内置操作需要字符串数组 options.${key}`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function executeBuiltin(operation: BrowserTransformBuiltinOperation, inputs: unknown[], options?: Record<string, unknown>): unknown {
|
||||
if (operation === 'value.literal') {
|
||||
if (inputs.length) throw new ExtensionError('transform_builtin_invalid', '固定值操作不接受输入');
|
||||
const value = options?.value;
|
||||
if (!['string', 'number', 'boolean'].includes(typeof value) && value !== null) {
|
||||
throw new ExtensionError('transform_builtin_invalid', '固定值只允许字符串、数字、布尔值或 null');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const one = () => {
|
||||
if (inputs.length !== 1) throw new ExtensionError('transform_builtin_invalid', `${operation} 需要 1 个输入`);
|
||||
return inputs[0];
|
||||
};
|
||||
if (operation === 'json.stringify') return JSON.stringify(one());
|
||||
if (operation === 'json.parse') {
|
||||
try { return JSON.parse(stringValue(one())) as unknown; } catch { throw new ExtensionError('transform_builtin_invalid', 'JSON 输入无效'); }
|
||||
}
|
||||
if (operation === 'text.toString') return stringValue(one());
|
||||
if (operation === 'url.encode') return encodeURIComponent(stringValue(one()));
|
||||
if (operation === 'url.decode') {
|
||||
try { return decodeURIComponent(stringValue(one())); } catch { throw new ExtensionError('transform_builtin_invalid', 'URL 编码输入无效'); }
|
||||
}
|
||||
if (operation === 'base64.encode') return encodeBase64(bytesValue(one()));
|
||||
if (operation === 'base64.decode') return byteResult(decodeBase64(stringValue(one())));
|
||||
if (operation === 'hex.encode') return hexEncode(bytesValue(one()));
|
||||
if (operation === 'hex.decode') return byteResult(hexDecode(stringValue(one())));
|
||||
if (operation === 'object.pick') {
|
||||
const source = one();
|
||||
const paths = optionStrings(options, 'paths');
|
||||
const keys = Array.isArray(options?.keys) ? optionStrings(options, 'keys') : paths.map((path) => path.split('.').at(-1) || path);
|
||||
if (paths.length !== keys.length) throw new ExtensionError('transform_builtin_invalid', 'object.pick 的 paths 与 keys 数量必须一致');
|
||||
return Object.fromEntries(paths.map((path, index) => [keys[index], cloneJsonBody(readTransformValue(source, path))]));
|
||||
}
|
||||
if (operation === 'object.compose') {
|
||||
const keys = optionStrings(options, 'keys');
|
||||
if (keys.length !== inputs.length || keys.some((key) => BLOCKED_PATH_SEGMENTS.has(key))) {
|
||||
throw new ExtensionError('transform_builtin_invalid', 'object.compose 的 keys 必须与输入一一对应');
|
||||
}
|
||||
return Object.fromEntries(keys.map((key, index) => [key, cloneJsonBody(inputs[index])]));
|
||||
}
|
||||
const keys = optionStrings(options, 'keys');
|
||||
if (keys.length !== inputs.length) throw new ExtensionError('transform_builtin_invalid', 'form.compose 的 keys 必须与输入一一对应');
|
||||
const form = new URLSearchParams();
|
||||
keys.forEach((key, index) => {
|
||||
const value = inputs[index];
|
||||
if (Array.isArray(value)) value.forEach((item) => form.append(key, stringValue(item)));
|
||||
else form.append(key, stringValue(value));
|
||||
});
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
function resolveReference(results: Map<string, unknown>, reference: BrowserTransformNodeReference): unknown {
|
||||
if (!results.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点引用不存在: ${reference.nodeId}`);
|
||||
const value = results.get(reference.nodeId);
|
||||
return reference.path ? readTransformValue(value, reference.path) : value;
|
||||
}
|
||||
|
||||
function validDestination(destination: string): boolean {
|
||||
if (destination === 'body') return true;
|
||||
if (destination.startsWith('body.')) { pathSegments(destination.slice(5)); return true; }
|
||||
if (destination.toLowerCase().startsWith('header.')) return Boolean(destination.slice(7)) && !/[\r\n:]/.test(destination.slice(7));
|
||||
if (destination.startsWith('query.')) return Boolean(destination.slice(6)) && !/[\r\n&#=]/.test(destination.slice(6));
|
||||
return false;
|
||||
}
|
||||
|
||||
export function assertTransformDirection(direction: BrowserTransformDirection): void {
|
||||
if (!direction.nodes.length || direction.nodes.length > MAX_PIPELINE_NODES) {
|
||||
throw new ExtensionError('transform_pipeline_empty', `转换 Pipeline 必须包含 1-${MAX_PIPELINE_NODES} 个节点`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
let outputCount = 0;
|
||||
for (const node of direction.nodes) {
|
||||
if (!node.id.trim() || !node.name.trim() || seen.has(node.id)) throw new ExtensionError('transform_pipeline_invalid', `Pipeline 节点 ID 无效或重复: ${node.id}`);
|
||||
const references = node.kind === 'builtin' ? node.inputs
|
||||
: node.kind === 'page.call' ? node.arguments
|
||||
: node.kind === 'output.write' ? [node.source] : [];
|
||||
for (const reference of references) {
|
||||
if (!seen.has(reference.nodeId)) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 引用了尚未产生的 ${reference.nodeId}`);
|
||||
if (reference.path) pathSegments(reference.path);
|
||||
}
|
||||
if (node.kind === 'context.read') pathSegments(node.path);
|
||||
if (node.kind === 'builtin' && !BUILTIN_OPERATIONS.has(node.operation)) throw new ExtensionError('transform_pipeline_invalid', `不支持的内置操作: ${node.operation}`);
|
||||
if (node.kind === 'page.call' && !node.callableId.trim()) throw new ExtensionError('transform_pipeline_invalid', `节点 ${node.name} 未绑定页面函数`);
|
||||
if (node.kind === 'output.write') {
|
||||
outputCount += 1;
|
||||
if (!validDestination(node.destination.trim())) throw new ExtensionError('transform_output_invalid', `不支持的输出目标: ${node.destination}`);
|
||||
}
|
||||
seen.add(node.id);
|
||||
}
|
||||
if (!outputCount) throw new ExtensionError('transform_pipeline_empty', '转换 Pipeline 缺少 output.write 节点');
|
||||
}
|
||||
|
||||
export function wildcardUrlMatches(pattern: string, url: string): boolean {
|
||||
const value = pattern.trim();
|
||||
if (!value || value === '*') return true;
|
||||
const escaped = value.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
const matcher = new RegExp(`^${escaped}$`, 'i');
|
||||
if (matcher.test(url)) return true;
|
||||
try { return matcher.test(new URL(url).pathname); } catch { return false; }
|
||||
}
|
||||
|
||||
export function assertTransformRoute(methods: string[], urlPattern: string, packet: BrowserTransformPacket, pageOrigin?: string): void {
|
||||
if (methods.length && (!packet.method || !methods.includes(packet.method.toUpperCase()))) {
|
||||
throw new ExtensionError('transform_route_mismatch', `请求方法 ${packet.method || '(missing)'} 不匹配转换配置`);
|
||||
}
|
||||
const explicitOriginPattern = /^(?:https?|\*):\/\//i.test(urlPattern.trim());
|
||||
if (pageOrigin && !explicitOriginPattern) {
|
||||
let packetOrigin = '';
|
||||
try { packetOrigin = new URL(packet.url).origin; } catch { /* validated by protocol */ }
|
||||
if (packetOrigin !== pageOrigin) throw new ExtensionError('transform_route_mismatch', `URL 来源 ${packetOrigin || '(invalid)'} 不匹配页面来源 ${pageOrigin}`);
|
||||
}
|
||||
if (!wildcardUrlMatches(urlPattern, packet.url)) throw new ExtensionError('transform_route_mismatch', `URL 不匹配转换配置: ${packet.url}`);
|
||||
}
|
||||
|
||||
function serializeStructuredBody(value: unknown, format: BodyFormat): Uint8Array {
|
||||
if (format === 'form') {
|
||||
const form = new URLSearchParams();
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (Array.isArray(item)) item.forEach((entry) => form.append(key, stringValue(entry)));
|
||||
else form.append(key, stringValue(item));
|
||||
}
|
||||
return encoder.encode(form.toString());
|
||||
}
|
||||
return encoder.encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function executeTransformDirection(
|
||||
profileId: string,
|
||||
directionName: 'request' | 'response',
|
||||
direction: BrowserTransformDirection,
|
||||
packet: BrowserTransformPacket,
|
||||
invoke: PageCallableInvoker,
|
||||
): Promise<BrowserTransformExecution> {
|
||||
assertTransformDirection(direction);
|
||||
const started = performance.now();
|
||||
const rawBody = decodeBase64(packet.bodyBase64);
|
||||
const headers = headerRecord(packet.headers);
|
||||
const parsed = parseBody(rawBody, headers);
|
||||
const context: TransformContext = {
|
||||
method: packet.method?.toUpperCase(),
|
||||
url: packet.url,
|
||||
statusCode: packet.statusCode,
|
||||
headers,
|
||||
query: queryRecord(packet.url),
|
||||
body: parsed.body,
|
||||
text: parsed.text,
|
||||
bodyBase64: packet.bodyBase64,
|
||||
};
|
||||
const logicalInput = cloneJsonBody(context);
|
||||
const results = new Map<string, unknown>();
|
||||
const nodeDurations: Array<{ nodeId: string; durationMs: number }> = [];
|
||||
let outputBody = rawBody;
|
||||
let logicalBody = parsed.body;
|
||||
let outputUrl = packet.url;
|
||||
const setHeaders = new Map<string, BrowserTransformHeader>();
|
||||
const removeHeaders = new Map<string, string>();
|
||||
|
||||
for (const node of direction.nodes) {
|
||||
const nodeStarted = performance.now();
|
||||
if (node.kind === 'context.read') {
|
||||
results.set(node.id, cloneJsonBody(readTransformValue(context, node.path)));
|
||||
} else if (node.kind === 'builtin') {
|
||||
results.set(node.id, executeBuiltin(node.operation, node.inputs.map((reference) => resolveReference(results, reference)), node.options));
|
||||
} else if (node.kind === 'page.call') {
|
||||
const execution = await invoke(node.callableId, node.arguments.map((reference) => resolveReference(results, reference)));
|
||||
results.set(node.id, execution.value);
|
||||
} else {
|
||||
const value = resolveReference(results, node.source);
|
||||
const destination = node.destination.trim();
|
||||
if (destination === 'body') {
|
||||
outputBody = bytesValue(value, node.encoding);
|
||||
logicalBody = value;
|
||||
} else if (destination.startsWith('body.')) {
|
||||
logicalBody = writeObjectPath(logicalBody, destination.slice(5), value);
|
||||
outputBody = serializeStructuredBody(logicalBody, parsed.format === 'form' ? 'form' : 'json');
|
||||
} else if (destination.toLowerCase().startsWith('header.')) {
|
||||
const name = destination.slice(7).trim();
|
||||
const normalized = name.toLowerCase();
|
||||
const encoded = value === undefined || value === null ? undefined
|
||||
: node.encoding === 'base64' ? encodeBase64(bytesValue(value)) : stringValue(value);
|
||||
if (encoded === undefined) {
|
||||
removeHeaders.set(normalized, name);
|
||||
setHeaders.delete(normalized);
|
||||
} else {
|
||||
if (/[\r\n]/.test(encoded)) throw new ExtensionError('transform_output_invalid', `Header ${name} 的值包含换行`);
|
||||
setHeaders.set(normalized, { name, value: encoded });
|
||||
removeHeaders.delete(normalized);
|
||||
}
|
||||
} else if (destination.startsWith('query.')) {
|
||||
const url = new URL(outputUrl);
|
||||
const key = destination.slice(6);
|
||||
if (value === undefined || value === null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, stringValue(value));
|
||||
outputUrl = url.toString();
|
||||
}
|
||||
results.set(node.id, value);
|
||||
}
|
||||
nodeDurations.push({ nodeId: node.id, durationMs: Math.max(0, performance.now() - nodeStarted) });
|
||||
}
|
||||
|
||||
return {
|
||||
profileId,
|
||||
direction: directionName,
|
||||
url: outputUrl,
|
||||
bodyBase64: encodeBase64(outputBody),
|
||||
setHeaders: [...setHeaders.values()],
|
||||
removeHeaders: [...removeHeaders.values()],
|
||||
logicalInput,
|
||||
logicalOutput: cloneJsonBody({ url: outputUrl, body: logicalBody, nodes: Object.fromEntries(results) }),
|
||||
nodeDurations,
|
||||
durationMs: Math.max(0, performance.now() - started),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user