466 lines
13 KiB
JavaScript
466 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
const dgram = require("dgram");
|
|
const path = require("path");
|
|
|
|
const {
|
|
CultNetRudpSession,
|
|
decodeRudpPacket,
|
|
encodeCultNetMessageForWire,
|
|
encodeRudpPacket,
|
|
parseCultNetMessage,
|
|
} = require("cultnet-ts");
|
|
|
|
const { encode, decode } = require(path.resolve(
|
|
__dirname,
|
|
"..",
|
|
"..",
|
|
"CultLib",
|
|
"node_modules",
|
|
"@msgpack",
|
|
"msgpack",
|
|
));
|
|
|
|
const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0";
|
|
const WEKSA_COMMAND_RUDP_CONNECTION_ID = 0x1d0d0002;
|
|
const WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID = "weksa.mimo_voicedesign_command.v0";
|
|
const WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID = "weksa.mimo_voicedesign_receipt.v0";
|
|
|
|
async function createWeksaRudpCommandServer(options) {
|
|
if (!options?.host || !options?.port || typeof options.onCommand !== "function") {
|
|
throw new Error("Weksa RUDP command server requires host, port, and onCommand.");
|
|
}
|
|
const socket = dgram.createSocket(endpointFamily(options.host));
|
|
await bindServerSocket(socket, options.host, options.port);
|
|
|
|
const sessions = new Map();
|
|
const resendTimer = setInterval(() => {
|
|
for (const entry of sessions.values()) {
|
|
for (const packet of entry.session.dueResends(Date.now())) {
|
|
void sendPacket(socket, entry.endpoint, packet);
|
|
}
|
|
}
|
|
}, 25);
|
|
resendTimer.unref?.();
|
|
|
|
const onMessage = (wire, remote) => {
|
|
void handleServerDatagram({
|
|
wire,
|
|
remote,
|
|
socket,
|
|
sessions,
|
|
options,
|
|
}).catch((error) => {
|
|
options.onError?.(error);
|
|
});
|
|
};
|
|
const onError = (error) => {
|
|
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
};
|
|
|
|
socket.on("message", onMessage);
|
|
socket.on("error", onError);
|
|
|
|
return {
|
|
endpoint: { host: options.host, port: options.port },
|
|
uri: formatRudpEndpoint(options.host, options.port),
|
|
close() {
|
|
clearInterval(resendTimer);
|
|
socket.off("message", onMessage);
|
|
socket.off("error", onError);
|
|
socket.close();
|
|
sessions.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function handleServerDatagram(input) {
|
|
const { wire, remote, socket, sessions, options } = input;
|
|
const packet = decodeRudpPacket(wire);
|
|
const key = endpointKey(remote.address, remote.port);
|
|
let entry = sessions.get(key);
|
|
if (!entry) {
|
|
if (packet.packetType !== "connect") {
|
|
return;
|
|
}
|
|
const session = new CultNetRudpSession({
|
|
connectionId: WEKSA_COMMAND_RUDP_CONNECTION_ID,
|
|
initialSequence: 1,
|
|
resendDelayMs: 100,
|
|
maxPendingReliablePackets: 64,
|
|
});
|
|
entry = {
|
|
session,
|
|
endpoint: { host: remote.address, port: remote.port },
|
|
};
|
|
sessions.set(key, entry);
|
|
await sendPacket(socket, entry.endpoint, session.acceptConnect(packet, Date.now()));
|
|
return;
|
|
}
|
|
|
|
const result = entry.session.receive(packet, Date.now());
|
|
if (result.reply) {
|
|
await sendPacket(socket, entry.endpoint, result.reply);
|
|
}
|
|
for (const frame of result.delivered) {
|
|
await handleServerFrame({
|
|
socket,
|
|
entry,
|
|
frame,
|
|
options,
|
|
});
|
|
}
|
|
if (packet.packetType === "accept" || result.delivered.length > 0) {
|
|
await sendPacket(socket, entry.endpoint, entry.session.createAck());
|
|
}
|
|
if (result.disconnected) {
|
|
sessions.delete(key);
|
|
}
|
|
}
|
|
|
|
async function handleServerFrame(input) {
|
|
const { socket, entry, frame, options } = input;
|
|
if (frame.channelId !== "schema") {
|
|
return;
|
|
}
|
|
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
|
if (message.schemaVersion !== "cultnet.document_put_raw.v0") {
|
|
return;
|
|
}
|
|
if (message.document.schemaId !== WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID) {
|
|
return;
|
|
}
|
|
const payload = decode(message.document.payload);
|
|
const command = {
|
|
...payload,
|
|
request_id: payload?.request_id ?? message.document.recordKey,
|
|
};
|
|
let receipt;
|
|
try {
|
|
receipt = await options.onCommand(command, message);
|
|
} catch (error) {
|
|
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
receipt = {
|
|
schema_version: WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
|
|
request_id: command.request_id,
|
|
generated_at: new Date().toISOString(),
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
if (!receipt) {
|
|
return;
|
|
}
|
|
const receiptMessage = {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: `${options.runtimeId ?? "weksa-daemon"}:receipt:${receipt.request_id}`,
|
|
document: {
|
|
schemaId: WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
|
|
recordKey: receipt.request_id,
|
|
storedAt: receipt.generated_at ?? receipt.observed_at ?? new Date().toISOString(),
|
|
payloadEncoding: "messagepack",
|
|
payload: encode(receipt),
|
|
sourceRuntimeId: options.runtimeId ?? "weksa-daemon",
|
|
sourceRole: "weksa-command-ingress",
|
|
tags: [CULTNET_RUDP_PROTOCOL_ID, "speech_provider.mimo.voicedesign"],
|
|
},
|
|
};
|
|
const wirePayload = encode(
|
|
encodeCultNetMessageForWire(receiptMessage, "cultnet.schema.v0"),
|
|
);
|
|
for (const packet of entry.session.sendMany("schema", wirePayload, {
|
|
reliable: true,
|
|
ordered: true,
|
|
nowMs: Date.now(),
|
|
})) {
|
|
await sendPacket(socket, entry.endpoint, packet);
|
|
}
|
|
}
|
|
|
|
async function sendWeksaMimoVoiceDesignCommand(options) {
|
|
const endpoint = parseEndpoint(options?.endpoint);
|
|
const requestId = options.command?.request_id ?? buildRequestId();
|
|
const command = {
|
|
...options.command,
|
|
request_id: requestId,
|
|
};
|
|
const socket = dgram.createSocket(endpointFamily(endpoint.host));
|
|
await bindClientSocket(socket, options?.localHost);
|
|
const receiver = createPacketReceiver(socket);
|
|
const session = new CultNetRudpSession({
|
|
connectionId: WEKSA_COMMAND_RUDP_CONNECTION_ID,
|
|
initialSequence: 1,
|
|
resendDelayMs: 100,
|
|
maxPendingReliablePackets: 64,
|
|
});
|
|
|
|
try {
|
|
await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array()));
|
|
await receiveCultNetMessageUntil({
|
|
receiver,
|
|
session,
|
|
endpoint,
|
|
timeoutMs: options.timeoutMs ?? 5000,
|
|
label: "accept",
|
|
predicate: (_message, packet) => packet.packetType === "accept",
|
|
});
|
|
|
|
const commandMessage = {
|
|
schemaVersion: "cultnet.document_put_raw.v0",
|
|
messageId: `${options.runtimeId ?? "weksa-cli"}:command:${requestId}`,
|
|
document: {
|
|
schemaId: WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID,
|
|
recordKey: requestId,
|
|
storedAt: new Date().toISOString(),
|
|
payloadEncoding: "messagepack",
|
|
payload: encode(command),
|
|
sourceRuntimeId: options.runtimeId ?? "weksa-cli",
|
|
sourceRole: "command-client",
|
|
tags: [CULTNET_RUDP_PROTOCOL_ID, "speech_provider.mimo.voicedesign"],
|
|
},
|
|
};
|
|
const wirePayload = encode(
|
|
encodeCultNetMessageForWire(commandMessage, "cultnet.schema.v0"),
|
|
);
|
|
for (const packet of session.sendMany("schema", wirePayload, {
|
|
reliable: true,
|
|
ordered: true,
|
|
nowMs: Date.now(),
|
|
})) {
|
|
await sendPacket(socket, endpoint, packet);
|
|
}
|
|
|
|
return await receiveCultNetMessageUntil({
|
|
receiver,
|
|
session,
|
|
endpoint,
|
|
timeoutMs: options.timeoutMs ?? 10000,
|
|
label: "receipt",
|
|
predicate: (message) =>
|
|
message.schemaVersion === "cultnet.document_put_raw.v0"
|
|
&& message.document.schemaId === WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID
|
|
&& message.document.recordKey === requestId,
|
|
decodePayload: true,
|
|
});
|
|
} finally {
|
|
receiver.close();
|
|
socket.close();
|
|
}
|
|
}
|
|
|
|
async function receiveCultNetMessageUntil(options) {
|
|
const deadline = Date.now() + options.timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const packet = await options.receiver.next(
|
|
Math.min(100, Math.max(1, deadline - Date.now())),
|
|
options.label,
|
|
);
|
|
const result = options.session.receive(packet, Date.now());
|
|
if (result.reply) {
|
|
await sendPacket(options.receiver.socket, options.endpoint, result.reply);
|
|
}
|
|
if (packet.packetType === "accept" || result.delivered.length > 0) {
|
|
await sendPacket(
|
|
options.receiver.socket,
|
|
options.endpoint,
|
|
options.session.createAck(),
|
|
);
|
|
}
|
|
if (packet.packetType === "accept" && options.predicate(undefined, packet)) {
|
|
return undefined;
|
|
}
|
|
for (const frame of result.delivered) {
|
|
if (frame.channelId !== "schema") {
|
|
continue;
|
|
}
|
|
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
|
if (!options.predicate(message, packet)) {
|
|
continue;
|
|
}
|
|
if (
|
|
options.decodePayload
|
|
&& message.schemaVersion === "cultnet.document_put_raw.v0"
|
|
) {
|
|
return decode(message.document.payload);
|
|
}
|
|
return message;
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== "ETIMEDOUT") {
|
|
throw error;
|
|
}
|
|
}
|
|
for (const packet of options.session.dueResends(Date.now())) {
|
|
await sendPacket(options.receiver.socket, options.endpoint, packet);
|
|
}
|
|
}
|
|
throw new Error(`timed out waiting for Weksa RUDP ${options.label} after ${options.timeoutMs}ms`);
|
|
}
|
|
|
|
async function bindServerSocket(socket, host, port) {
|
|
await new Promise((resolve, reject) => {
|
|
socket.once("error", reject);
|
|
socket.bind(port, host, () => {
|
|
socket.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function bindClientSocket(socket, localHost) {
|
|
const bindHost = localHost ?? "0.0.0.0";
|
|
await new Promise((resolve, reject) => {
|
|
socket.once("error", reject);
|
|
socket.bind(0, bindHost, () => {
|
|
socket.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function createPacketReceiver(socket) {
|
|
const packets = [];
|
|
const waiters = [];
|
|
const errors = [];
|
|
|
|
const resolveNext = () => {
|
|
while (waiters.length > 0 && (packets.length > 0 || errors.length > 0)) {
|
|
const waiter = waiters.shift();
|
|
clearTimeout(waiter.timer);
|
|
if (errors.length > 0) {
|
|
waiter.reject(errors.shift());
|
|
} else {
|
|
waiter.resolve(packets.shift());
|
|
}
|
|
}
|
|
};
|
|
|
|
const onMessage = (wire) => {
|
|
try {
|
|
packets.push(decodeRudpPacket(wire));
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
resolveNext();
|
|
};
|
|
const onError = (error) => {
|
|
errors.push(error);
|
|
resolveNext();
|
|
};
|
|
|
|
socket.on("message", onMessage);
|
|
socket.on("error", onError);
|
|
|
|
return {
|
|
socket,
|
|
next(timeoutMs, label = "packet") {
|
|
if (packets.length > 0) {
|
|
return Promise.resolve(packets.shift());
|
|
}
|
|
if (errors.length > 0) {
|
|
return Promise.reject(errors.shift());
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const waiter = {
|
|
resolve,
|
|
reject,
|
|
timer: setTimeout(() => {
|
|
const index = waiters.indexOf(waiter);
|
|
if (index >= 0) {
|
|
waiters.splice(index, 1);
|
|
}
|
|
const error = new Error(`timed out waiting for Weksa RUDP ${label}`);
|
|
error.code = "ETIMEDOUT";
|
|
reject(error);
|
|
}, Math.max(1, timeoutMs)),
|
|
};
|
|
waiters.push(waiter);
|
|
});
|
|
},
|
|
close() {
|
|
socket.off("message", onMessage);
|
|
socket.off("error", onError);
|
|
while (waiters.length > 0) {
|
|
const waiter = waiters.shift();
|
|
clearTimeout(waiter.timer);
|
|
const error = new Error("Weksa RUDP command receiver closed.");
|
|
error.code = "ECLOSED";
|
|
waiter.reject(error);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sendPacket(socket, endpoint, packet) {
|
|
const wire = encodeRudpPacket(packet);
|
|
await new Promise((resolve, reject) => {
|
|
socket.send(wire, endpoint.port, endpoint.host, (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseEndpoint(value) {
|
|
if (!value) {
|
|
throw new Error("Weksa RUDP endpoint is required.");
|
|
}
|
|
const text = String(value).trim();
|
|
if (text.toLowerCase().startsWith("rudp://")) {
|
|
const parsed = new URL(text);
|
|
return {
|
|
host: parsed.hostname,
|
|
port: parsePort(parsed.port),
|
|
};
|
|
}
|
|
const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/);
|
|
if (ipv6) {
|
|
return { host: ipv6[1], port: parsePort(ipv6[2]) };
|
|
}
|
|
const index = text.lastIndexOf(":");
|
|
if (index <= 0) {
|
|
throw new Error(`Weksa RUDP endpoint must be host:port, got "${value}".`);
|
|
}
|
|
return {
|
|
host: text.slice(0, index),
|
|
port: parsePort(text.slice(index + 1)),
|
|
};
|
|
}
|
|
|
|
function parsePort(value) {
|
|
const port = Number(value);
|
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
throw new Error(`Weksa RUDP endpoint port is invalid: ${value}`);
|
|
}
|
|
return port;
|
|
}
|
|
|
|
function endpointFamily(host) {
|
|
return host.includes(":") ? "udp6" : "udp4";
|
|
}
|
|
|
|
function endpointKey(host, port) {
|
|
return `${host}:${port}`;
|
|
}
|
|
|
|
function formatRudpEndpoint(host, port) {
|
|
return host.includes(":") ? `rudp://[${host}]:${port}` : `rudp://${host}:${port}`;
|
|
}
|
|
|
|
function buildRequestId() {
|
|
return `mimo-${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
module.exports = {
|
|
CULTNET_RUDP_PROTOCOL_ID,
|
|
WEKSA_COMMAND_RUDP_CONNECTION_ID,
|
|
WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID,
|
|
WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
|
|
createWeksaRudpCommandServer,
|
|
sendWeksaMimoVoiceDesignCommand,
|
|
formatRudpEndpoint,
|
|
};
|