Apply editorconfig

This commit is contained in:
Andras Schmelczer 2025-12-07 13:38:23 +00:00
parent ad3191957a
commit b05e415acf
131 changed files with 16404 additions and 13617 deletions

View file

@ -1,6 +1,6 @@
export class AuthenticationError extends Error {
public constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
public constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
}

View file

@ -7,171 +7,171 @@ import { SyncResetError } from "./sync-reset-error";
import { sleep } from "../utils/sleep";
describe("FetchController", () => {
const createMockFetch = (
shouldSleep: boolean
): Mock<() => Promise<Response>> =>
mock.fn(async () => {
if (shouldSleep) {
await sleep(30);
}
return Promise.resolve(new Response("OK", { status: 200 }));
});
const createMockFetch = (
shouldSleep: boolean
): Mock<() => Promise<Response>> =>
mock.fn(async () => {
if (shouldSleep) {
await sleep(30);
}
return Promise.resolve(new Response("OK", { status: 200 }));
});
beforeEach(() => {
mock.timers.enable({ apis: ["setTimeout"] });
});
beforeEach(() => {
mock.timers.enable({ apis: ["setTimeout"] });
});
afterEach(() => {
mock.timers.reset();
});
afterEach(() => {
mock.timers.reset();
});
it("should allow fetch when canFetch is true", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should allow fetch when canFetch is true", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const response = await controlledFetch("http://example.com");
const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should block fetch until canFetch becomes true", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should block fetch until canFetch becomes true", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const fetchPromise = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 0);
const fetchPromise = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 0);
controller.canFetch = true;
await Promise.resolve();
mock.timers.tick(30);
controller.canFetch = true;
await Promise.resolve();
mock.timers.tick(30);
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should reject during reset", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should reject during reset", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const firstRequest = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 1);
const firstRequest = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 1);
controller.startReset();
controller.startReset();
const secondRequest = controlledFetch("http://example.com");
const secondRequest = controlledFetch("http://example.com");
await assert.rejects(
firstRequest,
(error: unknown) => error instanceof SyncResetError
);
await assert.rejects(
secondRequest,
(error: unknown) => error instanceof SyncResetError
);
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
await assert.rejects(
firstRequest,
(error: unknown) => error instanceof SyncResetError
);
await assert.rejects(
secondRequest,
(error: unknown) => error instanceof SyncResetError
);
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should allow fetch after reset finishes", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should allow fetch after reset finishes", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
controller.startReset();
controller.finishReset();
controller.startReset();
controller.finishReset();
const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK");
});
const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK");
});
it("should defer canFetch changes during reset", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should defer canFetch changes during reset", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
controller.startReset();
controller.canFetch = true;
controller.startReset();
controller.canFetch = true;
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) => error instanceof SyncResetError
);
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) => error instanceof SyncResetError
);
controller.finishReset();
controller.finishReset();
const fetchPromise = controlledFetch("http://example.com");
mock.timers.tick(30);
const fetchPromise = controlledFetch("http://example.com");
mock.timers.tick(30);
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
});
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
});
it("should handle different input types", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should handle different input types", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
await controlledFetch("http://example.com");
await controlledFetch(new URL("http://example.com"));
await controlledFetch(
new Request("http://example.com", { method: "POST" })
);
await controlledFetch("http://example.com");
await controlledFetch(new URL("http://example.com"));
await controlledFetch(
new Request("http://example.com", { method: "POST" })
);
assert.strictEqual(mockFetch.mock.calls.length, 3);
});
assert.strictEqual(mockFetch.mock.calls.length, 3);
});
it("should handle fetch errors", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = mock.fn(async () => {
throw new Error("Network error");
});
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
it("should handle fetch errors", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = mock.fn(async () => {
throw new Error("Network error");
});
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) =>
error instanceof Error && error.message === "Network error"
);
});
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) =>
error instanceof Error && error.message === "Network error"
);
});
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
controller.startReset();
mock.timers.tick(10);
controller.finishReset();
});
controller.startReset();
mock.timers.tick(10);
controller.finishReset();
});
});

View file

