Files
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

449 lines
24 KiB
TypeScript

import {createHash} from 'node:crypto';
import {io, type Socket} from 'socket.io-client';
import {BotStream} from './stream.ts';
import {BotFileService} from './files.ts';
import type {EnsureForward, ForwardAck, ForwardReady, ForwardScope} from '../../types/forward.ts';
import {error_ack, exact, failure_code, ForwardFailure, success_data, validate_command, validate_join,
validate_rpc} from '../../utils/forward_protocol.ts';
type Entry = {
command: EnsureForward;
chain: Promise<unknown>;
pending: number;
socket?: Socket;
detach?: () => void;
ready: boolean;
terminal: boolean;
epoch: number;
lease: number;
retain_until: number;
expiry?: ReturnType<typeof setTimeout>;
stream?: BotStream;
files?: Pick<BotFileService, 'list' | 'read' | 'dispose'>;
unsubscribe?: () => void;
cancel: Set<() => void>;
used_tokens: Map<string, number>;
};
type Options = {
socket_factory?: typeof io;
stream_factory?: (bot_id: number) => BotStream;
file_factory?: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
};
const scope_keys = ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id'] as const;
const fingerprint = (token: string) => createHash('sha256').update(token).digest('hex');
const managers = new WeakMap<Socket, NodeForwardManager>();
export class NodeForwardManager {
private readonly entries = new Map<string, Entry>();
private readonly streams = new Map<number, {stream: BotStream; refs: number}>();
private readonly socket_factory: typeof io;
private readonly stream_factory: (bot_id: number) => BotStream;
private readonly file_factory: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
private stopped = false;
constructor(readonly node_id: number, readonly node_session_id: string, options: Options = {}) {
this.socket_factory = options.socket_factory ?? io;
this.stream_factory = options.stream_factory ?? (bot_id => new BotStream(bot_id));
this.file_factory = options.file_factory ?? (bot_id => new BotFileService(bot_id));
}
async ensure(raw: unknown): Promise<ForwardAck<ForwardReady>> {
let entry: Entry | undefined;
try {
const command = validate_command(raw);
this.check_command(command);
for (const [id, previous] of this.entries) {
if (previous.terminal && previous.pending === 0
&& previous.retain_until + 5000 <= Date.now()) this.entries.delete(id);
}
entry = this.entries.get(command.forward_session_id);
if (entry) {
if (scope_keys.some(key => entry!.command[key] !== command[key])
|| (['url', 'path', 'namespace'] as const).some(key => entry!.command[key] !== command[key])) {
throw new ForwardFailure('SESSION_STALE');
}
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
if (entry.pending >= 16) throw new ForwardFailure('RATE_LIMITED');
} else {
if (this.entries.size >= 4096 || [...this.entries.values()].filter(item => !item.terminal).length >= 500) {
throw new ForwardFailure('RATE_LIMITED');
}
// Retain closed identities past all known credentials, separately from active capacity.
entry = {command: {...command, token: ''}, chain: Promise.resolve(), pending: 0, ready: false, terminal: false,
epoch: 0, lease: 0, retain_until: command.expires_at, cancel: new Set(), used_tokens: new Map()};
this.entries.set(command.forward_session_id, entry);
this.arm_expiry(entry);
}
const current = entry;
current.retain_until = Math.max(current.retain_until, command.expires_at);
current.pending++;
let running = false;
const run = current.chain.then(() => {
running = true;
return this.establish(current, command);
});
current.chain = run.then(() => { current.pending--; }, () => { current.pending--; });
return await this.until_deadline(run, command.deadline_at, () => {
if (running) this.close_transport(current);
});
} catch (error) {
return error_ack(failure_code(error));
}
}
private check_command(command: EnsureForward) {
if (this.stopped || command.node_session_id !== this.node_session_id) throw new ForwardFailure('SESSION_STALE');
if (command.node_id !== this.node_id) throw new ForwardFailure('FORBIDDEN');
if (command.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (command.expires_at <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
}
private scope(command: EnsureForward): ForwardScope {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = command;
return {forward_id, forward_session_id, node_session_id, node_id, bot_id};
}
private async establish(entry: Entry, command: EnsureForward): Promise<ForwardAck<ForwardReady>> {
this.check_command(command);
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
if (command.expires_at < entry.lease) throw new ForwardFailure('SESSION_STALE');
const digest = fingerprint(command.token);
for (const [token, expiry] of entry.used_tokens) if (expiry <= Date.now()) entry.used_tokens.delete(token);
if (!entry.used_tokens.has(digest) && entry.used_tokens.size >= 256) throw new ForwardFailure('RATE_LIMITED');
let socket = entry.socket;
try {
if (socket?.connected && entry.ready) {
entry.used_tokens.set(digest, command.expires_at);
const joined = validate_join(await this.ack(entry, socket, 'reauth', {token: command.token}, command), command, false);
this.check_command(command);
if (!joined.node_ready) {
entry.ready = false;
await this.ready(entry, socket, command);
}
} else {
if (entry.used_tokens.has(digest)) throw new ForwardFailure('TOKEN_EXPIRED');
this.close_transport(entry);
// No autonomous reconnect: a join window can expire before the lease does.
socket = this.socket_factory(`${command.url.replace(/\/$/, '')}${command.namespace === '/' ? '' : command.namespace}`, {
path: command.path, autoConnect: false, reconnection: false, forceNew: true,
timeout: Math.min(10_000, this.remaining(command)), auth: {token: command.token},
});
entry.socket = socket;
this.install_transport(entry, socket);
await this.connect(entry, socket, command);
entry.used_tokens.set(digest, command.expires_at);
validate_join(await this.ack(entry, socket, 'join_room', {token: command.token}, command), command, true);
this.check_command(command);
if (!entry.stream) {
let shared = this.streams.get(command.bot_id);
if (!shared) {
shared = {stream: this.stream_factory(command.bot_id), refs: 0};
this.streams.set(command.bot_id, shared);
}
shared.refs++;
entry.stream = shared.stream;
}
this.observe_updates(entry, socket);
await this.ready(entry, socket, command);
}
this.check_command(command);
if (entry.socket !== socket || !socket.connected || entry.terminal || !entry.ready) throw new ForwardFailure('SESSION_STALE');
entry.command = {...command, token: ''};
entry.lease = command.expires_at;
entry.used_tokens.set(digest, command.expires_at);
this.arm_expiry(entry);
entry.stream?.resync();
this.check_command(command);
return {status: 'success', data: {...this.scope(command), node_ready: true, lease_expires_at: command.expires_at}};
} catch (error) {
this.close_transport(entry);
if (!entry.lease) this.release_stream(entry);
throw error;
}
}
private remaining(command: EnsureForward) {
const remaining = Math.min(command.deadline_at, command.expires_at) - Date.now();
if (remaining <= 0) throw new ForwardFailure('TIMEOUT');
return Math.min(remaining, 2_147_483_647);
}
private until_deadline<T>(task: Promise<T>, deadline: number, timeout: () => void): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
timeout();
reject(new ForwardFailure('TIMEOUT'));
}, Math.max(0, Math.min(deadline - Date.now(), 2_147_483_647)));
task.then(resolve, reject).finally(() => clearTimeout(timer));
});
}
private connect(entry: Entry, socket: Socket, command: EnsureForward): Promise<void> {
return new Promise((resolve, reject) => {
const done = (error?: ForwardFailure) => {
clearTimeout(timer);
socket.off('connect', connected);
socket.off('connect_error', failed);
entry.cancel.delete(cancel);
if (error) reject(error); else resolve();
};
const connected = () => done();
const failed = () => done(new ForwardFailure('RELAY_UNAVAILABLE'));
const cancel = () => done(new ForwardFailure('CANCELLED'));
const timer = setTimeout(() => done(new ForwardFailure('TIMEOUT')), this.remaining(command));
entry.cancel.add(cancel);
socket.once('connect', connected);
socket.once('connect_error', failed);
socket.connect();
});
}
private async ack(entry: Entry, socket: Socket, event: string, payload: unknown, command: EnsureForward): Promise<unknown> {
if (entry.terminal || entry.socket !== socket || !socket.connected || this.stopped) throw new ForwardFailure('SESSION_STALE');
const epoch = entry.epoch;
const result = await new Promise<unknown>((resolve, reject) => {
const cancel = () => { entry.cancel.delete(cancel); reject(new ForwardFailure('CANCELLED')); };
entry.cancel.add(cancel);
socket.timeout(this.remaining(command)).emit(event, payload, (error: unknown, response: unknown) => {
entry.cancel.delete(cancel);
if (error) reject(new ForwardFailure('TIMEOUT')); else resolve(response);
});
});
if (entry.terminal || entry.epoch !== epoch || entry.socket !== socket || !socket.connected || this.stopped) {
throw new ForwardFailure('SESSION_STALE');
}
return result;
}
private async ready(entry: Entry, socket: Socket, command: EnsureForward) {
const data = success_data(await this.ack(entry, socket, 'forward_node_ready', {
forward_session_id: command.forward_session_id,
}, command));
if (!exact(data, ['node_ready']) || data.node_ready !== true) throw new ForwardFailure('RELAY_UNAVAILABLE');
entry.ready = true;
}
private install_transport(entry: Entry, socket: Socket) {
const seen = new Map<string, number>();
const rpc = async (raw: unknown, callback: unknown) => {
if (typeof callback !== 'function') return;
let response: ForwardAck;
try {
// Relay may dispatch immediately after accepting ready, before its ACK reaches Node.
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || !entry.stream) {
throw new ForwardFailure('SESSION_STALE');
}
const request = validate_rpc(raw);
if (request.forward_session_id !== entry.command.forward_session_id) throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
for (const [id, deadline] of seen) if (deadline <= Date.now()) seen.delete(id);
if (seen.has(request.correlation_id)) throw new ForwardFailure('CANCELLED');
if (seen.size >= 512) throw new ForwardFailure('RATE_LIMITED');
seen.set(request.correlation_id, request.deadline_at);
let data: unknown;
switch (request.method) {
case 'bot.snapshot': data = entry.stream.snapshot(); break;
case 'get_context_info': data = entry.stream.get_context_info(request.payload, request.deadline_at); break;
case 'bot.logs': data = entry.stream.logs(request.payload as {limit: number; stream_id?: string; after_seq?: number}); break;
case 'get_log_history':
case 'get_data_file_list':
case 'get_file_data':
case 'execute_function':
case 'world.snapshot':
case 'world.chunk':
case 'world.unwatch':
case 'workflow.start':
case 'workflow.stop':
case 'workflow.snapshot':
case 'workflow.catalog':
case 'workflow.runtime':
case 'workflow.control':
case 'workflow.command':
case 'tab_complete': {
const epoch = entry.epoch;
const check_transport = () => {
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
};
if (request.method === 'get_data_file_list' || request.method === 'get_file_data') {
// File IDs belong to this transport/session, never the shared BotStream.
const files = entry.files ??= this.file_factory(entry.command.bot_id);
const deadline = Math.min(request.deadline_at, entry.lease || entry.command.expires_at);
data = request.method === 'get_data_file_list' ? await files.list(deadline, entry.lease || entry.command.expires_at)
: await files.read(request.payload as {id: string; offset?: number; length?: number}, deadline);
if (request.method === 'get_data_file_list') {
const listing = data as {expires_at: number};
listing.expires_at = Math.min(listing.expires_at, entry.lease || entry.command.expires_at);
}
} else if (request.method === 'world.snapshot') {
data = await this.until_deadline(entry.stream.world_snapshot(request.payload, request.deadline_at, check_transport),
request.deadline_at, () => {});
} else if (request.method === 'world.chunk') {
data = await this.until_deadline(entry.stream.world_chunk(request.payload, request.deadline_at, check_transport),
request.deadline_at, () => {});
} else if (request.method === 'world.unwatch') {
data = entry.stream.world_unwatch(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.start') {
data = entry.stream.workflow_start(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.stop') {
data = await this.until_deadline(entry.stream.workflow_stop(request.payload, request.deadline_at, check_transport),
request.deadline_at, () => {});
} else if (request.method === 'workflow.snapshot') {
data = entry.stream.workflow_snapshot(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.catalog') {
data = entry.stream.workflow_catalog(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.runtime') {
data = entry.stream.workflow_runtime(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.control') {
data = entry.stream.workflow_control(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.command') {
data = entry.stream.workflow_command(request.payload, request.deadline_at, check_transport);
} else data = request.method === 'get_log_history' ? await entry.stream.get_log_history()
: request.method === 'execute_function' ? await entry.stream.execute_function(
request.payload as {function: string; name: string; args: unknown[]}, request.deadline_at, check_transport)
: await entry.stream.tab_complete(request.payload, request.deadline_at);
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
break;
}
case 'bot.chat': data = entry.stream.chat(request.payload as {message: string}, request.deadline_at); break;
}
response = {status: 'success', data};
} catch (error) {
response = error_ack(failure_code(error));
}
try { callback(response); } catch { /* The request ACK must never escape an event callback. */ }
};
const closed = (raw: unknown) => {
if (entry.socket !== socket || !exact(raw, ['reason', 'code'], ['forward_session_id'])
|| (raw.forward_session_id !== undefined && raw.forward_session_id !== entry.command.forward_session_id)) return;
this.retire(entry);
};
const disconnected = () => {
if (entry.socket === socket) this.close_transport(entry);
};
const status = (raw: unknown) => {
if (entry.socket === socket && exact(raw, ['forward_session_id', 'node_ready', 'reason'])
&& raw.forward_session_id === entry.command.forward_session_id && raw.reason === 'resync_required') {
entry.stream?.resync();
}
};
socket.on('relay_request', rpc);
socket.on('forward_session_closed', closed);
socket.on('relay_status', status);
socket.on('disconnect', disconnected);
entry.detach = () => {
socket.off('relay_request', rpc);
socket.off('forward_session_closed', closed);
socket.off('relay_status', status);
socket.off('disconnect', disconnected);
};
}
private observe_updates(entry: Entry, socket: Socket) {
entry.unsubscribe?.();
let window = Date.now();
let count = 0;
entry.unsubscribe = entry.stream!.subscribe(event => {
if ((event.name !== 'log' && event.name !== 'info_update' && event.name !== 'status_update') || entry.socket !== socket || !socket.connected
|| !entry.ready || entry.terminal || this.stopped) return;
if (event.name === 'status_update') {
// Lifecycle state must survive a busy log stream; reconnect rather
// than silently retaining an online snapshot when the queue is full.
if ((socket.io.engine?.writeBuffer.length ?? 0) >= 64) this.close_transport(entry);
else socket.emit('bot_event', {...event, forward_session_id: entry.command.forward_session_id});
return;
}
if (Date.now() - window >= 1000) { window = Date.now(); count = 0; }
if (++count > 150 || (socket.io.engine?.writeBuffer.length ?? 0) >= 64) return;
if (event.name === 'log') {
socket.volatile.emit('bot_log', {...event, forward_session_id: entry.command.forward_session_id});
} else {
const {name, payload, ...metadata} = event;
const update = {...metadata,
forward_session_id: entry.command.forward_session_id, data: payload};
// Plugin lifecycle transitions can finish within the same transport
// write. Volatile would drop the final state while that write drains,
// leaving the UI initializing until another plugin change occurs.
// Keep the shared rate/buffer bounds above for these queued packets.
if (payload.name === 'plugin_states' || payload.name === 'workflow_states') socket.emit('info_update', update);
else socket.volatile.emit('info_update', update);
}
});
}
private close_transport(entry: Entry) {
entry.epoch++;
entry.ready = false;
entry.unsubscribe?.();
entry.unsubscribe = undefined;
entry.files?.dispose();
entry.files = undefined;
const socket = entry.socket;
entry.socket = undefined;
entry.detach?.();
entry.detach = undefined;
for (const cancel of [...entry.cancel]) cancel();
entry.cancel.clear();
socket?.disconnect();
if (socket) socket.auth = {};
}
private arm_expiry(entry: Entry) {
clearTimeout(entry.expiry);
const expires_at = Math.max(entry.lease, entry.command.expires_at);
entry.expiry = setTimeout(() => {
if (expires_at <= Date.now()) this.retire(entry);
else this.arm_expiry(entry);
}, Math.min(Math.max(0, expires_at - Date.now()), 2_147_483_647));
entry.expiry.unref();
}
private release_stream(entry: Entry) {
if (!entry.stream) return;
const shared = this.streams.get(entry.command.bot_id);
if (shared && --shared.refs === 0) {
shared.stream.dispose();
this.streams.delete(entry.command.bot_id);
}
entry.stream = undefined;
}
private retire(entry: Entry) {
entry.terminal = true;
clearTimeout(entry.expiry);
this.close_transport(entry);
this.release_stream(entry);
entry.used_tokens.clear();
}
stop() {
if (this.stopped) return;
this.stopped = true;
for (const entry of this.entries.values()) this.retire(entry);
this.entries.clear();
}
}
export function bind_node_session(socket: Socket, node_id: number, node_session_id: string): void {
clear_node_session(socket);
managers.set(socket, new NodeForwardManager(node_id, node_session_id));
}
export function clear_node_session(socket: Socket): void {
managers.get(socket)?.stop();
managers.delete(socket);
}
export function ensure_forward(socket: Socket, command: unknown): Promise<ForwardAck<ForwardReady>> {
if (!socket.connected) return Promise.resolve(error_ack('NODE_OFFLINE'));
return managers.get(socket)?.ensure(command) ?? Promise.resolve(error_ack('UNAUTHORIZED'));
}