refactor(src): split oversized modules
This commit is contained in:
13
src/gateway/server/close-reason.ts
Normal file
13
src/gateway/server/close-reason.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const CLOSE_REASON_MAX_BYTES = 120;
|
||||
|
||||
export function truncateCloseReason(
|
||||
reason: string,
|
||||
maxBytes = CLOSE_REASON_MAX_BYTES,
|
||||
): string {
|
||||
if (!reason) return "invalid handshake";
|
||||
const buf = Buffer.from(reason);
|
||||
if (buf.length <= maxBytes) return reason;
|
||||
return buf.subarray(0, maxBytes).toString();
|
||||
}
|
||||
72
src/gateway/server/health-state.ts
Normal file
72
src/gateway/server/health-state.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
getHealthSnapshot,
|
||||
type HealthSummary,
|
||||
} from "../../commands/health.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
STATE_DIR_CLAWDBOT,
|
||||
} from "../../config/config.js";
|
||||
import { listSystemPresence } from "../../infra/system-presence.js";
|
||||
import type { Snapshot } from "../protocol/index.js";
|
||||
|
||||
let presenceVersion = 1;
|
||||
let healthVersion = 1;
|
||||
let healthCache: HealthSummary | null = null;
|
||||
let healthRefresh: Promise<HealthSummary> | null = null;
|
||||
let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null;
|
||||
|
||||
export function buildGatewaySnapshot(): Snapshot {
|
||||
const presence = listSystemPresence();
|
||||
const uptimeMs = Math.round(process.uptime() * 1000);
|
||||
// Health is async; caller should await getHealthSnapshot and replace later if needed.
|
||||
const emptyHealth: unknown = {};
|
||||
return {
|
||||
presence,
|
||||
health: emptyHealth,
|
||||
stateVersion: { presence: presenceVersion, health: healthVersion },
|
||||
uptimeMs,
|
||||
// Surface resolved paths so UIs can display the true config location.
|
||||
configPath: CONFIG_PATH_CLAWDBOT,
|
||||
stateDir: STATE_DIR_CLAWDBOT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getHealthCache(): HealthSummary | null {
|
||||
return healthCache;
|
||||
}
|
||||
|
||||
export function getHealthVersion(): number {
|
||||
return healthVersion;
|
||||
}
|
||||
|
||||
export function incrementPresenceVersion(): number {
|
||||
presenceVersion += 1;
|
||||
return presenceVersion;
|
||||
}
|
||||
|
||||
export function getPresenceVersion(): number {
|
||||
return presenceVersion;
|
||||
}
|
||||
|
||||
export function setBroadcastHealthUpdate(
|
||||
fn: ((snap: HealthSummary) => void) | null,
|
||||
) {
|
||||
broadcastHealthUpdate = fn;
|
||||
}
|
||||
|
||||
export async function refreshGatewayHealthSnapshot(opts?: { probe?: boolean }) {
|
||||
if (!healthRefresh) {
|
||||
healthRefresh = (async () => {
|
||||
const snap = await getHealthSnapshot({ probe: opts?.probe });
|
||||
healthCache = snap;
|
||||
healthVersion += 1;
|
||||
if (broadcastHealthUpdate) {
|
||||
broadcastHealthUpdate(snap);
|
||||
}
|
||||
return snap;
|
||||
})().finally(() => {
|
||||
healthRefresh = null;
|
||||
});
|
||||
}
|
||||
return healthRefresh;
|
||||
}
|
||||
122
src/gateway/server/hooks.ts
Normal file
122
src/gateway/server/hooks.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { CliDeps } from "../../cli/deps.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
|
||||
import { runCronIsolatedAgentTurn } from "../../cron/isolated-agent.js";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import type { createSubsystemLogger } from "../../logging.js";
|
||||
import type { HookMessageChannel, HooksConfigResolved } from "../hooks.js";
|
||||
import { createHooksRequestHandler } from "../server-http.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export function createGatewayHooksRequestHandler(params: {
|
||||
deps: CliDeps;
|
||||
getHooksConfig: () => HooksConfigResolved | null;
|
||||
bindHost: string;
|
||||
port: number;
|
||||
logHooks: SubsystemLogger;
|
||||
}) {
|
||||
const { deps, getHooksConfig, bindHost, port, logHooks } = params;
|
||||
|
||||
const dispatchWakeHook = (value: {
|
||||
text: string;
|
||||
mode: "now" | "next-heartbeat";
|
||||
}) => {
|
||||
const sessionKey = resolveMainSessionKeyFromConfig();
|
||||
enqueueSystemEvent(value.text, { sessionKey });
|
||||
if (value.mode === "now") {
|
||||
requestHeartbeatNow({ reason: "hook:wake" });
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchAgentHook = (value: {
|
||||
message: string;
|
||||
name: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
channel: HookMessageChannel;
|
||||
to?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
}) => {
|
||||
const sessionKey = value.sessionKey.trim()
|
||||
? value.sessionKey.trim()
|
||||
: `hook:${randomUUID()}`;
|
||||
const mainSessionKey = resolveMainSessionKeyFromConfig();
|
||||
const jobId = randomUUID();
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: jobId,
|
||||
name: value.name,
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "at", atMs: now },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: value.wakeMode,
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: value.message,
|
||||
model: value.model,
|
||||
thinking: value.thinking,
|
||||
timeoutSeconds: value.timeoutSeconds,
|
||||
deliver: value.deliver,
|
||||
channel: value.channel,
|
||||
to: value.to,
|
||||
},
|
||||
state: { nextRunAtMs: now },
|
||||
};
|
||||
|
||||
const runId = randomUUID();
|
||||
void (async () => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const result = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job,
|
||||
message: value.message,
|
||||
sessionKey,
|
||||
lane: "cron",
|
||||
});
|
||||
const summary =
|
||||
result.summary?.trim() || result.error?.trim() || result.status;
|
||||
const prefix =
|
||||
result.status === "ok"
|
||||
? `Hook ${value.name}`
|
||||
: `Hook ${value.name} (${result.status})`;
|
||||
enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
|
||||
sessionKey: mainSessionKey,
|
||||
});
|
||||
if (value.wakeMode === "now") {
|
||||
requestHeartbeatNow({ reason: `hook:${jobId}` });
|
||||
}
|
||||
} catch (err) {
|
||||
logHooks.warn(`hook agent failed: ${String(err)}`);
|
||||
enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`, {
|
||||
sessionKey: mainSessionKey,
|
||||
});
|
||||
if (value.wakeMode === "now") {
|
||||
requestHeartbeatNow({ reason: `hook:${jobId}:error` });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return runId;
|
||||
};
|
||||
|
||||
return createHooksRequestHandler({
|
||||
getHooksConfig,
|
||||
bindHost,
|
||||
port,
|
||||
logHooks,
|
||||
dispatchAgentHook,
|
||||
dispatchWakeHook,
|
||||
});
|
||||
}
|
||||
38
src/gateway/server/http-listen.ts
Normal file
38
src/gateway/server/http-listen.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
|
||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||
|
||||
export async function listenGatewayHttpServer(params: {
|
||||
httpServer: HttpServer;
|
||||
bindHost: string;
|
||||
port: number;
|
||||
}) {
|
||||
const { httpServer, bindHost, port } = params;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
httpServer.listen(port, bindHost);
|
||||
});
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "EADDRINUSE") {
|
||||
throw new GatewayLockError(
|
||||
`another gateway instance is already listening on ws://${bindHost}:${port}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
throw new GatewayLockError(
|
||||
`failed to bind gateway socket on ws://${bindHost}:${port}: ${String(err)}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
270
src/gateway/server/ws-connection.ts
Normal file
270
src/gateway/server/ws-connection.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { WebSocket, WebSocketServer } from "ws";
|
||||
import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js";
|
||||
import {
|
||||
listSystemPresence,
|
||||
upsertPresence,
|
||||
} from "../../infra/system-presence.js";
|
||||
import type { createSubsystemLogger } from "../../logging.js";
|
||||
import { isWebchatClient } from "../../utils/message-channel.js";
|
||||
|
||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||
import { isLoopbackAddress } from "../net.js";
|
||||
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
|
||||
import type {
|
||||
GatewayRequestContext,
|
||||
GatewayRequestHandlers,
|
||||
} from "../server-methods/types.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import { logWs } from "../ws-log.js";
|
||||
import {
|
||||
getHealthVersion,
|
||||
getPresenceVersion,
|
||||
incrementPresenceVersion,
|
||||
} from "./health-state.js";
|
||||
import { attachGatewayWsMessageHandler } from "./ws-connection/message-handler.js";
|
||||
import type { GatewayWsClient } from "./ws-types.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export function attachGatewayWsConnectionHandler(params: {
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
port: number;
|
||||
bridgeHost?: string;
|
||||
canvasHostEnabled: boolean;
|
||||
canvasHostServerPort?: number;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
gatewayMethods: string[];
|
||||
events: string[];
|
||||
logGateway: SubsystemLogger;
|
||||
logHealth: SubsystemLogger;
|
||||
logWsControl: SubsystemLogger;
|
||||
extraHandlers: GatewayRequestHandlers;
|
||||
broadcast: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
buildRequestContext: () => GatewayRequestContext;
|
||||
}) {
|
||||
const {
|
||||
wss,
|
||||
clients,
|
||||
port,
|
||||
bridgeHost,
|
||||
canvasHostEnabled,
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
gatewayMethods,
|
||||
events,
|
||||
logGateway,
|
||||
logHealth,
|
||||
logWsControl,
|
||||
extraHandlers,
|
||||
broadcast,
|
||||
buildRequestContext,
|
||||
} = params;
|
||||
|
||||
wss.on("connection", (socket, upgradeReq) => {
|
||||
let client: GatewayWsClient | null = null;
|
||||
let closed = false;
|
||||
const openedAt = Date.now();
|
||||
const connId = randomUUID();
|
||||
const remoteAddr = (
|
||||
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
||||
)._socket?.remoteAddress;
|
||||
const headerValue = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
const requestHost = headerValue(upgradeReq.headers.host);
|
||||
const requestOrigin = headerValue(upgradeReq.headers.origin);
|
||||
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
|
||||
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
||||
|
||||
const canvasHostPortForWs =
|
||||
canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
||||
const canvasHostOverride =
|
||||
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
|
||||
? bridgeHost
|
||||
: undefined;
|
||||
const canvasHostUrl = resolveCanvasHostUrl({
|
||||
canvasPort: canvasHostPortForWs,
|
||||
hostOverride: canvasHostServerPort ? canvasHostOverride : undefined,
|
||||
requestHost: upgradeReq.headers.host,
|
||||
forwardedProto: upgradeReq.headers["x-forwarded-proto"],
|
||||
localAddress: upgradeReq.socket?.localAddress,
|
||||
});
|
||||
|
||||
logWs("in", "open", { connId, remoteAddr });
|
||||
let handshakeState: "pending" | "connected" | "failed" = "pending";
|
||||
let closeCause: string | undefined;
|
||||
let closeMeta: Record<string, unknown> = {};
|
||||
let lastFrameType: string | undefined;
|
||||
let lastFrameMethod: string | undefined;
|
||||
let lastFrameId: string | undefined;
|
||||
|
||||
const setCloseCause = (cause: string, meta?: Record<string, unknown>) => {
|
||||
if (!closeCause) closeCause = cause;
|
||||
if (meta && Object.keys(meta).length > 0) {
|
||||
closeMeta = { ...closeMeta, ...meta };
|
||||
}
|
||||
};
|
||||
|
||||
const setLastFrameMeta = (meta: {
|
||||
type?: string;
|
||||
method?: string;
|
||||
id?: string;
|
||||
}) => {
|
||||
if (meta.type || meta.method || meta.id) {
|
||||
lastFrameType = meta.type ?? lastFrameType;
|
||||
lastFrameMethod = meta.method ?? lastFrameMethod;
|
||||
lastFrameId = meta.id ?? lastFrameId;
|
||||
}
|
||||
};
|
||||
|
||||
const send = (obj: unknown) => {
|
||||
try {
|
||||
socket.send(JSON.stringify(obj));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const close = (code = 1000, reason?: string) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(handshakeTimer);
|
||||
if (client) clients.delete(client);
|
||||
try {
|
||||
socket.close(code, reason);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
socket.once("error", (err) => {
|
||||
logWsControl.warn(
|
||||
`error conn=${connId} remote=${remoteAddr ?? "?"}: ${formatError(err)}`,
|
||||
);
|
||||
close();
|
||||
});
|
||||
|
||||
const isNoisySwiftPmHelperClose = (
|
||||
userAgent: string | undefined,
|
||||
remote: string | undefined,
|
||||
) =>
|
||||
Boolean(
|
||||
userAgent?.toLowerCase().includes("swiftpm-testing-helper") &&
|
||||
isLoopbackAddress(remote),
|
||||
);
|
||||
|
||||
socket.once("close", (code, reason) => {
|
||||
const durationMs = Date.now() - openedAt;
|
||||
const closeContext = {
|
||||
cause: closeCause,
|
||||
handshake: handshakeState,
|
||||
durationMs,
|
||||
lastFrameType,
|
||||
lastFrameMethod,
|
||||
lastFrameId,
|
||||
host: requestHost,
|
||||
origin: requestOrigin,
|
||||
userAgent: requestUserAgent,
|
||||
forwardedFor,
|
||||
...closeMeta,
|
||||
};
|
||||
if (!client) {
|
||||
const logFn = isNoisySwiftPmHelperClose(requestUserAgent, remoteAddr)
|
||||
? logWsControl.debug
|
||||
: logWsControl.warn;
|
||||
logFn(
|
||||
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} fwd=${forwardedFor ?? "n/a"} origin=${requestOrigin ?? "n/a"} host=${requestHost ?? "n/a"} ua=${requestUserAgent ?? "n/a"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
|
||||
closeContext,
|
||||
);
|
||||
}
|
||||
if (client && isWebchatClient(client.connect.client)) {
|
||||
logWsControl.info(
|
||||
`webchat disconnected code=${code} reason=${reason?.toString() || "n/a"} conn=${connId}`,
|
||||
);
|
||||
}
|
||||
if (client?.presenceKey) {
|
||||
upsertPresence(client.presenceKey, { reason: "disconnect" });
|
||||
incrementPresenceVersion();
|
||||
broadcast(
|
||||
"presence",
|
||||
{ presence: listSystemPresence() },
|
||||
{
|
||||
dropIfSlow: true,
|
||||
stateVersion: {
|
||||
presence: getPresenceVersion(),
|
||||
health: getHealthVersion(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
logWs("out", "close", {
|
||||
connId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
durationMs,
|
||||
cause: closeCause,
|
||||
handshake: handshakeState,
|
||||
lastFrameType,
|
||||
lastFrameMethod,
|
||||
lastFrameId,
|
||||
});
|
||||
close();
|
||||
});
|
||||
|
||||
const handshakeTimer = setTimeout(() => {
|
||||
if (!client) {
|
||||
handshakeState = "failed";
|
||||
setCloseCause("handshake-timeout", {
|
||||
handshakeMs: Date.now() - openedAt,
|
||||
});
|
||||
logWsControl.warn(
|
||||
`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`,
|
||||
);
|
||||
close();
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
attachGatewayWsMessageHandler({
|
||||
socket,
|
||||
upgradeReq,
|
||||
connId,
|
||||
remoteAddr,
|
||||
forwardedFor,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
requestUserAgent,
|
||||
canvasHostUrl,
|
||||
resolvedAuth,
|
||||
gatewayMethods,
|
||||
events,
|
||||
extraHandlers,
|
||||
buildRequestContext,
|
||||
send,
|
||||
close,
|
||||
isClosed: () => closed,
|
||||
clearHandshakeTimer: () => clearTimeout(handshakeTimer),
|
||||
getClient: () => client,
|
||||
setClient: (next) => {
|
||||
client = next;
|
||||
clients.add(next);
|
||||
},
|
||||
setHandshakeState: (next) => {
|
||||
handshakeState = next;
|
||||
},
|
||||
setCloseCause,
|
||||
setLastFrameMeta,
|
||||
logGateway,
|
||||
logHealth,
|
||||
logWsControl,
|
||||
});
|
||||
});
|
||||
}
|
||||
412
src/gateway/server/ws-connection/message-handler.ts
Normal file
412
src/gateway/server/ws-connection/message-handler.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
|
||||
import type { WebSocket } from "ws";
|
||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging.js";
|
||||
import {
|
||||
isGatewayCliClient,
|
||||
isWebchatClient,
|
||||
} from "../../../utils/message-channel.js";
|
||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { authorizeGatewayConnect } from "../../auth.js";
|
||||
import { isLoopbackAddress } from "../../net.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
validateConnectParams,
|
||||
validateRequestFrame,
|
||||
} from "../../protocol/index.js";
|
||||
import {
|
||||
MAX_BUFFERED_BYTES,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
TICK_INTERVAL_MS,
|
||||
} from "../../server-constants.js";
|
||||
import type {
|
||||
GatewayRequestContext,
|
||||
GatewayRequestHandlers,
|
||||
} from "../../server-methods/types.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
import { formatError } from "../../server-utils.js";
|
||||
import { formatForLog, logWs } from "../../ws-log.js";
|
||||
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
import {
|
||||
buildGatewaySnapshot,
|
||||
getHealthCache,
|
||||
getHealthVersion,
|
||||
incrementPresenceVersion,
|
||||
refreshGatewayHealthSnapshot,
|
||||
} from "../health-state.js";
|
||||
import type { GatewayWsClient } from "../ws-types.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export function attachGatewayWsMessageHandler(params: {
|
||||
socket: WebSocket;
|
||||
upgradeReq: IncomingMessage;
|
||||
connId: string;
|
||||
remoteAddr?: string;
|
||||
forwardedFor?: string;
|
||||
requestHost?: string;
|
||||
requestOrigin?: string;
|
||||
requestUserAgent?: string;
|
||||
canvasHostUrl?: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
gatewayMethods: string[];
|
||||
events: string[];
|
||||
extraHandlers: GatewayRequestHandlers;
|
||||
buildRequestContext: () => GatewayRequestContext;
|
||||
send: (obj: unknown) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
isClosed: () => boolean;
|
||||
clearHandshakeTimer: () => void;
|
||||
getClient: () => GatewayWsClient | null;
|
||||
setClient: (next: GatewayWsClient) => void;
|
||||
setHandshakeState: (state: "pending" | "connected" | "failed") => void;
|
||||
setCloseCause: (cause: string, meta?: Record<string, unknown>) => void;
|
||||
setLastFrameMeta: (meta: {
|
||||
type?: string;
|
||||
method?: string;
|
||||
id?: string;
|
||||
}) => void;
|
||||
logGateway: SubsystemLogger;
|
||||
logHealth: SubsystemLogger;
|
||||
logWsControl: SubsystemLogger;
|
||||
}) {
|
||||
const {
|
||||
socket,
|
||||
upgradeReq,
|
||||
connId,
|
||||
remoteAddr,
|
||||
forwardedFor,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
requestUserAgent,
|
||||
canvasHostUrl,
|
||||
resolvedAuth,
|
||||
gatewayMethods,
|
||||
events,
|
||||
extraHandlers,
|
||||
buildRequestContext,
|
||||
send,
|
||||
close,
|
||||
isClosed,
|
||||
clearHandshakeTimer,
|
||||
getClient,
|
||||
setClient,
|
||||
setHandshakeState,
|
||||
setCloseCause,
|
||||
setLastFrameMeta,
|
||||
logGateway,
|
||||
logHealth,
|
||||
logWsControl,
|
||||
} = params;
|
||||
|
||||
const isWebchatConnect = (p: ConnectParams | null | undefined) =>
|
||||
isWebchatClient(p?.client);
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
if (isClosed()) return;
|
||||
const text = rawDataToString(data);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const frameType =
|
||||
parsed && typeof parsed === "object" && "type" in parsed
|
||||
? typeof (parsed as { type?: unknown }).type === "string"
|
||||
? String((parsed as { type?: unknown }).type)
|
||||
: undefined
|
||||
: undefined;
|
||||
const frameMethod =
|
||||
parsed && typeof parsed === "object" && "method" in parsed
|
||||
? typeof (parsed as { method?: unknown }).method === "string"
|
||||
? String((parsed as { method?: unknown }).method)
|
||||
: undefined
|
||||
: undefined;
|
||||
const frameId =
|
||||
parsed && typeof parsed === "object" && "id" in parsed
|
||||
? typeof (parsed as { id?: unknown }).id === "string"
|
||||
? String((parsed as { id?: unknown }).id)
|
||||
: undefined
|
||||
: undefined;
|
||||
if (frameType || frameMethod || frameId) {
|
||||
setLastFrameMeta({ type: frameType, method: frameMethod, id: frameId });
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
// Handshake must be a normal request:
|
||||
// { type:"req", method:"connect", params: ConnectParams }.
|
||||
const isRequestFrame = validateRequestFrame(parsed);
|
||||
if (
|
||||
!isRequestFrame ||
|
||||
(parsed as RequestFrame).method !== "connect" ||
|
||||
!validateConnectParams((parsed as RequestFrame).params)
|
||||
) {
|
||||
const handshakeError = isRequestFrame
|
||||
? (parsed as RequestFrame).method === "connect"
|
||||
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
|
||||
: "invalid handshake: first request must be connect"
|
||||
: "invalid request frame";
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("invalid-handshake", {
|
||||
frameType,
|
||||
frameMethod,
|
||||
frameId,
|
||||
handshakeError,
|
||||
});
|
||||
if (isRequestFrame) {
|
||||
const req = parsed as RequestFrame;
|
||||
send({
|
||||
type: "res",
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, handshakeError),
|
||||
});
|
||||
} else {
|
||||
logWsControl.warn(
|
||||
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"} fwd=${forwardedFor ?? "n/a"} origin=${requestOrigin ?? "n/a"} host=${requestHost ?? "n/a"} ua=${requestUserAgent ?? "n/a"}`,
|
||||
);
|
||||
}
|
||||
const closeReason = truncateCloseReason(
|
||||
handshakeError || "invalid handshake",
|
||||
);
|
||||
if (isRequestFrame) {
|
||||
queueMicrotask(() => close(1008, closeReason));
|
||||
} else {
|
||||
close(1008, closeReason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = parsed as RequestFrame;
|
||||
const connectParams = frame.params as ConnectParams;
|
||||
const clientLabel =
|
||||
connectParams.client.displayName ?? connectParams.client.id;
|
||||
|
||||
// protocol negotiation
|
||||
const { minProtocol, maxProtocol } = connectParams;
|
||||
if (maxProtocol < PROTOCOL_VERSION || minProtocol > PROTOCOL_VERSION) {
|
||||
setHandshakeState("failed");
|
||||
logWsControl.warn(
|
||||
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||
);
|
||||
setCloseCause("protocol-mismatch", {
|
||||
minProtocol,
|
||||
maxProtocol,
|
||||
expectedProtocol: PROTOCOL_VERSION,
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "protocol mismatch", {
|
||||
details: { expectedProtocol: PROTOCOL_VERSION },
|
||||
}),
|
||||
});
|
||||
close(1002, "protocol mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await authorizeGatewayConnect({
|
||||
auth: resolvedAuth,
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
setHandshakeState("failed");
|
||||
logWsControl.warn(
|
||||
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||
);
|
||||
const authProvided = connectParams.auth?.token
|
||||
? "token"
|
||||
: connectParams.auth?.password
|
||||
? "password"
|
||||
: "none";
|
||||
setCloseCause("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
authReason: authResult.reason,
|
||||
allowTailscale: resolvedAuth.allowTailscale,
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
||||
});
|
||||
close(1008, "unauthorized");
|
||||
return;
|
||||
}
|
||||
const authMethod = authResult.method ?? "none";
|
||||
|
||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||
const clientId = connectParams.client.id;
|
||||
const instanceId = connectParams.client.instanceId;
|
||||
const presenceKey = shouldTrackPresence
|
||||
? (instanceId ?? connId)
|
||||
: undefined;
|
||||
|
||||
logWs("in", "connect", {
|
||||
connId,
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
version: connectParams.client.version,
|
||||
mode: connectParams.client.mode,
|
||||
clientId,
|
||||
platform: connectParams.client.platform,
|
||||
auth: authMethod,
|
||||
});
|
||||
|
||||
if (isWebchatConnect(connectParams)) {
|
||||
logWsControl.info(
|
||||
`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (presenceKey) {
|
||||
upsertPresence(presenceKey, {
|
||||
host:
|
||||
connectParams.client.displayName ??
|
||||
connectParams.client.id ??
|
||||
os.hostname(),
|
||||
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
|
||||
version: connectParams.client.version,
|
||||
platform: connectParams.client.platform,
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
modelIdentifier: connectParams.client.modelIdentifier,
|
||||
mode: connectParams.client.mode,
|
||||
instanceId,
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
}
|
||||
|
||||
const snapshot = buildGatewaySnapshot();
|
||||
const cachedHealth = getHealthCache();
|
||||
if (cachedHealth) {
|
||||
snapshot.health = cachedHealth;
|
||||
snapshot.stateVersion.health = getHealthVersion();
|
||||
}
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
server: {
|
||||
version:
|
||||
process.env.CLAWDBOT_VERSION ??
|
||||
process.env.npm_package_version ??
|
||||
"dev",
|
||||
commit: process.env.GIT_COMMIT,
|
||||
host: os.hostname(),
|
||||
connId,
|
||||
},
|
||||
features: { methods: gatewayMethods, events },
|
||||
snapshot,
|
||||
canvasHostUrl,
|
||||
policy: {
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||
tickIntervalMs: TICK_INTERVAL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
clearHandshakeTimer();
|
||||
const nextClient: GatewayWsClient = {
|
||||
socket,
|
||||
connect: connectParams,
|
||||
connId,
|
||||
presenceKey,
|
||||
};
|
||||
setClient(nextClient);
|
||||
setHandshakeState("connected");
|
||||
|
||||
logWs("out", "hello-ok", {
|
||||
connId,
|
||||
methods: gatewayMethods.length,
|
||||
events: events.length,
|
||||
presence: snapshot.presence.length,
|
||||
stateVersion: snapshot.stateVersion.presence,
|
||||
});
|
||||
|
||||
send({ type: "res", id: frame.id, ok: true, payload: helloOk });
|
||||
void refreshGatewayHealthSnapshot({ probe: true }).catch((err) =>
|
||||
logHealth.error(
|
||||
`post-connect health refresh failed: ${formatError(err)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// After handshake, accept only req frames
|
||||
if (!validateRequestFrame(parsed)) {
|
||||
send({
|
||||
type: "res",
|
||||
id: (parsed as { id?: unknown })?.id ?? "invalid",
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid request frame: ${formatValidationErrors(validateRequestFrame.errors)}`,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const req = parsed as RequestFrame;
|
||||
logWs("in", "req", { connId, id: req.id, method: req.method });
|
||||
const respond = (
|
||||
ok: boolean,
|
||||
payload?: unknown,
|
||||
error?: ErrorShape,
|
||||
meta?: Record<string, unknown>,
|
||||
) => {
|
||||
send({ type: "res", id: req.id, ok, payload, error });
|
||||
logWs("out", "res", {
|
||||
connId,
|
||||
id: req.id,
|
||||
ok,
|
||||
method: req.method,
|
||||
errorCode: error?.code,
|
||||
errorMessage: error?.message,
|
||||
...meta,
|
||||
});
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
await handleGatewayRequest({
|
||||
req,
|
||||
respond,
|
||||
client,
|
||||
isWebchatConnect,
|
||||
extraHandlers,
|
||||
context: buildRequestContext(),
|
||||
});
|
||||
})().catch((err) => {
|
||||
logGateway.error(`request handler failed: ${formatForLog(err)}`);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
logGateway.error(`parse/handle error: ${String(err)}`);
|
||||
logWs("out", "parse-error", { connId, error: formatForLog(err) });
|
||||
if (!getClient()) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
10
src/gateway/server/ws-types.ts
Normal file
10
src/gateway/server/ws-types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
import type { ConnectParams } from "../protocol/index.js";
|
||||
|
||||
export type GatewayWsClient = {
|
||||
socket: WebSocket;
|
||||
connect: ConnectParams;
|
||||
connId: string;
|
||||
presenceKey?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user