@ -7,143 +7,143 @@ import { SyncResetError } from "./sync-reset-error";
* and aborts outstanding requests when a reset is started.
*/
export class FetchController {
private static readonly UNTIL_RESOLUTION = Symbol();
private static readonly UNTIL_RESOLUTION = Symbol();
private isResetting = false;
private isResetting = false;
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
public constructor(
private _canFetch: boolean,
private readonly logger: Logger
) {
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
}
public constructor(
private _canFetch: boolean,
private readonly logger: Logger
) {
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
}
/**
* Whether the fetch implementation can immediately send requests once outside of a reset.
*/
public get canFetch(): boolean {
return this._canFetch;
}
/**
* Whether the fetch implementation can immediately send requests once outside of a reset.
*/
public get canFetch(): boolean {
return this._canFetch;
}
/**
* Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished.
*
* @param canFetch Whether fetching is enabled
*/
public set canFetch(canFetch: boolean) {
this._canFetch = canFetch;
/**
* Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished.
*
* @param canFetch Whether fetching is enabled
*/
public set canFetch(canFetch: boolean) {
this._canFetch = canFetch;
if (!this.isResetting) {
const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
previousResolve(FetchController.UNTIL_RESOLUTION);
}
}
if (!this.isResetting) {
const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
previousResolve(FetchController.UNTIL_RESOLUTION);
}
}
private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) {
return input.href;
}
if (typeof input === "string") {
return input;
}
return input.url;
}
private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) {
return input.href;
}
if (typeof input === "string") {
return input;
}
return input.url;
}
/**
* Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called.
*/
public startReset(): void {
this.isResetting = true;
this.rejectUntil(new SyncResetError());
// Catch unhandled rejection if no fetches are waiting
this.until.catch(() => {
// Intentionally ignore - this rejection is handled by waiting fetches
});
}
/**
* Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called.
*/
public startReset(): void {
this.isResetting = true;
this.rejectUntil(new SyncResetError());
// Catch unhandled rejection if no fetches are waiting
this.until.catch(() => {
// Intentionally ignore - this rejection is handled by waiting fetches
});
}
/**
* Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings.
*/
public finishReset(): void {
if (!this.isResetting) {
return;
}
/**
* Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings.
*/
public finishReset(): void {
if (!this.isResetting) {
return;
}
this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
}
this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
}
/**
*
* |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------|
*
* @param logger for errors
* @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state
*/
public getControlledFetchImplementation(
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch || this.isResetting) {
await this.until;
}
/**
*
* |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------|
*
* @param logger for errors
* @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state
*/
public getControlledFetchImplementation(
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch || this.isResetting) {
await this.until;
}
try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input =
typeof Request !== "undefined" && input instanceof Request
? input.clone()
: input;
try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input =
typeof Request !== "undefined" && input instanceof Request
? input.clone()
: input;
const fetchPromise = fetch(_input, init);
const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;
do {
result = await Promise.race([this.until, fetchPromise]);
} while (result === FetchController.UNTIL_RESOLUTION);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;
do {
result = await Promise.race([this.until, fetchPromise]);
} while (result === FetchController.UNTIL_RESOLUTION);
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if (!fetchResult.ok) {
this.logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got status ${fetchResult.status}`
);
}
if (!fetchResult.ok) {
this.logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got status ${fetchResult.status}`
);
}
return fetchResult;
} catch (error) {
logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got error: ${error}`
);
throw error;
}
};
}
return fetchResult;
} catch (error) {
logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got error: ${error}`
);
throw error;
}
};
}
}

View file

