split: sync-client services layer
Add build-vault-url helper. Rewrite fetch-controller and websocket-manager (plus their tests). Update server-config and sync-service to consume the new error types and the regenerated API types from previous chunks.
This commit is contained in:
parent
9d99a4ac23
commit
45b86cffe4
7 changed files with 377 additions and 232 deletions
|
|
@ -4,12 +4,15 @@ import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
|
|||
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
|
||||
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
|
||||
import type { ClientCursors } from "./types/ClientCursors";
|
||||
import { createPromise } from "../utils/create-promise";
|
||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
|
||||
import {
|
||||
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
|
||||
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
|
||||
} from "../consts";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
import { buildVaultUrl } from "./build-vault-url";
|
||||
|
||||
export class WebSocketManager {
|
||||
public readonly onWebSocketStatusChanged = new EventListeners<
|
||||
|
|
@ -26,32 +29,22 @@ export class WebSocketManager {
|
|||
|
||||
private isStopped = true;
|
||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||
private stopPromise: Promise<void> | null = null;
|
||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||
|
||||
private webSocket: WebSocket | undefined;
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly settings: Settings,
|
||||
webSocketImplementation?: typeof globalThis.WebSocket
|
||||
) {
|
||||
if (webSocketImplementation) {
|
||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
||||
} else {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.WebSocket === "undefined"
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
||||
} else {
|
||||
this.webSocketFactoryImplementation = WebSocket;
|
||||
}
|
||||
}
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket
|
||||
) {}
|
||||
|
||||
public get hasOutstandingWork(): boolean {
|
||||
return this.outstandingPromises.length > 0;
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
|
|
@ -67,49 +60,14 @@ export class WebSocketManager {
|
|||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const [promise, resolve] = createPromise();
|
||||
this.resolveDisconnectingPromise = resolve;
|
||||
|
||||
this.isStopped = true;
|
||||
|
||||
if (this.reconnectTimeoutId !== undefined) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
|
||||
)
|
||||
);
|
||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
|
||||
// Concurrent callers (e.g. destroy() and onSettingsChange) must share
|
||||
// the same disconnect; otherwise the second call would overwrite
|
||||
// resolveDisconnectingPromise and strand the first caller's await
|
||||
// until the timeout rejects.
|
||||
this.stopPromise ??= this.performStop().finally(() => {
|
||||
this.stopPromise = null;
|
||||
});
|
||||
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
await Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error while waiting for WebSocket to close: ${String(error)}`
|
||||
);
|
||||
// Force cleanup even if close didn't work
|
||||
this.resolveDisconnectingPromise();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} finally {
|
||||
// Clear timeout to prevent unhandled rejection
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitUntilFinished();
|
||||
await this.stopPromise;
|
||||
}
|
||||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
|
|
@ -162,6 +120,59 @@ export class WebSocketManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async performStop(): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>();
|
||||
this.resolveDisconnectingPromise = (): void => {
|
||||
resolve(undefined);
|
||||
};
|
||||
|
||||
this.isStopped = true;
|
||||
|
||||
if (this.reconnectTimeoutId !== undefined) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds`
|
||||
)
|
||||
);
|
||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000);
|
||||
});
|
||||
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
await Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error while waiting for WebSocket to close: ${String(error)}`
|
||||
);
|
||||
// Force cleanup even if close didn't work
|
||||
this.resolveDisconnectingPromise();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} finally {
|
||||
// Clear timeout to prevent unhandled rejection
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitUntilFinished();
|
||||
}
|
||||
|
||||
private initializeWebSocket(): void {
|
||||
// Clean up old WebSocket handlers to prevent race conditions
|
||||
if (this.webSocket) {
|
||||
|
|
@ -171,26 +182,55 @@ export class WebSocketManager {
|
|||
this.webSocket.onclose = null;
|
||||
this.webSocket.onmessage = null;
|
||||
this.webSocket.onerror = null;
|
||||
this.webSocket.close();
|
||||
this.webSocket.close(
|
||||
1000,
|
||||
"Closing previous WebSocket connection"
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to close previous WebSocket connection: ${e}`
|
||||
);
|
||||
}
|
||||
// Abandon any outstanding handler promises from the previous
|
||||
// connection. They'll still resolve in the background, but we
|
||||
// no longer want `waitUntilFinished` / `stop` to block on
|
||||
// post-reconnect state — and we definitely don't want their
|
||||
// results applied against a now-stale socket.
|
||||
this.outstandingPromises.length = 0;
|
||||
}
|
||||
|
||||
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
||||
// Build the WS URL through the same vault-URL helper the HTTP client
|
||||
// uses so vault-name encoding, trailing-slash stripping, and any path
|
||||
// prefix in `remoteUri` stay in sync between transports.
|
||||
const wsUri = new URL(buildVaultUrl(this.settings, "/ws"));
|
||||
wsUri.protocol = wsUri.protocol.startsWith("https") ? "wss" : "ws";
|
||||
|
||||
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
||||
|
||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||
const ws = new this.webSocketFactoryImplementation(wsUri);
|
||||
this.webSocket = ws;
|
||||
|
||||
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open.
|
||||
// The callback closes the *captured* `ws` rather than `this.webSocket` so a delayed timeout cannot
|
||||
// accidentally close a freshly-constructed replacement socket. (Closing the already-closed `ws` is a no-op.)
|
||||
this.connectionTimeoutId = setTimeout(() => {
|
||||
this.connectionTimeoutId = undefined;
|
||||
this.logger.warn(
|
||||
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
|
||||
);
|
||||
// Force close to trigger onclose handler which will schedule reconnection
|
||||
ws.close(1000, "Connection timeout");
|
||||
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
|
||||
|
||||
ws.onopen = (): void => {
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket.onopen = (): void => {
|
||||
// Check if we've been stopped while connecting
|
||||
if (this.isStopped) {
|
||||
this.webSocket?.close(
|
||||
ws.close(
|
||||
1000,
|
||||
"WebSocketManager was stopped during connection"
|
||||
);
|
||||
|
|
@ -200,7 +240,7 @@ export class WebSocketManager {
|
|||
this.onWebSocketStatusChanged.trigger(true);
|
||||
};
|
||||
|
||||
this.webSocket.onmessage = (event): void => {
|
||||
ws.onmessage = (event): void => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = JSON.parse(
|
||||
|
|
@ -231,7 +271,18 @@ export class WebSocketManager {
|
|||
}
|
||||
};
|
||||
|
||||
this.webSocket.onclose = (event): void => {
|
||||
ws.onerror = (error): void => {
|
||||
this.logger.warn(
|
||||
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
|
||||
);
|
||||
};
|
||||
|
||||
ws.onclose = (event): void => {
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||
);
|
||||
|
|
@ -241,10 +292,13 @@ export class WebSocketManager {
|
|||
this.resolveDisconnectingPromise?.();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} else {
|
||||
const delay =
|
||||
this.settings.getSettings().webSocketRetryIntervalMs;
|
||||
this.logger.info(`Reconnecting to WebSocket in ${delay}ms...`);
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimeoutId = undefined;
|
||||
this.initializeWebSocket();
|
||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -252,22 +306,22 @@ export class WebSocketManager {
|
|||
private async handleWebSocketMessage(
|
||||
message: WebSocketServerMessage
|
||||
): Promise<void> {
|
||||
if (message.type === "vaultUpdate") {
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if (message.type === "cursorPositions") {
|
||||
this.logger.debug(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
);
|
||||
|
||||
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||
message.clients
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Received unknown message type: ${JSON.stringify(message)}`
|
||||
);
|
||||
switch (message.type) {
|
||||
case "vaultUpdate":
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
return;
|
||||
case "cursorPositions":
|
||||
this.logger.debug(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
);
|
||||
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||
message.clients
|
||||
);
|
||||
return;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Received unknown message type: ${JSON.stringify(message)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue