chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
import {randomUUID} from 'node:crypto';
import {constants} from 'node:fs';
import {lstat, open, opendir, realpath} from 'node:fs/promises';
import type {BigIntStats} from 'node:fs';
import path from 'node:path';
import {performance} from 'node:perf_hooks';
import {get_project_root_path} from '../../utils/path_utils.ts';
import {exact, ForwardFailure, integer, uuid} from '../../utils/forward_protocol.ts';
export type DataFileEntry = {type: 'directory'; name: string; children: DataFileEntry[]}
| {type: 'file'; name: string; id: string; size: number; modified_at: number};
export interface DataFileList {
listing_id: string;
expires_at: number;
partial: boolean;
children: DataFileEntry[];
}
export interface FileData {
id: string;
name: string;
encoding: 'base64';
offset: number;
next_offset: number;
total_bytes: number;
eof: boolean;
data: string;
}
export interface BotFileServiceOptions {
/** Trusted server-side override of the bot data root, primarily for tests. */
root?: string;
now?: () => number;
}
type FileRecord = {parts: string[]; stat: BigIntStats; expires_at: number; append: boolean};
const TTL = 300_000;
const CHUNK = 262_144;
const MAX_IDS = 8000;
const MAX_ENTRIES = 2000;
const MAX_BYTES = 512 * 1024;
const identity = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino
&& a.birthtimeNs === b.birthtimeNs;
const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'ELOOP'].includes((error as NodeJS.ErrnoException)?.code ?? '');
export class BotFileService {
private readonly root: string;
private readonly now: () => number;
private readonly files = new Map<string, FileRecord>();
private disposed = false;
private listing = false;
private reads = 0;
constructor(bot_id: number, options: BotFileServiceOptions = {}) {
if (!integer(bot_id, 1)) throw new ForwardFailure('INVALID_REQUEST');
this.root = path.resolve(options.root ?? path.join(get_project_root_path(), 'data', 'bot', String(bot_id)));
this.now = options.now ?? Date.now;
}
dispose(): void {
this.disposed = true;
this.files.clear();
}
private check(deadline: number): void {
if (this.disposed) throw new ForwardFailure('CANCELLED');
if (!integer(deadline, 1)) throw new ForwardFailure('INVALID_REQUEST');
if (this.now() >= deadline) throw new ForwardFailure('TIMEOUT');
}
private prune(): void {
for (const [id, record] of this.files) {
if (record.expires_at <= this.now()) this.files.delete(id);
}
}
// Check every ancestor, including ancestors above the configured root. These
// checks narrow parent replacement races; they do not make path lookup atomic.
private async safe(parts: string[], deadline: number): Promise<BigIntStats> {
const target = path.join(this.root, ...parts);
const relative = path.relative(this.root, target);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new ForwardFailure('RESYNC_REQUIRED');
}
const parsed = path.parse(target);
let current = parsed.root;
let stat = await lstat(current, {bigint: true});
for (const component of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
this.check(deadline);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
current = path.join(current, component);
stat = await lstat(current, {bigint: true});
this.check(deadline);
if (stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
}
const resolved = await realpath(target);
this.check(deadline);
if (resolved !== target) throw new ForwardFailure('RESYNC_REQUIRED');
return stat;
}
async list(deadline_at: number, expires_at?: number): Promise<DataFileList> {
this.check(deadline_at);
if (expires_at !== undefined && (!integer(expires_at, 1) || expires_at <= this.now()))
throw new ForwardFailure('TOKEN_EXPIRED');
if (this.listing) throw new ForwardFailure('RATE_LIMITED');
this.listing = true;
const pending = new Map<string, FileRecord>();
const result: DataFileList = {listing_id: randomUUID(), expires_at: Math.min(this.now() + TTL, expires_at ?? Infinity),
partial: false, children: []};
let entries = 0;
let bytes = Buffer.byteLength(JSON.stringify(result));
const started = performance.now();
const budget = () => {
this.check(deadline_at);
if (performance.now() - started >= 2000) result.partial = true;
return !result.partial;
};
try {
this.prune();
const rootStat = await this.safe([], deadline_at);
if (!rootStat.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
const visit = async (parts: string[], children: DataFileEntry[], depth: number): Promise<void> => {
if (!budget()) return;
const before = await this.safe(parts, deadline_at);
if (!before.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
const names: string[] = [];
const directory = await opendir(path.join(this.root, ...parts));
try {
while (budget()) {
const entry = await directory.read();
this.check(deadline_at);
if (!entry) break;
if (names.length >= MAX_ENTRIES) { result.partial = true; break; }
names.push(entry.name);
}
} finally { await directory.close(); }
const after = await this.safe(parts, deadline_at);
if (!identity(before, after)) throw new ForwardFailure('RESYNC_REQUIRED');
// A bounded directory read may have hit its limit; still emit its
// collected entries, then stop further traversal.
const stopped = result.partial;
result.partial = false;
for (const name of names.sort()) {
if (!budget()) break;
if (entries >= MAX_ENTRIES) { result.partial = true; break; }
if (depth >= 32) { result.partial = true; break; }
const childParts = [...parts, name];
let stat: BigIntStats;
try { stat = await lstat(path.join(this.root, ...childParts), {bigint: true}); }
catch {
this.check(deadline_at);
result.partial = true;
break;
}
this.check(deadline_at);
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) continue;
if (stat.isFile() && (stat.size > BigInt(Number.MAX_SAFE_INTEGER)
|| this.files.size + pending.size >= MAX_IDS)) { result.partial = true; break; }
const entry: DataFileEntry = stat.isDirectory()
? {type: 'directory', name, children: []}
: {type: 'file', name, id: randomUUID(), size: Number(stat.size), modified_at: Number(stat.mtimeMs)};
const cost = Buffer.byteLength(JSON.stringify(entry)) + 1;
if (bytes + cost > MAX_BYTES) { result.partial = true; break; }
bytes += cost;
entries++;
children.push(entry);
if (entry.type === 'file') pending.set(entry.id, {parts: childParts, stat,
expires_at: result.expires_at, append: childParts.length === 2
&& childParts[0] === 'log' && name.endsWith('.log')});
else {
try { await visit(childParts, entry.children, depth + 1); }
catch (error) {
this.check(deadline_at);
if (error instanceof ForwardFailure) throw error;
result.partial = true;
}
}
if (result.partial) break;
}
result.partial ||= stopped;
};
await visit([], result.children, 0);
const finalRoot = await this.safe([], deadline_at);
if (!identity(rootStat, finalRoot)) throw new ForwardFailure('RESYNC_REQUIRED');
this.check(deadline_at);
for (const [id, record] of pending) this.files.set(id, record);
return result;
} catch (error) {
this.check(deadline_at);
if (missing(error) && result.children.length === 0 && pending.size === 0) {
// Only a genuinely absent root is an empty listing.
try { await lstat(this.root); }
catch (rootError) {
this.check(deadline_at);
if ((rootError as NodeJS.ErrnoException).code === 'ENOENT') return result;
}
}
throw error instanceof ForwardFailure ? error : new ForwardFailure('INTERNAL_ERROR');
} finally { this.listing = false; }
}
async read(payload: {id: string; offset?: number; length?: number}, deadline_at: number): Promise<FileData> {
this.check(deadline_at);
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|| (payload.offset !== undefined && !integer(payload.offset))
|| (payload.length !== undefined && (!integer(payload.length, 1) || payload.length > CHUNK))) {
throw new ForwardFailure('INVALID_REQUEST');
}
this.prune();
const record = this.files.get(payload.id);
if (!record) throw new ForwardFailure('INVALID_REQUEST');
const offset = payload.offset ?? 0;
const size = Number(record.stat.size);
if (offset > size) throw new ForwardFailure('INVALID_REQUEST');
if (this.reads >= 4) throw new ForwardFailure('RATE_LIMITED');
this.reads++;
const validate = (stat: BigIntStats) => {
if (!stat.isFile() || !identity(record.stat, stat) || (record.append
? stat.size < record.stat.size
: stat.size !== record.stat.size || stat.mtimeNs !== record.stat.mtimeNs
|| stat.ctimeNs !== record.stat.ctimeNs)) throw new ForwardFailure('RESYNC_REQUIRED');
};
try {
validate(await this.safe(record.parts, deadline_at));
const handle = await open(path.join(this.root, ...record.parts), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
this.check(deadline_at);
validate(await handle.stat({bigint: true}));
validate(await this.safe(record.parts, deadline_at));
const buffer = Buffer.alloc(Math.min(payload.length ?? CHUNK, size - offset));
let received = 0;
while (received < buffer.length) {
const {bytesRead} = await handle.read(buffer, received, buffer.length - received, offset + received);
this.check(deadline_at);
if (bytesRead === 0) throw new ForwardFailure('RESYNC_REQUIRED');
received += bytesRead;
}
validate(await handle.stat({bigint: true}));
validate(await this.safe(record.parts, deadline_at));
this.check(deadline_at);
if (record.expires_at <= this.now()) throw new ForwardFailure('INVALID_REQUEST');
return {id: payload.id, name: record.parts.at(-1)!, encoding: 'base64', offset,
next_offset: offset + received, total_bytes: size, eof: offset + received === size,
data: buffer.toString('base64')};
} finally { await handle.close(); }
} catch (error) {
this.check(deadline_at);
throw error instanceof ForwardFailure ? error
: new ForwardFailure(missing(error) ? 'RESYNC_REQUIRED' : 'INTERNAL_ERROR');
} finally { this.reads--; }
}
}
+430
View File
@@ -0,0 +1,430 @@
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 '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 === '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') || entry.socket !== socket || !socket.connected
|| !entry.ready || entry.terminal || this.stopped) 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'));
}
+820
View File
@@ -0,0 +1,820 @@
import {randomUUID} from 'node:crypto';
import type {log} from '../../types/utils/logger.ts';
import {Logger, type LogHistoryRecord} from '../../utils/logger.ts';
import {get_project_root_path} from '../../utils/path_utils.ts';
import * as path from 'node:path';
import {local_command_completion} from '../../utils/command_completion.ts';
import {get_bot_context, observe_bot_context} from '../instance/index.ts';
import type {bot_status} from '../../types/bot/instance/bot_runtime.ts';
import type {InfoManager, InfoSnapshot, InfoUpdate} from '../info/init.ts';
import type {FunctionManager} from '../function/init.ts';
import type {RunSnapshot, StartResult, WorkflowManager, WorkflowCommand, WorkflowRuntimeSnapshot} from '../../types/bot/workflow/runtime.ts';
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
import {get_workflow_catalog} from '../workflow/catalog.ts';
import {summarize_run, type RunSummary} from '../workflow/runtime/summary.ts';
import {object, validate_workflow_payload} from '../../utils/forward_protocol.ts';
export interface StreamContext {
readonly bot_status: bot_status;
readonly bot_instance: {
chat(message: string): unknown;
players?: Record<string, unknown>;
tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise<unknown[]>;
_client?: {
protocolVersion?: number;
state?: string;
_mcBundle?: Array<{data?: {params?: {transactionId?: number}}; metadata?: {name?: string}}>;
write(name: string, payload: unknown): void;
on(name: string, listener: (packet: any) => void): unknown;
off(name: string, listener: (packet: any) => void): unknown;
};
} | null;
readonly logger?: {read_recent_log(count: number): Promise<LogHistoryRecord[]>};
readonly info_manager?: Pick<InfoManager, 'get_all_info'> | null;
readonly function_manager?: Pick<FunctionManager, 'run'> | null;
readonly workflow_manager?: Pick<WorkflowManager, 'start' | 'stop' | 'get_snapshot' | 'get_snapshots'>
& Partial<Pick<WorkflowManager, 'control' | 'get_command' | 'get_runtime_snapshot'>> | null;
log?(segments: log[]): void;
on(event: any, callback: (...args: any[]) => void): unknown;
off(event: any, callback: (...args: any[]) => void): unknown;
}
export interface StreamContextSource {
get(bot_id: number): StreamContext | undefined;
observe(bot_id: number, callback: (context: StreamContext | undefined) => void): () => void;
}
export interface LogSegment {
message: string;
font_color: string;
font_style: Array<'normal' | 'italic' | 'bold' | 'light' | 'underline' | 'url'>;
hoverEvent?: log['hoverEvent'];
clickEvent?: log['clickEvent'];
insertion?: string;
}
export interface BotLogRecord {
id: string;
timestamp: number;
context_generation: string | null;
seq: number;
segments: LogSegment[];
}
export interface BotSnapshot {
bot_id: number;
bot_status: bot_status;
context_exists: boolean;
context_generation: string | null;
stream_id: string;
seq: number;
captured_at: number;
}
export interface BotLogsResult {
stream_id: string;
seq: number;
records: BotLogRecord[];
truncated: boolean;
}
interface StreamEventPayloads {
status_update: {status: bot_status; context_exists: boolean};
log: {records: BotLogRecord[]};
message: {text: string; position: string};
bot_error: {message: string};
info_update: InfoUpdate;
}
export type BotStreamEvent = {[K in keyof StreamEventPayloads]: {
bot_id: number;
context_generation: string | null;
stream_id: string;
seq: number;
emitted_at: number;
name: K;
payload: StreamEventPayloads[K];
}}[keyof StreamEventPayloads];
export type BotStreamErrorCode = 'INVALID_REQUEST' | 'TIMEOUT' | 'NO_CONTEXT'
| 'BOT_NOT_ONLINE' | 'INTERNAL_ERROR' | 'CANCELLED' | 'RESYNC_REQUIRED' | 'RATE_LIMITED';
export class BotStreamError extends Error {
readonly retryable = false;
constructor(readonly code: BotStreamErrorCode, message: string) {
super(message);
this.name = 'BotStreamError';
}
}
export interface BotStreamOptions {
source?: StreamContextSource;
capacity?: number;
now?: () => number;
max_chat_length?: number;
history_reader?: (count: number) => Promise<LogHistoryRecord[]>;
}
const default_source: StreamContextSource = {get: get_bot_context, observe: observe_bot_context};
const generations = new WeakMap<StreamContext, string>();
const completion_pending = new WeakSet<object>();
let completion_transaction = 0;
const statuses = new Set(['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']);
const styles = new Set<LogSegment['font_style'][number]>(['normal', 'italic', 'bold', 'light', 'underline', 'url']);
function own(value: unknown, key: string): unknown {
if (!value || typeof value !== 'object') return undefined;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
}
function exact(value: unknown, required: string[], optional: string[] = []): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Reflect.ownKeys(value);
return required.every(key => Object.hasOwn(value, key)) && keys.every(key => {
if (typeof key !== 'string' || !required.includes(key) && !optional.includes(key)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
return 'value' in descriptor && descriptor.enumerable;
});
}
function text(value: string, bytes: number): string {
if (Buffer.byteLength(value) <= bytes) return value;
let result = '';
let size = 0;
for (const character of value) {
size += Buffer.byteLength(character);
if (size > bytes) break;
result += character;
}
return result;
}
// Project module values to bounded JSON without invoking getters or toJSON methods.
class InfoDataError extends BotStreamError {
constructor(readonly reason: string, readonly field_path: string) {
super('INTERNAL_ERROR', 'Invalid info data');
}
}
function clone_info_data(value: unknown): unknown {
let entries = 0;
let bytes = 0;
const ancestors = new Set<object>();
const max_bytes = 4 * 1024 * 1024;
const visit = (item: unknown, depth: number, field_path: string): unknown => {
const invalid = (reason: string): never => { throw new InfoDataError(reason, field_path); };
if (depth > 64) invalid('max_depth');
if (++entries > 250_000) invalid('max_entries');
let result: unknown;
if (item === null || typeof item === 'boolean' || typeof item === 'string' || typeof item === 'number') {
if (typeof item === 'number' && !Number.isFinite(item)) invalid('non_finite_number');
if (typeof item === 'string' && Buffer.byteLength(item) > max_bytes) invalid('max_bytes');
bytes += Buffer.byteLength(JSON.stringify(item));
result = item;
} else {
if (!Array.isArray(item) && !object(item)) invalid('non_json_value');
const container = item as object;
if (ancestors.has(container)) invalid('cyclic_reference');
ancestors.add(container);
const keys = Reflect.ownKeys(container);
bytes += 2;
if (Array.isArray(item)) {
if (item.length > 65_536) invalid('max_array_length');
if (keys.length !== item.length + 1) invalid('non_dense_array');
const values: unknown[] = [];
for (let index = 0; index < item.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
if (index) bytes++;
values.push(visit(descriptor!.value, depth + 1, `${field_path}[${index}]`));
}
result = values;
} else {
if (keys.length > 65_536) invalid('max_keys');
const values = Object.create(null) as Record<string, unknown>;
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
if (typeof key !== 'string') invalid('non_string_key');
if (Buffer.byteLength(key as string) > max_bytes) invalid('max_bytes');
const descriptor = Object.getOwnPropertyDescriptor(container, key)!;
if (!descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0);
// Paths contain property indexes rather than server-provided keys or item content.
const path_key = ['name', 'value', 'window', 'slots', 'nbt', 'components', 'special', 'offers', 'data', 'pages'].includes(key as string)
? String(key) : `<field:${index}>`;
values[key as string] = visit(descriptor.value, depth + 1, `${field_path}.${path_key}`);
}
result = values;
}
ancestors.delete(container);
}
if (bytes > max_bytes) invalid('max_bytes');
return result;
};
return visit(value, 0, '$');
}
function valid_info_name(value: unknown): value is string {
return typeof value === 'string' && value.length <= 128 && /^[a-zA-Z0-9_-]+$/.test(value);
}
function valid_info_text(value: unknown, max: number): boolean {
return typeof value === 'string' && Buffer.byteLength(value) <= max;
}
function valid_info_i18n(value: unknown): boolean {
if (!exact(value, ['lang', 'name', 'description'], ['fields', 'values'])
|| !valid_info_text(value.lang, 128) || !valid_info_text(value.name, 256)
|| !valid_info_text(value.description, 4096)) return false;
return ['fields', 'values'].every(key => !Object.hasOwn(value, key)
|| object(value[key]) && Object.entries(value[key]).every(([name, metadata]) =>
valid_info_text(name, 128) && valid_info_i18n(metadata)));
}
function project_segments(value: unknown): LogSegment[] {
if (!Array.isArray(value)) return [];
const segments: LogSegment[] = [];
let bytes = 2;
for (let index = 0; index < Math.min(value.length, 256); index++) {
const item = own(value, String(index));
const message = own(item, 'message');
if (typeof message !== 'string') continue;
const color = own(item, 'font_color');
const font_style = own(item, 'font_style');
const safe_styles: LogSegment['font_style'] = [];
if (Array.isArray(font_style)) {
for (let i = 0; i < Math.min(font_style.length, styles.size); i++) {
const style = own(font_style, String(i));
if (typeof style === 'string' && styles.has(style as any) && !safe_styles.includes(style as any)) {
safe_styles.push(style as LogSegment['font_style'][number]);
}
}
}
const segment: LogSegment = {
message: text(message, 16_000),
font_color: typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffffff',
font_style: safe_styles.length ? safe_styles : ['normal'],
};
for (const key of ['hoverEvent', 'clickEvent'] as const) {
const event = own(item, key);
const action = own(event, 'action');
const value = own(event, 'value');
if (typeof action !== 'string' || typeof value !== 'string') continue;
if (key === 'hoverEvent') {
if (['show_text', 'show_item', 'show_entity'].includes(action)) {
segment.hoverEvent = {action: action as NonNullable<log['hoverEvent']>['action'], value: text(value, 4096)};
const parts = own(event, 'segments');
if (action === 'show_text' && Array.isArray(parts)) {
let remaining = 4096;
segment.hoverEvent.segments = project_segments(parts.slice(0, 64).map(part => ({
message: own(part, 'message'), font_color: own(part, 'font_color'), font_style: own(part, 'font_style'),
}))).map(({message, font_color, font_style}) => {
const clipped = message.slice(0, remaining);
remaining -= clipped.length;
return {message: clipped, font_color, font_style};
}).filter(part => part.message.length > 0);
}
}
} else if (['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page',
'copy_to_clipboard', 'show_dialog', 'custom'].includes(action)) {
segment.clickEvent = {action, value: text(value, 4096)};
}
}
const insertion = own(item, 'insertion');
if (typeof insertion === 'string') segment.insertion = text(insertion, 4096);
const size = Buffer.byteLength(JSON.stringify(segment)) + (segments.length ? 1 : 0);
if (bytes + size > 16_384) break;
segments.push(segment);
bytes += size;
}
return segments;
}
function message_text(value: unknown, depth = 0): string {
if (typeof value === 'string') return text(value, 4096);
if (depth >= 4) return '';
const json = own(value, 'json');
if (json !== undefined) return message_text(json, depth + 1);
const content = own(value, 'text');
let result = typeof content === 'string' ? text(content, 4096) : '';
const extra = own(value, 'extra');
if (Array.isArray(extra)) {
for (let index = 0; index < Math.min(extra.length, 16); index++) {
result = text(result + message_text(own(extra, String(index)), depth + 1), 4096);
}
}
return result;
}
export class BotStream {
readonly stream_id = randomUUID();
private readonly source: StreamContextSource;
private readonly capacity: number;
private readonly now: () => number;
private readonly max_chat_length: number;
private readonly history_reader: (count: number) => Promise<LogHistoryRecord[]>;
private readonly history_stream_id = randomUUID();
private context: StreamContext | undefined;
private generation: string | null = null;
private sequence = 0;
private dropped_through = -1;
private readonly records: BotLogRecord[] = [];
private readonly listeners = new Set<(event: BotStreamEvent) => void>();
private readonly pending_events: BotStreamEvent[] = [];
private publishing = false;
private context_listeners: Array<[string, (...args: any[]) => void]> = [];
private unobserve: (() => void) | undefined;
private disposed = false;
constructor(readonly bot_id: number, options: BotStreamOptions = {}) {
if (!Number.isSafeInteger(bot_id) || bot_id < 1) throw new BotStreamError('INVALID_REQUEST', 'Invalid bot ID');
this.capacity = options.capacity ?? 200;
this.max_chat_length = options.max_chat_length ?? 256;
if (!Number.isSafeInteger(this.capacity) || this.capacity < 1 || this.capacity > 1000
|| !Number.isSafeInteger(this.max_chat_length) || this.max_chat_length < 1) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid stream limits');
}
this.source = options.source ?? default_source;
this.now = options.now ?? Date.now;
this.history_reader = options.history_reader ?? (count => this.context?.logger
? this.context.logger.read_recent_log(count)
: Logger.read_recent_log(path.join(get_project_root_path(), 'data/bot', String(bot_id), 'log'), count));
this.unobserve = this.source.observe(bot_id, context => this.bind(context));
this.bind(this.source.get(bot_id));
}
private bind(context: StreamContext | undefined) {
if (this.disposed || this.context === context) return;
this.detach();
this.context = context;
this.generation = context ? generations.get(context) ?? randomUUID() : null;
if (context) {
generations.set(context, this.generation!);
const listen = (event: string, callback: (...args: any[]) => void) => {
const guarded = (...args: any[]) => {
if (!this.disposed && this.context === context && this.source.get(this.bot_id) === context) {
callback(...args);
}
};
this.context_listeners.push([event, guarded]);
context.on(event, guarded);
};
listen('status_update', () => this.status_event());
listen('log', input => {
const segments = project_segments(input);
if (!segments.length) return;
const record: BotLogRecord = {
id: randomUUID(), timestamp: this.now(), context_generation: this.generation,
seq: ++this.sequence, segments,
};
this.records.push(record);
if (this.records.length > this.capacity) this.dropped_through = this.records.shift()!.seq;
this.publish('log', {records: [record]}, record.seq);
});
listen('message', (input, position) => {
const projected = message_text(input);
if (projected) this.publish('message', {
text: projected,
position: typeof position === 'string' && position.trim() ? text(position, 128) : 'chat',
});
});
listen('error', () => this.publish('bot_error', {message: 'Bot connection error'}));
listen('info_update', input => {
if (context.bot_status !== 'online' || !context.bot_instance || !context.info_manager) return;
try {
const data = clone_info_data(input);
if (!exact(data, ['name', 'value']) || !valid_info_name(data.name)) {
throw new BotStreamError('INTERNAL_ERROR', 'Invalid info update');
}
this.publish('info_update', data as unknown as InfoUpdate);
} catch (error) {
const name = own(input, 'name');
console.error('Invalid bot info update was skipped', {
module: valid_info_name(name) ? name : 'unknown',
reason: error instanceof InfoDataError ? error.reason : 'invalid_update',
path: error instanceof InfoDataError ? error.field_path : '$',
});
}
});
}
this.status_event();
}
private detach() {
for (const [event, listener] of this.context_listeners) this.context?.off(event, listener);
this.context_listeners = [];
}
private state() {
return {
status: this.context && statuses.has(this.context.bot_status) ? this.context.bot_status : 'offline' as bot_status,
context_exists: !!this.context,
};
}
private status_event() {
this.publish('status_update', this.state());
}
private publish<K extends keyof StreamEventPayloads>(name: K, payload: StreamEventPayloads[K], seq = ++this.sequence) {
const event = {
bot_id: this.bot_id, context_generation: this.generation, stream_id: this.stream_id,
seq, emitted_at: this.now(), name, payload,
} as BotStreamEvent;
// Serialize reentrant callbacks so every subscriber sees increasing sequence numbers.
if (this.pending_events.length === 128) this.pending_events.shift();
this.pending_events.push(event);
if (this.publishing) return;
this.publishing = true;
try {
let dispatched = 0;
while (this.pending_events.length && !this.disposed && dispatched++ < 1024) {
const pending = this.pending_events.shift()!;
for (const listener of Array.from(this.listeners)) {
if (this.disposed) break;
try { listener(structuredClone(pending)); } catch { /* Isolate consumer failures from the bot lifecycle. */ }
}
}
} finally {
this.pending_events.length = 0;
this.publishing = false;
}
}
private active() {
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
this.bind(this.source.get(this.bot_id));
}
resync(): void {
this.active();
this.status_event();
}
snapshot(payload: unknown = {}): BotSnapshot {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid snapshot request');
const state = this.state();
return {
bot_id: this.bot_id, bot_status: state.status, context_exists: state.context_exists,
context_generation: this.generation, stream_id: this.stream_id,
seq: this.sequence, captured_at: this.now(),
};
}
get_context_info(payload: unknown, deadline_at: number): InfoSnapshot[] {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid info request');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance)
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
const manager = context.info_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Bot info manager is unavailable');
let data: unknown;
try { data = clone_info_data(manager.get_all_info()); }
catch { throw new BotStreamError('INTERNAL_ERROR', 'Could not read bot info'); }
const names = new Set<string>();
if (!Array.isArray(data) || data.length > 256 || !data.every(info => {
if (!exact(info, ['name', 'i18n', 'value']) || !valid_info_name(info.name) || names.has(info.name)
|| !valid_info_i18n(info.i18n)) return false;
names.add(info.name);
return true;
})) throw new BotStreamError('INTERNAL_ERROR', 'Invalid bot info result');
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.info_manager !== manager || context.bot_status !== 'online')
throw new BotStreamError('CANCELLED', 'Bot context changed');
return data as InfoSnapshot[];
}
logs(payload: unknown): BotLogsResult {
this.active();
if (!exact(payload, ['limit'], ['stream_id', 'after_seq'])
|| !Number.isSafeInteger(payload.limit) || (payload.limit as number) < 1 || (payload.limit as number) > 200
|| Object.hasOwn(payload, 'stream_id') && (typeof payload.stream_id !== 'string'
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(payload.stream_id))
|| Object.hasOwn(payload, 'after_seq') && (!Number.isSafeInteger(payload.after_seq)
|| (payload.after_seq as number) < 0 || typeof payload.stream_id !== 'string')) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid logs request');
}
const mismatch = payload.stream_id !== undefined && payload.stream_id !== this.stream_id;
const cursor = !mismatch && typeof payload.after_seq === 'number' ? payload.after_seq : undefined;
if (cursor !== undefined && cursor > this.sequence) {
throw new BotStreamError('RESYNC_REQUIRED', 'Log cursor exceeds stream sequence');
}
const available = this.records.filter(record => cursor === undefined || record.seq > cursor);
const candidates = cursor === undefined ? available.slice(-(payload.limit as number)).reverse()
: available.slice(0, payload.limit as number);
const records: BotLogRecord[] = [];
let bytes = 0;
for (const record of candidates) {
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
records.push(record);
bytes += size;
}
if (cursor === undefined) records.reverse();
return {
stream_id: this.stream_id, seq: this.sequence, records: structuredClone(records),
truncated: mismatch || available.length > records.length
|| (cursor === undefined ? this.dropped_through >= 0 : cursor < this.dropped_through),
};
}
async get_log_history(): Promise<BotLogsResult> {
this.active();
const history = await this.history_reader(20);
this.active();
const records: BotLogRecord[] = [];
let bytes = 0;
for (const entry of history.slice(-20).reverse()) {
const segments = project_segments(entry.log);
if (!segments.length) continue;
const record: BotLogRecord = {id: entry.id, timestamp: entry.timestamp,
context_generation: null, seq: 0, segments};
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
bytes += size;
records.push(record);
}
// History has its own sequence namespace so it cannot advance live log cursors.
records.reverse();
records.forEach((record, index) => { record.seq = index + 1; });
return {stream_id: this.history_stream_id, seq: records.length, records,
truncated: history.length >= 20 || records.length < history.length};
}
async tab_complete(payload: unknown, deadline_at: number): Promise<{matches: string[]; start: number; length: number}> {
this.active();
if (!exact(payload, ['text']) || typeof payload.text !== 'string' || payload.text.length > 256
|| /[\r\n\u0000]/.test(payload.text)) throw new BotStreamError('INVALID_REQUEST', 'Invalid completion text');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.tabComplete !== 'function')
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
if (completion_pending.has(instance)) throw new BotStreamError('RATE_LIMITED', 'Completion request is already pending');
completion_pending.add(instance);
// Leave time for Node -> Forward -> Web delivery before the relay deadline.
const timeout = Math.max(1, Math.min(4500, deadline_at - this.now() - 250));
const input = payload.text;
let start = input.search(/\S*$/);
if (start === 0 && input.startsWith('/')) start = 1;
let length = input.length - start;
try {
const client = instance._client;
const modern = (client?.protocolVersion ?? 0) >= 393;
const local = modern ? local_command_completion(instance, input, Object.keys(instance.players ?? {})) : undefined;
const values = await new Promise<unknown[]>((resolve, reject) => {
let received = 0;
let settled = false;
const transactionId = completion_transaction = (completion_transaction + 1) % 2_147_483_647;
const finish = (error?: BotStreamError, values?: unknown[]) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (modern) client!.off('tab_complete', receive);
client?.off('end', ended);
if (error) reject(error); else resolve(values ?? []);
};
const receive = (packet: {transactionId?: number; matches?: unknown[]; start?: number; length?: number}) => {
received++;
if (packet.transactionId !== transactionId) return;
if (!Number.isSafeInteger(packet.start) || !Number.isSafeInteger(packet.length)
|| packet.start! < 0 || packet.length! < 0 || packet.start! + packet.length! > input.length) {
finish(new BotStreamError('INTERNAL_ERROR', 'Invalid completion range'));
return;
}
start = packet.start!;
length = packet.length!;
finish(undefined, packet.matches);
};
const ended = () => finish(new BotStreamError('CANCELLED', 'Bot connection ended'));
const timer = setTimeout(() => {
if (local?.matches.length) { start = local.start; length = local.length; finish(undefined, []); }
else {
try {
context.log?.([{message: `[Tab补全超时] protocol=${client?.protocolVersion ?? 'unknown'} state=${client?.state ?? 'unknown'} transactionId=${transactionId} responses=${received} bundledPackets=${client?._mcBundle?.length ?? 0}`,
font_color: '#d1b478', font_style: ['normal']}]);
} catch { /* Diagnostics cannot prevent request completion. */ }
finish(new BotStreamError('TIMEOUT', 'Completion timed out'));
}
}, local?.matches.length ? Math.min(timeout, 200) : timeout);
client?.on('end', ended);
try {
if (modern) {
client!.on('tab_complete', receive);
client!.write('tab_complete', {transactionId, text: payload.text});
} else {
instance.tabComplete!(payload.text as string, false, false, timeout).then(
values => finish(undefined, values),
() => finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')));
}
} catch { finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')); }
});
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.bot_status !== 'online') throw new BotStreamError('CANCELLED', 'Bot context changed');
const matches: string[] = [];
if (!Array.isArray(values)) throw new BotStreamError('INTERNAL_ERROR', 'Invalid completion result');
for (const value of values.slice(0, 1000)) {
let match = typeof value === 'string' ? value : own(value, 'match');
if (!modern && start === 1 && input.startsWith('/') && typeof match === 'string' && match.startsWith('/'))
match = match.slice(1);
if (typeof match === 'string' && match.length > 0 && match.length <= 256 && !matches.includes(match))
matches.push(match);
if (matches.length === 100) break;
}
if (local?.matches.length && (!matches.length || start === local.start && length === local.length)) {
if (!matches.length) { start = local.start; length = local.length; }
for (const match of local.matches) {
if (matches.length >= 100) break;
if (!matches.includes(match)) matches.push(match);
}
}
// Legacy protocols only return candidates; replace the last token, preserving the command slash.
return {matches, start, length};
} finally { completion_pending.delete(instance); }
}
async execute_function(payload: {function: string; name: string; args: unknown[]}, deadline_at: number,
check_transport: () => void = () => {}): Promise<boolean> {
this.active();
const context = this.context;
const instance = context?.bot_instance;
const manager = context?.function_manager;
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Function deadline expired');
if (this.disposed || !context || this.source.get(this.bot_id) !== context || context.bot_instance !== instance)
throw new BotStreamError('CANCELLED', 'Bot context changed');
};
check();
if (!instance || context!.bot_status !== 'online' || !manager) return false;
try {
return await manager.run(payload.function, payload.name, payload.args, check) === true;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
return false;
}
}
private workflow_context(deadline_at: number, check_transport: () => void, online = false) {
this.active();
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
const manager = context.workflow_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Workflow manager is unavailable');
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.workflow_manager !== manager) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (online && (context.bot_status !== 'online' || !instance))
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
};
check();
return {manager, check};
}
workflow_start(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): StartResult {
const input = validate_workflow_payload('workflow.start', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport, true);
const trigger = input.trigger === undefined ? undefined : structuredClone(input.trigger);
check();
const result = structuredClone(manager.start(input.workflow_id, trigger));
check();
return result;
}
async workflow_stop(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): Promise<{stopped: true}> {
const input = validate_workflow_payload('workflow.stop', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
check();
await manager.stop(input.run_id);
check();
return {stopped: true};
}
workflow_snapshot(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): RunSnapshot | RunSummary[] | (RunSummary & Pick<RunSnapshot, 'trigger'>) | null {
const input = validate_workflow_payload('workflow.snapshot', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
const snapshot = input.run_id === undefined ? null : manager.get_snapshot(input.run_id) ?? null;
const result = structuredClone(input.run_id === undefined ? manager.get_snapshots().map(summarize_run)
: snapshot && input.include_branches === false ? {...summarize_run(snapshot), ...(snapshot.trigger === undefined ? {} : {trigger: snapshot.trigger})} : snapshot);
check();
return result;
}
workflow_catalog(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): WorkflowCatalog {
this.active();
validate_workflow_payload('workflow.catalog', payload);
const check = () => {
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
};
check();
const result = structuredClone(get_workflow_catalog());
check();
return result;
}
workflow_runtime(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowRuntimeSnapshot {
validate_workflow_payload('workflow.runtime', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (!manager.get_runtime_snapshot) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_runtime_snapshot());
check();
return result;
}
workflow_control(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand {
const input = validate_workflow_payload('workflow.control', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.control) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const {context_generation, ...request} = structuredClone(input);
check();
try {
const result = structuredClone(manager.control(request));
check();
return result;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
const code = (error as {code?: string})?.code;
throw new BotStreamError(code === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'INVALID_REQUEST', 'Workflow control rejected');
}
}
workflow_command(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand | null {
const input = validate_workflow_payload('workflow.command', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.get_command) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_command(input.command_id) ?? null);
check();
return result;
}
chat(payload: unknown, deadline_at: number): {accepted: true} {
this.active();
if (!exact(payload, ['message']) || typeof payload.message !== 'string'
|| !payload.message.trim() || payload.message.length > this.max_chat_length) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid chat request');
}
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) {
throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
}
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.chat !== 'function') {
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
}
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
if (this.source.get(this.bot_id) !== context || context.bot_instance !== instance || context.bot_status !== 'online') {
throw new BotStreamError('CANCELLED', 'Bot context changed');
}
try { instance.chat(payload.message); } catch {
throw new BotStreamError('INTERNAL_ERROR', 'Bot chat failed');
}
return {accepted: true};
}
subscribe(listener: (event: BotStreamEvent) => void): () => void {
this.active();
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.unobserve?.();
this.unobserve = undefined;
this.detach();
this.context = undefined;
this.generation = null;
this.listeners.clear();
this.records.length = 0;
this.pending_events.length = 0;
}
}