@ -5,83 +5,83 @@ import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse";
export interface ServerConfigData {
mergeableFileExtensions: string[];
supportedApiVersion: number;
isAuthenticated: boolean;
mergeableFileExtensions: string[];
supportedApiVersion: number;
isAuthenticated: boolean;
}
export class ServerConfig {
private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined;
private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined;
public constructor(private readonly syncService: SyncService) {}
public constructor(private readonly syncService: SyncService) {}
public async initialize(): Promise<void> {
this.response = this.syncService.ping();
this.config = await this.response;
public async initialize(): Promise<void> {
this.response = this.syncService.ping();
this.config = await this.response;
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient =
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError(
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
shouldUpgradeClient ? "client" : "sync-server"
} to ensure compatibility.`
);
}
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient =
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError(
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
shouldUpgradeClient ? "client" : "sync-server"
} to ensure compatibility.`
);
}
if (!this.config.isAuthenticated) {
throw new AuthenticationError(
"Failed to authenticate with the sync-server."
);
}
}
if (!this.config.isAuthenticated) {
throw new AuthenticationError(
"Failed to authenticate with the sync-server."
);
}
}
public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean;
message: string;
}> {
try {
let { response } = this;
if (!response && !forceUpdate) {
throw new Error("ServerConfig not initialized");
} else if (forceUpdate) {
response = this.response = this.syncService.ping();
}
public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean;
message: string;
}> {
try {
let { response } = this;
if (!response && !forceUpdate) {
throw new Error("ServerConfig not initialized");
} else if (forceUpdate) {
response = this.response = this.syncService.ping();
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
this.config = result;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
this.config = result;
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
}
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
}
}
public getConfig(): ServerConfigData {
if (!this.config) {
throw new Error("ServerConfig not initialized");
}
public getConfig(): ServerConfigData {
if (!this.config) {
throw new Error("ServerConfig not initialized");
}
return this.config;
}
return this.config;
}
public reset(): void {
this.response = undefined;
this.config = undefined;
}
public reset(): void {
this.response = undefined;
this.config = undefined;
}
}

View file

@ -1,6 +1,6 @@
export class ServerVersionMismatchError extends Error {
public constructor(message: string) {
super(message);
this.name = "ServerVersionMismatchError";
}
public constructor(message: string) {
super(message);
this.name = "ServerVersionMismatchError";
}
}

View file

@ -1,6 +1,6 @@
export class SyncResetError extends Error {
public constructor() {
super("SyncClient has been reset, cleaning up");
this.name = "SyncResetError";
}
public constructor() {
super("SyncClient has been reset, cleaning up");
this.name = "SyncResetError";
}
}

View file

