Files
node_server/utils/forward_protocol.ts
T
2508932142 6c69afb38a feat: add world voxel snapshot forwarding
Collect chunk voxel data from the bot world and stream snapshot,
chunk, and unwatch messages through the forward protocol.
2026-10-23 10:44:37 +08:00

253 lines
14 KiB
TypeScript

import type {EnsureForward, ForwardAck, ForwardErrorCode, JoinReady, RelayRequest, WorkflowPayloads, WorldPayloads} from '../types/forward.ts';
import {assert_json} from '../bot/workflow/runtime/value.ts';
const errors: Record<ForwardErrorCode, [string, boolean]> = {
INVALID_REQUEST: ['Invalid request', false],
UNAUTHORIZED: ['Authorization required', false],
FORBIDDEN: ['Operation forbidden', false],
NODE_OFFLINE: ['Node unavailable', true],
RELAY_UNAVAILABLE: ['Relay unavailable', true],
SESSION_STALE: ['Session is no longer current', false],
TOKEN_EXPIRED: ['Authorization expired', false],
TIMEOUT: ['Request deadline exceeded', true],
CANCELLED: ['Request cancelled', false],
NO_CONTEXT: ['Bot context unavailable', false],
BOT_NOT_ONLINE: ['Bot is not online', false],
RATE_LIMITED: ['Request limit exceeded', true],
RESYNC_REQUIRED: ['Synchronization required', true],
INTERNAL_ERROR: ['Operation failed', false],
};
export class ForwardFailure extends Error {
constructor(public readonly code: ForwardErrorCode) {
super(errors[code][0]);
}
}
export function error_ack(code: ForwardErrorCode): ForwardAck<never> {
const [message, retryable] = errors[code];
return {status: 'error', data: {code, message, retryable}};
}
export function failure_code(error: unknown): ForwardErrorCode {
if (error !== null && typeof error === 'object' && 'code' in error
&& typeof error.code === 'string' && Object.hasOwn(errors, error.code)) {
return error.code as ForwardErrorCode;
}
return 'INTERNAL_ERROR';
}
export function object(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === null || Object.getPrototypeOf(prototype) === null;
}
export function exact(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record<string, unknown> {
return object(value) && required.every(key => Object.hasOwn(value, key))
&& Reflect.ownKeys(value).every(key => {
if (typeof key !== 'string' || (!required.includes(key) && !optional.includes(key))) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
return descriptor.enumerable && 'value' in descriptor;
});
}
export const uuid = (value: unknown): value is string => typeof value === 'string'
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
export const integer = (value: unknown, min = 0): value is number => Number.isSafeInteger(value) && (value as number) >= min;
const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max;
export function validate_command(value: unknown): EnsureForward {
if (!exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id',
'url', 'path', 'namespace', 'token', 'expires_at', 'deadline_at'])
|| !integer(value.forward_id) || !integer(value.node_id, 1) || !integer(value.bot_id, 1)
|| !uuid(value.forward_session_id) || !text(value.node_session_id, 256) || !value.node_session_id.trim()
|| Buffer.byteLength(value.node_session_id) > 256
|| !text(value.token, 8192) || Buffer.byteLength(value.token) > 8192
|| !integer(value.expires_at, 1) || !integer(value.deadline_at, 1)
|| !text(value.url, 2048) || !text(value.path, 256) || !text(value.namespace, 256)
|| !/^\/[a-zA-Z0-9_./-]*$/.test(value.path)
|| !/^\/[a-zA-Z0-9_/-]*$/.test(value.namespace)) {
throw new ForwardFailure('INVALID_REQUEST');
}
try {
const url = new URL(value.url);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search
|| (url.pathname !== '' && url.pathname !== '/')) throw new Error();
} catch {
throw new ForwardFailure('INVALID_REQUEST');
}
return value as unknown as EnsureForward;
}
export function success_data(value: unknown): unknown {
if (!exact(value, ['status', 'data'])) throw new ForwardFailure('RELAY_UNAVAILABLE');
if (value.status === 'error') {
if (exact(value.data, ['code', 'message', 'retryable']) && typeof value.data.code === 'string'
&& Object.hasOwn(errors, value.data.code) && typeof value.data.message === 'string'
&& typeof value.data.retryable === 'boolean') {
throw new ForwardFailure(value.data.code as ForwardErrorCode);
}
throw new ForwardFailure('RELAY_UNAVAILABLE');
}
if (value.status !== 'success') throw new ForwardFailure('RELAY_UNAVAILABLE');
return value.data;
}
export function validate_join(value: unknown, command: EnsureForward, joining: boolean): JoinReady {
const data = success_data(value);
if (!exact(data, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'role', 'node_ready', 'expires_at'])
|| data.forward_id !== command.forward_id || data.forward_session_id !== command.forward_session_id
|| data.node_session_id !== command.node_session_id
|| data.node_id !== command.node_id || data.bot_id !== command.bot_id || data.role !== 'node'
|| typeof data.node_ready !== 'boolean' || (joining && data.node_ready !== false)
|| data.expires_at !== command.expires_at || (data.expires_at as number) <= Date.now()) {
throw new ForwardFailure('SESSION_STALE');
}
return data as unknown as JoinReady;
}
export function validate_rpc(value: unknown): RelayRequest {
if (!exact(value, ['request_id', 'method', 'payload', 'correlation_id', 'forward_session_id', 'deadline_at'])
|| !uuid(value.request_id) || !uuid(value.correlation_id) || !uuid(value.forward_session_id)
|| !integer(value.deadline_at, 1)) throw new ForwardFailure('INVALID_REQUEST');
switch (value.method) {
case 'world.snapshot':
case 'world.chunk':
case 'world.unwatch':
validate_world_payload(value.method, value.payload);
break;
case 'workflow.start':
case 'workflow.stop':
case 'workflow.snapshot':
case 'workflow.catalog':
case 'workflow.runtime':
case 'workflow.control':
case 'workflow.command':
validate_workflow_payload(value.method, value.payload);
break;
case 'execute_function':
if (!exact(value.payload, ['function', 'name', 'args'])
|| typeof value.payload.function !== 'string'
|| !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value.payload.function)
|| typeof value.payload.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(value.payload.name)
|| !Array.isArray(value.payload.args)) throw new ForwardFailure('INVALID_REQUEST');
break;
case 'bot.snapshot':
case 'get_log_history':
case 'get_context_info':
case 'get_data_file_list':
if (!exact(value.payload, [])) throw new ForwardFailure('INVALID_REQUEST');
break;
case 'get_file_data': {
const payload = value.payload;
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|| (Object.hasOwn(payload, 'offset') && !integer(payload.offset))
|| (Object.hasOwn(payload, 'length') && (!integer(payload.length, 1) || payload.length > 262_144))) {
throw new ForwardFailure('INVALID_REQUEST');
}
break;
}
case 'bot.logs': {
const payload = value.payload;
if (!exact(payload, ['limit'], ['stream_id', 'after_seq']) || !integer(payload.limit, 1) || payload.limit > 200
|| (Object.hasOwn(payload, 'stream_id') && !uuid(payload.stream_id))
|| (Object.hasOwn(payload, 'after_seq') && (!uuid(payload.stream_id) || !integer(payload.after_seq)))) {
throw new ForwardFailure('INVALID_REQUEST');
}
break;
}
case 'bot.chat':
if (!exact(value.payload, ['message']) || !text(value.payload.message, 256)
|| !value.payload.message.trim() || /[\r\n\u0000]/.test(value.payload.message)) throw new ForwardFailure('INVALID_REQUEST');
break;
case 'tab_complete':
if (!exact(value.payload, ['text']) || typeof value.payload.text !== 'string'
|| value.payload.text.length > 256 || /[\r\n\u0000]/.test(value.payload.text)) throw new ForwardFailure('INVALID_REQUEST');
break;
default:
throw new ForwardFailure('INVALID_REQUEST');
}
return value as unknown as RelayRequest;
}
export function validate_workflow_payload<M extends keyof WorkflowPayloads>(method: M, payload: unknown): WorkflowPayloads[M] {
if (method === 'workflow.start') {
if (!exact(payload, ['workflow_id'], ['trigger']) || typeof payload.workflow_id !== 'string'
|| !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id)) {
throw new ForwardFailure('INVALID_REQUEST');
}
if (Object.hasOwn(payload, 'trigger')) {
try {
if (!object(payload.trigger)) throw new Error();
assert_json(payload.trigger);
} catch { throw new ForwardFailure('INVALID_REQUEST'); }
}
} else if (method === 'workflow.catalog' || method === 'workflow.runtime') {
if (!exact(payload, [])) throw new ForwardFailure('INVALID_REQUEST');
} else if (method === 'workflow.command') {
if (!exact(payload, ['command_id', 'context_generation']) || !uuid(payload.command_id)
|| !uuid(payload.context_generation)) throw new ForwardFailure('INVALID_REQUEST');
} else if (method === 'workflow.control') {
try {assert_json(payload);} catch {throw new ForwardFailure('INVALID_REQUEST');}
if (!object(payload) || !uuid(payload.command_id) || !uuid(payload.context_generation)
|| !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(payload.action as string)) {
throw new ForwardFailure('INVALID_REQUEST');
}
const run = payload.action === 'stop_run';
if (!exact(payload, ['command_id', 'context_generation', 'action', run ? 'run_id' : 'workflow_id'],
[...(!run ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])])
|| run && !uuid(payload.run_id)
|| !run && (typeof payload.workflow_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id)
|| ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id))
|| Object.hasOwn(payload, 'expected_generation') && !integer(payload.expected_generation, 1)) {
throw new ForwardFailure('INVALID_REQUEST');
}
if (Object.hasOwn(payload, 'input')) {
try {
if (!object(payload.input)) throw new Error();
assert_json(payload.input);
if (Buffer.byteLength(JSON.stringify(payload.input)) > 65_536) throw new Error();
} catch {throw new ForwardFailure('INVALID_REQUEST');}
}
} else if (method === 'workflow.stop' || method === 'workflow.snapshot') {
if (!exact(payload, method === 'workflow.stop' ? ['run_id'] : [], method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : [])
|| Object.hasOwn(payload, 'run_id') && !uuid(payload.run_id)
|| Object.hasOwn(payload, 'include_branches') && (!Object.hasOwn(payload, 'run_id') || typeof payload.include_branches !== 'boolean')) {
throw new ForwardFailure('INVALID_REQUEST');
}
} else {
throw new ForwardFailure('INVALID_REQUEST');
}
return payload as WorkflowPayloads[M];
}
export function validate_world_payload<M extends keyof WorldPayloads>(method: M, payload: unknown): WorldPayloads[M] {
if (method === 'world.unwatch') {
if (!exact(payload, ['watch_id']) || !uuid(payload.watch_id)) throw new ForwardFailure('INVALID_REQUEST');
} else if (method === 'world.chunk') {
if (!exact(payload, ['x', 'z', 'world_generation', 'watch_id'])
|| !Number.isSafeInteger(payload.x) || !Number.isSafeInteger(payload.z)
|| !uuid(payload.world_generation) || !uuid(payload.watch_id)) throw new ForwardFailure('INVALID_REQUEST');
} else if (method === 'world.snapshot') {
if (!exact(payload, ['radius', 'known'], ['world_generation', 'watch_id'])
|| !integer(payload.radius, 1) || payload.radius > 3
|| Object.hasOwn(payload, 'world_generation') && !uuid(payload.world_generation)
|| Object.hasOwn(payload, 'watch_id') && !uuid(payload.watch_id)
|| !Array.isArray(payload.known) || payload.known.length > 49
|| Reflect.ownKeys(payload.known).length !== payload.known.length + 1) throw new ForwardFailure('INVALID_REQUEST');
const seen = new Set<string>();
for (let index = 0; index < payload.known.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(payload.known, String(index));
const item = descriptor && 'value' in descriptor && descriptor.enumerable ? descriptor.value : undefined;
if (!exact(item, ['x', 'z', 'revision']) || !Number.isSafeInteger(item.x)
|| !Number.isSafeInteger(item.z) || !uuid(item.revision)) throw new ForwardFailure('INVALID_REQUEST');
const key = `${item.x},${item.z}`;
if (seen.has(key)) throw new ForwardFailure('INVALID_REQUEST');
seen.add(key);
}
} else throw new ForwardFailure('INVALID_REQUEST');
return payload as WorldPayloads[M];
}