import type { BuildEvents } from "#Source/event/index.ts" import { EventManager } from "#Source/event/index.ts" import type { LoggerFriendly, LoggerFriendlyOptions } from "#Source/log/index.ts" import { Logger } from "#Source/log/index.ts" import type { SocketUnitBaseSnapshot, SocketUnitLifecycleState, SocketUnitHeartbeatOptions, SocketUnitHeartbeatSnapshot, } from "../common/index.ts" import { SocketUnitHeartbeat } from "../common/index.ts" /** * @description 表示客户端 SocketUnit 的传输状态。 */ export type SocketUnitStatus = "UNINSTANTIATED" | "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED" /** * @description 表示客户端 SocketUnit 对外派发的事件表。 */ export type SocketUnitEvents = BuildEvents<{ status: (status: SocketUnitStatus) => void message: (message: Message) => void }> /** * @description 表示客户端 SocketUnit 的诊断快照。 */ export interface SocketUnitSnapshot extends SocketUnitBaseSnapshot< string | undefined, SocketUnitStatus, SocketUnitHeartbeatSnapshot > { adapterState: { kind: "client" } } /** * @description 表示客户端初始消息的构造器。 */ export type InitialMessageBuilder = (clientId: string) => Message /** * @description 表示客户端 SocketUnit 的构造参数。 */ export interface SocketUnitOptions extends LoggerFriendlyOptions { url: string /** * @description 是否启用初始消息。 * * 初始消息遵循与普通业务消息一致的 ready 语义: * 只有在连接已打开且 clientId 已就绪后才会发送。 * * @default false */ enableInitialMessage?: boolean | undefined initialMessageBuilder?: InitialMessageBuilder | undefined heartbeat?: SocketUnitHeartbeatOptions | undefined } /** * @description 验证客户端 SocketUnit 配置是否合法。 */ export const validateSocketUnitOptions = (options: SocketUnitOptions): void => { if (options.enableInitialMessage === true && options.initialMessageBuilder === undefined) { throw new Error("Initialize message builder is required when enable initial message.") } } /** * @description 表示补齐默认值后的客户端 SocketUnit 配置。 */ export interface ResolvedSocketUnitOptions extends LoggerFriendlyOptions { url: string enableInitialMessage: boolean initialMessageBuilder?: InitialMessageBuilder | undefined heartbeat: SocketUnitHeartbeatOptions } /** * @description 为客户端 SocketUnit 配置补齐默认值。 */ export const resolveSocketUnitOptions = ( options: SocketUnitOptions, ): ResolvedSocketUnitOptions => { return { ...options, url: options.url, enableInitialMessage: options.enableInitialMessage ?? false, initialMessageBuilder: options.initialMessageBuilder, heartbeat: options.heartbeat ?? {}, } } interface Action { action: () => void } /** * @description 关于心跳: * 心跳只会在正常连接时工作,目的是保持连接的活跃状态,避免被服务器断开。 * 连接断开之后,心跳会停止工作,直到连接重新建立。 */ export class SocketUnit implements LoggerFriendly { protected readonly options: ResolvedSocketUnitOptions readonly logger: Logger protected heartbeat: SocketUnitHeartbeat protected clientId: string | undefined protected webSocket: WebSocket | null protected status: SocketUnitStatus protected actionQueue: Action[] protected runtimeWebSocketListenerCleanup: (() => void) | undefined protected openSettlementListenerCleanup: (() => void) | undefined eventManager: EventManager> constructor(options: SocketUnitOptions) { validateSocketUnitOptions(options) this.options = resolveSocketUnitOptions(options) this.logger = Logger.fromOptions(options).setDefaultName("SocketUnit") this.heartbeat = new SocketUnitHeartbeat( { ...this.options.heartbeat, logger: this.logger.derive().setName("SocketUnitHeartbeat"), }, { sendMessage: (message) => { return this.safeSendMessage(message) }, close: () => { void this.close() }, }, ) this.clientId = undefined this.webSocket = null this.status = "UNINSTANTIATED" this.actionQueue = [] this.runtimeWebSocketListenerCleanup = undefined this.openSettlementListenerCleanup = undefined this.eventManager = new EventManager>() } /** * @description 返回当前 SocketUnit 的状态。 */ getStatus(): SocketUnitStatus { return this.status } /** * @description 返回当前 SocketUnit 的快照。 */ getSnapshot(): SocketUnitSnapshot { const heartbeatSnapshot: SocketUnitHeartbeatSnapshot = this.heartbeat.getSnapshot() return { status: this.status, clientId: this.clientId, lifecycleState: this.getLifecycleState(), isReadyToWork: this.isReadyToWork(), hasTransport: this.webSocket !== null, pendingActionCount: this.actionQueue.length, transportOwnership: "OWNED", heartbeat: heartbeatSnapshot, adapterState: { kind: "client", }, } } protected getLifecycleState(): SocketUnitLifecycleState { if (this.status === "UNINSTANTIATED") { return "IDLE" } if (this.status === "CLOSED") { return "CLOSED" } return "RUNNING" } protected mutateRuntimeState(mutator: () => void): void { const previousStatus = this.status const wasReadyToWork = this.isReadyToWork() mutator() const nextStatus = this.status const isReadyToWork = this.isReadyToWork() this.handleRuntimeStateChange(previousStatus, nextStatus, wasReadyToWork, isReadyToWork) } protected handleRuntimeStateChange( previousStatus: SocketUnitStatus, nextStatus: SocketUnitStatus, wasReadyToWork: boolean, isReadyToWork: boolean, ): void { if ( (previousStatus !== nextStatus && nextStatus !== "OPEN") || (wasReadyToWork === true && isReadyToWork === false) ) { this.heartbeat.stop() } if (previousStatus !== nextStatus) { this.eventManager.emit("status", nextStatus) } if (wasReadyToWork === false && isReadyToWork === true) { this.triggerReadyToWork() } } /** * @description 从 WebSocket 实例计算状态。 */ protected calculateStatus(): SocketUnitStatus { if (this.webSocket === null) { return "UNINSTANTIATED" } const readyStateToStatusMap: Record = { [WebSocket.CONNECTING]: "CONNECTING", [WebSocket.OPEN]: "OPEN", [WebSocket.CLOSING]: "CLOSING", [WebSocket.CLOSED]: "CLOSED", } const status = readyStateToStatusMap[this.webSocket.readyState] if (status === undefined) { throw new Error(`Unexpected readyState: ${this.webSocket.readyState}`) } return status } /** * @description [sugar method] 自动执行 {@link calculateStatus} 并使用计算结果设置状态。 */ protected updateStatus(): SocketUnitStatus { const status = this.calculateStatus() this.mutateRuntimeState(() => { this.status = status }) return status } /** * @description 设置当前 SocketUnit 绑定的 clientId。 */ setClientId(clientId: string): void { this.mutateRuntimeState(() => { this.clientId = clientId }) } /** * @description [sugar method] 设置 WebSocket 实例并调用 {@link updateStatus} 更新状态。 */ protected setWebSocket(ws: WebSocket | null): void { this.mutateRuntimeState(() => { this.webSocket = ws this.status = this.calculateStatus() }) } protected replaceRuntimeWebSocketListenerCleanup(cleanup: () => void): void { this.removeRuntimeWebSocketListeners() this.runtimeWebSocketListenerCleanup = cleanup } protected removeRuntimeWebSocketListeners(): void { const cleanup = this.runtimeWebSocketListenerCleanup this.runtimeWebSocketListenerCleanup = undefined cleanup?.() } protected replaceOpenSettlementListenerCleanup(cleanup: () => void): void { this.removeOpenSettlementListeners() this.openSettlementListenerCleanup = cleanup } protected removeOpenSettlementListeners(): void { const cleanup = this.openSettlementListenerCleanup this.openSettlementListenerCleanup = undefined cleanup?.() } protected removeWebSocketListeners(): void { this.removeOpenSettlementListeners() this.removeRuntimeWebSocketListeners() } protected clearPendingActions(): void { this.actionQueue = [] } protected pushToQueueOrExecute(action: Action): void { if (this.isReadyToWork() === true) { action.action() } else { this.actionQueue.push(action) } } protected executeActionQueue(): void { ;[...this.actionQueue].forEach((action) => { action.action() }) this.actionQueue = [] } /** * @description NOTE: Ready 之后才可以发消息。 */ protected isReadyToWork(): boolean { return this.status === "OPEN" && this.clientId !== undefined } protected triggerReadyToWork(): void { if (this.isReadyToWork() === true) { this.heartbeat.start(this.clientId!) this.executeActionQueue() } } protected cleanupConnectionResources(): void { this.heartbeat.stop() this.removeWebSocketListeners() if (this.webSocket !== null) { this.setWebSocket(null) } } protected resetRuntimeState(): void { this.cleanupConnectionResources() this.clearPendingActions() this.mutateRuntimeState(() => { this.clientId = undefined this.status = "UNINSTANTIATED" }) } /** * @description 发送一条业务消息;若尚未 ready,则先进入待发送队列。 */ sendMessage(message: Message): void { this.pushToQueueOrExecute({ action: (): void => { this.safeSendMessage({ action: "Business message send", messageString: JSON.stringify(message), }) }, }) } protected enqueueInitialMessageIfEnabled(): void { if (this.options.enableInitialMessage === true) { this.pushToQueueOrExecute({ action: () => { const clientId = this.clientId! const initialMessage = this.options.initialMessageBuilder!(clientId) const initialMessageString = JSON.stringify(initialMessage) const sendResult = this.safeSendMessage({ action: "Initial message send", messageString: initialMessageString, }) if (sendResult === true) { this.logger.log(`Initial message sent: ${clientId}, ${initialMessageString}`) } }, }) } } protected safeSendMessage(message: { action: string; messageString: string }): boolean { const webSocket = this.webSocket if (webSocket === null) { this.logger.warn(`${message.action} skipped because WebSocket is not initialized.`) return false } if (webSocket.readyState !== WebSocket.OPEN) { this.logger.warn(`${message.action} skipped because WebSocket is not open.`) return false } try { webSocket.send(message.messageString) return true } catch (exception) { this.logger.error(`${message.action} failed.`, exception) void this.close() return false } } protected parseMessage(event: MessageEvent): Message | undefined { try { const messageString = event.data as string const parsedMessage = JSON.parse(messageString) as Message return parsedMessage } catch { this.logger.error("Failed to parse message:", event.data) return undefined } } protected handleParsedMessage(parsedMessage: Message): void { const clientId = this.clientId if (clientId === undefined) { this.logger.warn("Message skipped because clientId is not ready.") return } const heartbeatHandleResult = this.heartbeat.handleMessage(clientId, parsedMessage) if (heartbeatHandleResult === true) { return } this.handleBusinessMessage(parsedMessage) } protected handleBusinessMessage(message: Message): void { this.eventManager.emit("message", message) } protected handleMessageEvent(event: MessageEvent): void { const parsedMessage = this.parseMessage(event) if (parsedMessage === undefined) { return } this.handleParsedMessage(parsedMessage) } protected readonly handleRuntimeOpen = (event: Event): void => { this.logger.log("WebSocket open", event) this.updateStatus() } protected readonly handleRuntimeMessage = (event: MessageEvent): void => { this.updateStatus() this.handleMessageEvent(event) } protected readonly handleRuntimeClose = (event: CloseEvent): void => { this.logger.log("WebSocket close", event) this.updateStatus() } protected readonly handleRuntimeError = (event: Event): void => { this.logger.error("WebSocket error", event) this.updateStatus() } protected attachRuntimeWebSocketListeners(webSocket: WebSocket): void { webSocket.addEventListener("open", this.handleRuntimeOpen) webSocket.addEventListener("message", this.handleRuntimeMessage) webSocket.addEventListener("close", this.handleRuntimeClose) webSocket.addEventListener("error", this.handleRuntimeError) this.replaceRuntimeWebSocketListenerCleanup((): void => { webSocket.removeEventListener("open", this.handleRuntimeOpen) webSocket.removeEventListener("message", this.handleRuntimeMessage) webSocket.removeEventListener("close", this.handleRuntimeClose) webSocket.removeEventListener("error", this.handleRuntimeError) }) } protected attachOpenSettlementListeners( webSocket: WebSocket, resolve: () => void, reject: (reason?: unknown) => void, ): void { let isOpenSettled = false const settleOpen = (callback: () => void): void => { if (isOpenSettled === true) { return } isOpenSettled = true this.removeOpenSettlementListeners() callback() } const openListener = (): void => { this.updateStatus() settleOpen(resolve) } const errorListener = (): void => { this.updateStatus() if (webSocket.readyState !== WebSocket.CLOSED) { return } settleOpen(() => { this.cleanupConnectionResources() reject(new Error(`Failed to open WebSocket connection: ${this.options.url}`)) }) } const closeListener = (event: CloseEvent): void => { this.updateStatus() const closeReason = event.reason.length !== 0 ? ` (${event.code}: ${event.reason})` : ` (${event.code})` settleOpen(() => { this.cleanupConnectionResources() reject(new Error(`WebSocket closed before opening${closeReason}.`)) }) } webSocket.addEventListener("open", openListener) webSocket.addEventListener("error", errorListener) webSocket.addEventListener("close", closeListener) this.replaceOpenSettlementListenerCleanup((): void => { webSocket.removeEventListener("open", openListener) webSocket.removeEventListener("error", errorListener) webSocket.removeEventListener("close", closeListener) }) } /** * @description 如果没有创建 WebSocket 实例,则创建一个 WebSocket 实例,并注册事件监听器。 * 如果已经创建了 WebSocket 实例,则什么都不做。 * 如果你希望在已经创建了 WebSocket 实例的情况下报错,可以使用 {@link safeOpen} 方法。 */ async open(): Promise { const status = this.updateStatus() if (status === "UNINSTANTIATED") { return await new Promise((resolve, reject) => { const webSocket = new WebSocket(this.options.url) this.setWebSocket(webSocket) this.attachRuntimeWebSocketListeners(webSocket) this.enqueueInitialMessageIfEnabled() this.attachOpenSettlementListeners(webSocket, resolve, reject) }) } } /** * @description {@link open} */ async safeOpen(): Promise { const status = this.updateStatus() if (status !== "UNINSTANTIATED") { throw new Error("Socket is already open") } else { await this.open() } } /** * @description 只负责关闭当前连接与释放连接级资源,保留 clientId 与待发送动作。 */ async close(): Promise { const closingWebSocket = new Promise((resolve, _reject) => { const status = this.calculateStatus() switch (status) { case "UNINSTANTIATED": { resolve() break } case "CONNECTING": { // 如果 WebSocket 处于连接中,等待连接建立后再关闭,否则会有如下报错: // WebSocket connection to 'ws://localhost:3001/' failed: WebSocket is closed before the connection is established. const webSocket = this.webSocket! const closeListener = (): void => { resolve() webSocket.removeEventListener("open", openListener) webSocket.removeEventListener("close", closeListener) } webSocket.addEventListener("close", closeListener) const openListener = (): void => { webSocket.close() } webSocket.addEventListener("open", openListener) break } case "OPEN": { const webSocket = this.webSocket! const closeListener = (): void => { resolve() webSocket.removeEventListener("close", closeListener) } webSocket.addEventListener("close", closeListener) webSocket.close() break } case "CLOSING": { const webSocket = this.webSocket! const closeListener = (): void => { resolve() webSocket.removeEventListener("close", closeListener) } webSocket.addEventListener("close", closeListener) break } case "CLOSED": { resolve() break } default: { throw new Error("Unexpected status") } } }) await closingWebSocket this.cleanupConnectionResources() } /** * @description 关闭当前连接后重建连接,保留 clientId 与待发送动作。 */ async reopen(): Promise { await this.close() await this.open() } /** * @description 清空连接级资源与运行态,将实例恢复到接近构造后的状态。 */ async reset(onReset?: () => void): Promise { await this.close() this.resetRuntimeState() onReset?.() } }