@ -1,7 +1,7 @@
import type {
DocumentId,
RelativePath,
VaultUpdateId
DocumentId,
RelativePath,
VaultUpdateId
} from "../persistence/database";
import type { Logger } from "../tracing/logger";
@ -19,416 +19,416 @@ import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
export class SyncService {
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
public constructor(
private readonly deviceId: string,
private readonly fetchController: FetchController,
private readonly settings: Settings,
private readonly logger: Logger,
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) {
// ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args);
public constructor(
private readonly deviceId: string,
private readonly fetchController: FetchController,
private readonly settings: Settings,
private readonly logger: Logger,
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) {
// ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args);
this.client = this.fetchController.getControlledFetchImplementation(
this.logger,
unboundFetch
);
this.pingClient = unboundFetch;
}
this.client = this.fetchController.getControlledFetchImplementation(
this.logger,
unboundFetch
);
this.pingClient = unboundFetch;
}
private static async errorFromResponse(
response: Response
): Promise<string> {
if (
response.headers
.get("Content-Type")
?.includes("application/json") == true
) {
const result: SerializedError =
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
return SyncService.formatError(result);
}
return `HTTP ${response.status}: ${response.statusText}`;
}
private static async errorFromResponse(
response: Response
): Promise<string> {
if (
response.headers
.get("Content-Type")
?.includes("application/json") == true
) {
const result: SerializedError =
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
return SyncService.formatError(result);
}
return `HTTP ${response.status}: ${response.statusText}`;
}
private static formatError(error: SerializedError): string {
let result = error.message;
if (error.causes.length > 0) {
const causes = error.causes.join(", ");
result += ` caused by: ${causes}`;
}
private static formatError(error: SerializedError): string {
let result = error.message;
if (error.causes.length > 0) {
const causes = error.causes.join(", ");
result += ` caused by: ${causes}`;
}
return result;
}
return result;
}
public async create({
documentId,
relativePath,
contentBytes
}: {
documentId?: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
public async create({
documentId,
relativePath,
contentBytes
}: {
documentId?: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}`
);
this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}`
);
const response = await this.client(this.getUrl("/documents"), {
method: "POST",
body: formData,
headers: this.getDefaultHeaders()
});
const response = await this.client(this.getUrl("/documents"), {
method: "POST",
body: formData,
headers: this.getDefaultHeaders()
});
if (!response.ok) {
throw new Error(
`Failed to create document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to create document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Created document ${JSON.stringify(result)}`);
this.logger.debug(`Created document ${JSON.stringify(result)}`);
return result;
});
}
return result;
});
}
public async putText({
parentVersionId,
documentId,
relativePath,
content
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
content: (number | string)[];
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
this.logger.debug(
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
);
public async putText({
parentVersionId,
documentId,
relativePath,
content
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
content: (number | string)[];
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
this.logger.debug(
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
);
const request: UpdateTextDocumentVersion = {
parentVersionId,
relativePath,
content
};
const request: UpdateTextDocumentVersion = {
parentVersionId,
relativePath,
content
};
const response = await this.client(
this.getUrl(`/documents/${documentId}/text`),
{
method: "PUT",
body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" })
}
);
const response = await this.client(
this.getUrl(`/documents/${documentId}/text`),
{
method: "PUT",
body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" })
}
);
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
return result;
});
}
return result;
});
}
public async putBinary({
parentVersionId,
documentId,
relativePath,
contentBytes
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
this.logger.debug(
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
);
const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
public async putBinary({
parentVersionId,
documentId,
relativePath,
contentBytes
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => {
this.logger.debug(
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
);
const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath);
formData.append(
"content",
new Blob([new Uint8Array(contentBytes)])
);
const response = await this.client(
this.getUrl(`/documents/${documentId}/binary`),
{
method: "PUT",
body: formData,
headers: this.getDefaultHeaders()
}
);
const response = await this.client(
this.getUrl(`/documents/${documentId}/binary`),
{
method: "PUT",
body: formData,
headers: this.getDefaultHeaders()
}
);
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
return result;
});
}
return result;
});
}
public async delete({
documentId,
relativePath
}: {
documentId: DocumentId;
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => {
const request: DeleteDocumentVersion = {
relativePath
};
public async delete({
documentId,
relativePath
}: {
documentId: DocumentId;
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => {
const request: DeleteDocumentVersion = {
relativePath
};
this.logger.debug(
`Delete document with id ${documentId} and relative path ${relativePath}`
);
this.logger.debug(
`Delete document with id ${documentId} and relative path ${relativePath}`
);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
method: "DELETE",
body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" })
}
);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
method: "DELETE",
body: JSON.stringify(request),
headers: this.getDefaultHeaders({ type: "json" })
}
);
if (!response.ok) {
throw new Error(
`Failed to delete document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to delete document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentVersionWithoutContent =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}`
);
this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}`
);
return result;
});
}
return result;
});
}
public async get({
documentId
}: {
documentId: DocumentId;
}): Promise<DocumentVersion> {
return this.retryForever(async () => {
this.logger.debug(`Getting document with id ${documentId}`);
public async get({
documentId
}: {
documentId: DocumentId;
}): Promise<DocumentVersion> {
return this.retryForever(async () => {
this.logger.debug(`Getting document with id ${documentId}`);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
headers: this.getDefaultHeaders()
}
);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
headers: this.getDefaultHeaders()
}
);
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentVersion =
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: DocumentVersion =
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Got document ${JSON.stringify(result)}`);
this.logger.debug(`Got document ${JSON.stringify(result)}`);
return result;
});
}
return result;
});
}
public async getDocumentVersionContent({
documentId,
vaultUpdateId
}: {
documentId: DocumentId;
vaultUpdateId: VaultUpdateId;
}): Promise<Uint8Array> {
return this.retryForever(async () => {
this.logger.debug(
`Getting document with id ${documentId} and version ${vaultUpdateId}`
);
public async getDocumentVersionContent({
documentId,
vaultUpdateId
}: {
documentId: DocumentId;
vaultUpdateId: VaultUpdateId;
}): Promise<Uint8Array> {
return this.retryForever(async () => {
this.logger.debug(
`Getting document with id ${documentId} and version ${vaultUpdateId}`
);
const response = await this.client(
this.getUrl(
`/documents/${documentId}/versions/${vaultUpdateId}/content`
),
{
headers: this.getDefaultHeaders()
}
);
const response = await this.client(
this.getUrl(
`/documents/${documentId}/versions/${vaultUpdateId}/content`
),
{
headers: this.getDefaultHeaders()
}
);
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to get document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result = await response.bytes();
this.logger.debug(
`Got document version content for document ${documentId} version ${vaultUpdateId}`
);
return result;
});
}
const result = await response.bytes();
this.logger.debug(
`Got document version content for document ${documentId} version ${vaultUpdateId}`
);
return result;
});
}
public async getAll(
since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> {
return this.retryForever(async () => {
this.logger.debug(
"Getting all documents" +
(since != null ? ` since ${since}` : "")
);
public async getAll(
since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> {
return this.retryForever(async () => {
this.logger.debug(
"Getting all documents" +
(since != null ? ` since ${since}` : "")
);
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since", since.toString());
}
const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders()
});
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since", since.toString());
}
const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders()
});
if (!response.ok) {
throw new Error(
`Failed to get documents: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to get documents: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: FetchLatestDocumentsResponse =
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: FetchLatestDocumentsResponse =
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Got ${result.latestDocuments.length} document metadata`
);
this.logger.debug(
`Got ${result.latestDocuments.length} document metadata`
);
return result;
});
}
return result;
});
}
public async ping(): Promise<PingResponse> {
this.logger.debug("Pinging server");
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
public async ping(): Promise<PingResponse> {
this.logger.debug("Pinging server");
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
if (!response.ok) {
throw new Error(
`Failed to ping server: ${await SyncService.errorFromResponse(
response
)}`
);
}
if (!response.ok) {
throw new Error(
`Failed to ping server: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Pinged server, got response: ${JSON.stringify(result)}`
);
this.logger.debug(
`Pinged server, got response: ${JSON.stringify(result)}`
);
return result;
}
return result;
}
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
const encodedVaultName = encodeURIComponent(vaultName.trim());
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
}
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
const encodedVaultName = encodeURIComponent(vaultName.trim());
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
}
private getDefaultHeaders(
{ type }: { type?: "json" } = { type: undefined }
): Record<string, string> {
const headers: Record<string, string> = {
"device-id": this.deviceId,
authorization: `Bearer ${this.settings.getSettings().token}`
};
private getDefaultHeaders(
{ type }: { type?: "json" } = { type: undefined }
): Record<string, string> {
const headers: Record<string, string> = {
"device-id": this.deviceId,
authorization: `Bearer ${this.settings.getSettings().token}`
};
if (type === "json") {
headers["Content-Type"] = "application/json";
}
if (type === "json") {
headers["Content-Type"] = "application/json";
}
return headers;
}
return headers;
}
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
try {
return await fn();
} catch (e) {
// We must not retry errors coming from reset
if (e instanceof SyncResetError) {
throw e;
}
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
try {
return await fn();
} catch (e) {
// We must not retry errors coming from reset
if (e instanceof SyncResetError) {
throw e;
}
const retryInterval =
this.settings.getSettings().networkRetryIntervalMs;
this.logger.error(
`Failed network call (${e}), retrying in ${retryInterval}ms`
);
await sleep(retryInterval);
}
}
}
const retryInterval =
this.settings.getSettings().networkRetryIntervalMs;
this.logger.error(
`Failed network call (${e}), retrying in ${retryInterval}ms`
);
await sleep(retryInterval);
}
}
}
}

View file

@ -2,7 +2,7 @@
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface ClientCursors {
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
userName: string;
deviceId: string;
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -1,13 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion {
/**
* The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
/**
* The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
}

View file

@ -2,5 +2,5 @@
import type { DocumentWithCursors } from "./DocumentWithCursors";
export interface CursorPositionFromClient {
documentsWithCursors: DocumentWithCursors[];
documentsWithCursors: DocumentWithCursors[];
}

View file

@ -2,5 +2,5 @@
import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer {
clients: ClientCursors[];
clients: ClientCursors[];
}

View file

@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CursorSpan {
start: number;
end: number;
start: number;
end: number;
}

View file

@ -1,5 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion {
relativePath: string;
relativePath: string;
}

View file

@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
* Response to an update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -1,12 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}

View file

@ -1,12 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

@ -2,8 +2,8 @@
import type { CursorSpan } from "./CursorSpan";
export interface DocumentWithCursors {
vault_update_id: number | null;
document_id: string;
relative_path: string;
cursors: CursorSpan[];
vault_update_id: number | null;
document_id: string;
relative_path: string;
cursors: CursorSpan[];
}

View file

@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
* Response to a fetch latest documents request.
*/
export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
}

View file

@ -4,22 +4,22 @@
* Response to a ping request.
*/
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
}

View file

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface SerializedError {
errorType: string;
message: string;
causes: string[];
errorType: string;
message: string;
causes: string[];
}

View file

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateDocumentVersion {
parent_version_id: bigint;
relative_path: string;
content: number[];
parent_version_id: bigint;
relative_path: string;
content: number[];
}

View file

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateTextDocumentVersion {
parentVersionId: number;
relativePath: string;
content: (number | string)[];
parentVersionId: number;
relativePath: string;
content: (number | string)[];
}

View file

@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -2,6 +2,6 @@
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
}

View file

@ -8,291 +8,291 @@ import type { Settings } from "../persistence/settings";
const WebSocket = require("ws") as typeof globalThis.WebSocket;
class MockCloseEvent extends Event {
public code: number;
public reason: string;
public code: number;
public reason: string;
public constructor(
type: string,
options: { code: number; reason: string }
) {
super(type);
this.code = options.code;
this.reason = options.reason;
}
public constructor(
type: string,
options: { code: number; reason: string }
) {
super(type);
this.code = options.code;
this.reason = options.reason;
}
}
class MockMessageEvent extends Event {
public data: string;
public data: string;
public constructor(type: string, options: { data: string }) {
super(type);
this.data = options.data;
}
public constructor(type: string, options: { data: string }) {
super(type);
this.data = options.data;
}
}
class MockWebSocket {
public readyState: number = WebSocket.CONNECTING;
public onopen: ((event: Event) => void) | null = null;
public onclose: ((event: MockCloseEvent) => void) | null = null;
public onmessage: ((event: MockMessageEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
public readyState: number = WebSocket.CONNECTING;
public onopen: ((event: Event) => void) | null = null;
public onclose: ((event: MockCloseEvent) => void) | null = null;
public onmessage: ((event: MockMessageEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
public sentMessages: string[] = [];
public sentMessages: string[] = [];
public constructor(public url: string) {
setTimeout(() => {
if (this.readyState === WebSocket.CONNECTING) {
this.readyState = WebSocket.OPEN;
this.onopen?.(new Event("open"));
}
}, 0);
}
public constructor(public url: string) {
setTimeout(() => {
if (this.readyState === WebSocket.CONNECTING) {
this.readyState = WebSocket.OPEN;
this.onopen?.(new Event("open"));
}
}, 0);
}
public send(data: string): void {
if (this.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.sentMessages.push(data);
}
public send(data: string): void {
if (this.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.sentMessages.push(data);
}
public close(code?: number, reason?: string): void {
this.readyState = WebSocket.CLOSED;
this.onclose?.(
new MockCloseEvent("close", {
code: code ?? 1000,
reason: reason ?? ""
})
);
}
public close(code?: number, reason?: string): void {
this.readyState = WebSocket.CLOSED;
this.onclose?.(
new MockCloseEvent("close", {
code: code ?? 1000,
reason: reason ?? ""
})
);
}
public simulateMessage(data: unknown): void {
this.onmessage?.(
new MockMessageEvent("message", { data: JSON.stringify(data) })
);
}
public simulateMessage(data: unknown): void {
this.onmessage?.(
new MockMessageEvent("message", { data: JSON.stringify(data) })
);
}
}
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
calls: Parameters<T>[];
calls: Parameters<T>[];
};
function createMockFn<T extends (...args: unknown[]) => unknown>(
implementation?: T
implementation?: T
): MockFn<T> {
const calls: Parameters<T>[] = [];
const mockFn = ((...args: Parameters<T>) => {
calls.push(args);
return implementation?.(...args);
}) as unknown as MockFn<T>;
mockFn.calls = calls;
return mockFn;
const calls: Parameters<T>[] = [];
const mockFn = ((...args: Parameters<T>) => {
calls.push(args);
return implementation?.(...args);
}) as unknown as MockFn<T>;
mockFn.calls = calls;
return mockFn;
}
describe("WebSocketManager", () => {
let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123";
let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123";
beforeEach(() => {
deviceId = "test-device-123";
const noop = (): void => {
// Intentionally empty for mock
};
mockLogger = {
info: createMockFn(noop),
warn: createMockFn(noop),
error: createMockFn(noop),
debug: createMockFn(noop)
} as unknown as Logger;
beforeEach(() => {
deviceId = "test-device-123";
const noop = (): void => {
// Intentionally empty for mock
};
mockLogger = {
info: createMockFn(noop),
warn: createMockFn(noop),
error: createMockFn(noop),
debug: createMockFn(noop)
} as unknown as Logger;
mockSettings = {
getSettings: () => ({
remoteUri: "https://example.com",
vaultName: "test-vault",
webSocketRetryIntervalMs: 1000
})
} as unknown as Settings;
});
mockSettings = {
getSettings: () => ({
remoteUri: "https://example.com",
vaultName: "test-vault",
webSocketRetryIntervalMs: 1000
})
} as unknown as Settings;
});
it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.onRemoteVaultUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.onRemoteVaultUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.onRemoteCursorsUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.onRemoteCursorsUpdateReceived.add(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({
type: "cursorPositions",
clients: [{ deviceId: "other-device", cursors: [] }]
});
mockWs.simulateMessage({
type: "cursorPositions",
clients: [{ deviceId: "other-device", cursors: [] }]
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
it("logs handshake send errors", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("logs handshake send errors", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.send = (): void => {
throw new Error("Buffer full");
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.send = (): void => {
throw new Error("Buffer full");
};
assert.throws(() => {
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test",
lastSeenVaultUpdateId: null
});
});
assert.throws(() => {
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test",
lastSeenVaultUpdateId: null
});
});
await manager.stop();
});
await manager.stop();
});
it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
await manager.stop();
assert.ok(true);
});
await manager.stop();
assert.ok(true);
});
it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
let statusChangeCount = 0;
manager.onWebSocketStatusChanged.add(() => {
statusChangeCount++;
});
let statusChangeCount = 0;
manager.onWebSocketStatusChanged.add(() => {
statusChangeCount++;
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
statusChangeCount = 0;
statusChangeCount = 0;
(
manager as unknown as { initializeWebSocket: () => void }
).initializeWebSocket();
await new Promise((resolve) => setTimeout(resolve, 50));
(
manager as unknown as { initializeWebSocket: () => void }
).initializeWebSocket();
await new Promise((resolve) => setTimeout(resolve, 50));
statusChangeCount = 0;
statusChangeCount = 0;
// Old handler should be cleared
firstWs.onclose?.(
new MockCloseEvent("close", { code: 1000, reason: "test" })
);
// Old handler should be cleared
firstWs.onclose?.(
new MockCloseEvent("close", { code: 1000, reason: "test" })
);
assert.strictEqual(statusChangeCount, 0);
await manager.stop();
});
assert.strictEqual(statusChangeCount, 0);
await manager.stop();
});
it("tracks message handling promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
it("tracks message handling promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
// eslint-disable-next-line @typescript-eslint/init-declarations
let resolveListener: () => void;
const listenerPromise = new Promise<void>((resolve) => {
resolveListener = resolve;
});
// eslint-disable-next-line @typescript-eslint/init-declarations
let resolveListener: () => void;
const listenerPromise = new Promise<void>((resolve) => {
resolveListener = resolve;
});
manager.onRemoteVaultUpdateReceived.add(async () => {
await listenerPromise;
});
manager.onRemoteVaultUpdateReceived.add(async () => {
await listenerPromise;
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
assert.ok(outstandingPromises.length > 0);
assert.ok(outstandingPromises.length > 0);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveListener!();
await new Promise((resolve) => setTimeout(resolve, 50));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveListener!();
await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
});