Add API for propagating cursor locations (#61)
This commit is contained in:
parent
f97193e287
commit
e8b9bf40c5
80 changed files with 1930 additions and 2229 deletions
|
|
@ -51,7 +51,10 @@ export class ConnectionStatus {
|
|||
logger: Logger,
|
||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||
): typeof globalThis.fetch {
|
||||
return async (input: RequestInfo | URL): Promise<Response> => {
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
while (!this.canFetch) {
|
||||
await this.until;
|
||||
}
|
||||
|
|
@ -63,7 +66,7 @@ export class ConnectionStatus {
|
|||
? input.clone()
|
||||
: input;
|
||||
|
||||
const fetchPromise = fetch(_input);
|
||||
const fetchPromise = fetch(_input, init);
|
||||
|
||||
// We only want to catch rejections from `this.until`
|
||||
let result: symbol | Response | undefined = undefined;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import type { Client } from "openapi-fetch";
|
||||
import createClient from "openapi-fetch";
|
||||
import type { components, paths } from "./types"; // generated by openapi-typescript
|
||||
import type {
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
VaultUpdateId
|
||||
} from "../persistence/database";
|
||||
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import type { ConnectionStatus } from "./connection-status";
|
||||
import { sleep } from "../utils/sleep";
|
||||
import { SyncResetError } from "./sync-reset-error";
|
||||
import type { SerializedError } from "./types/SerializedError";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||
import type { DocumentVersion } from "./types/DocumentVersion";
|
||||
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
|
||||
import type { PingResponse } from "./types/PingResponse";
|
||||
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
|
||||
|
||||
export interface CheckConnectionResult {
|
||||
isSuccessful: boolean;
|
||||
|
|
@ -19,47 +24,28 @@ export interface CheckConnectionResult {
|
|||
|
||||
export class SyncService {
|
||||
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
|
||||
private client: Client<paths>;
|
||||
private pingClient: Client<paths>;
|
||||
private readonly client: typeof globalThis.fetch;
|
||||
private readonly pingClient: typeof globalThis.fetch;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly connectionStatus: ConnectionStatus,
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
private readonly fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
|
||||
) {
|
||||
[this.client, this.pingClient] = this.createClient(
|
||||
this.settings.getSettings().remoteUri
|
||||
// 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.connectionStatus.getFetchImplementation(
|
||||
this.logger,
|
||||
unboundFetch
|
||||
);
|
||||
|
||||
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
|
||||
if (newSettings.remoteUri === oldSettings.remoteUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
[this.client, this.pingClient] = this.createClient(
|
||||
newSettings.remoteUri
|
||||
);
|
||||
});
|
||||
this.pingClient = unboundFetch;
|
||||
}
|
||||
|
||||
private get deviceIdHeader(): string {
|
||||
// @ts-expect-error, injected by webpack
|
||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
: typeof process !== "undefined"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
return `vault-link/${packageVersion} (${this.deviceId}; ${platform})`;
|
||||
}
|
||||
|
||||
private static formatError(
|
||||
error: components["schemas"]["SerializedError"]
|
||||
): string {
|
||||
private static formatError(error: SerializedError): string {
|
||||
let result = error.message;
|
||||
if (error.causes.length > 0) {
|
||||
const causes = error.causes.join(", ");
|
||||
|
|
@ -77,47 +63,39 @@ export class SyncService {
|
|||
documentId?: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.withRetries(async () => {
|
||||
const formData = new FormData();
|
||||
if (documentId !== undefined) {
|
||||
formData.append("document_id", documentId);
|
||||
}
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append("device_id", this.deviceId);
|
||||
formData.append("content", new Blob([contentBytes]));
|
||||
|
||||
const response = await this.client.POST(
|
||||
"/vaults/{vault_id}/documents",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
body: formData as any // FormData is not supported by openapi-fetch
|
||||
}
|
||||
);
|
||||
const response = await this.client(this.getUrl("/documents"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentVersionWithoutContent =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentVersionWithoutContent;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to create document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to create document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Created document ${JSON.stringify(response.data)} with id ${
|
||||
response.data.documentId
|
||||
`Created document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +109,7 @@ export class SyncService {
|
|||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<components["schemas"]["DocumentUpdateResponse"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.withRetries(async () => {
|
||||
this.logger.debug(
|
||||
`Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||
|
|
@ -141,39 +117,35 @@ export class SyncService {
|
|||
const formData = new FormData();
|
||||
formData.append("parent_version_id", parentVersionId.toString());
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append("device_id", this.deviceId);
|
||||
formData.append("content", new Blob([contentBytes]));
|
||||
|
||||
const response = await this.client.PUT(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
body: formData as any // FormData is not supported by openapi-fetch
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentUpdateResponse =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentUpdateResponse;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to update document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Updated document ${JSON.stringify(response.data)} with id ${
|
||||
response.data.documentId
|
||||
}`
|
||||
`Updated document ${JSON.stringify(result)} with id ${
|
||||
result.documentId
|
||||
}}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -183,39 +155,39 @@ export class SyncService {
|
|||
}: {
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.withRetries(async () => {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
const response = await this.client.DELETE(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const request: DeleteDocumentVersion = {
|
||||
relativePath
|
||||
};
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
},
|
||||
header: {
|
||||
"device-id": this.deviceIdHeader
|
||||
}
|
||||
},
|
||||
|
||||
body: {
|
||||
relativePath,
|
||||
deviceId: this.deviceId
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.getDefaultHeaders()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to delete document`);
|
||||
const result: SerializedError | DocumentVersionWithoutContent =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| DocumentVersionWithoutContent;
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to delete document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted document ${relativePath} with id ${documentId}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -223,100 +195,77 @@ export class SyncService {
|
|||
documentId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
}): Promise<components["schemas"]["DocumentVersion"]> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
}): Promise<DocumentVersion> {
|
||||
return this.withRetries(async () => {
|
||||
const response = await this.client.GET(
|
||||
"/vaults/{vault_id}/documents/{document_id}",
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName,
|
||||
document_id: documentId
|
||||
}
|
||||
}
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
const result: SerializedError | DocumentVersion =
|
||||
(await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${SyncService.formatError(response.error)}`
|
||||
`Failed to get document: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Get document ${response.data.relativePath} with id ${response.data.documentId}`
|
||||
`Get document ${result.relativePath} with id ${result.documentId}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async getAll(
|
||||
since?: VaultUpdateId
|
||||
): Promise<components["schemas"]["FetchLatestDocumentsResponse"]> {
|
||||
): Promise<FetchLatestDocumentsResponse> {
|
||||
return this.withRetries(async () => {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
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 response = await this.client.GET(
|
||||
"/vaults/{vault_id}/documents",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
},
|
||||
query: {
|
||||
since_update_id: since
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const result: SerializedError | FetchLatestDocumentsResponse =
|
||||
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
| SerializedError
|
||||
| FetchLatestDocumentsResponse;
|
||||
|
||||
const { error } = response;
|
||||
if (error) {
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to get documents: ${SyncService.formatError(response.error)}`
|
||||
`Failed to get documents: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Got ${response.data.latestDocuments.length} document metadata`
|
||||
`Got ${result.latestDocuments.length} document metadata`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async checkConnection(): Promise<CheckConnectionResult> {
|
||||
const { vaultName } = this.settings.getSettings();
|
||||
|
||||
try {
|
||||
const response = await this.pingClient.GET(
|
||||
"/vaults/{vault_id}/ping",
|
||||
{
|
||||
params: {
|
||||
header: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
},
|
||||
path: {
|
||||
vault_id: vaultName
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
const result: PingResponse | SerializedError =
|
||||
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Ping response: ${JSON.stringify(response.data)}`
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
if ("errorType" in result) {
|
||||
throw new Error(
|
||||
`Failed to ping server: ${SyncService.formatError(response.error)}`
|
||||
`Failed to ping server: ${SyncService.formatError(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
if (result.isAuthenticated) {
|
||||
return {
|
||||
isSuccessful: true,
|
||||
|
|
@ -336,29 +285,17 @@ export class SyncService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client and a ping client for the given remote URI.
|
||||
*/
|
||||
private createClient(remoteUri: string): [Client<paths>, Client<paths>] {
|
||||
return [
|
||||
createClient<paths>({
|
||||
baseUrl: remoteUri,
|
||||
fetch: this.connectionStatus.getFetchImplementation(
|
||||
this.logger,
|
||||
this.fetchImplementation
|
||||
),
|
||||
headers: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
}
|
||||
}),
|
||||
createClient<paths>({
|
||||
baseUrl: remoteUri,
|
||||
fetch: this.fetchImplementation,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
}
|
||||
})
|
||||
];
|
||||
private getUrl(path: string): string {
|
||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
|
||||
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
|
||||
}
|
||||
|
||||
private getDefaultHeaders(): Record<string, string> {
|
||||
return {
|
||||
"device-id": this.deviceId,
|
||||
authorization: `Bearer ${this.settings.getSettings().token}`
|
||||
};
|
||||
}
|
||||
|
||||
private async withRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
|
|
|||
|
|
@ -1,655 +0,0 @@
|
|||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/vaults/{vault_id}/documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
since_update_id?: number | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["FetchLatestDocumentsResponse"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"device-id": string;
|
||||
};
|
||||
path: {
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/documents/json": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"device-id": string;
|
||||
};
|
||||
path: {
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateDocumentVersion"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/documents/{document_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentVersion"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"device-id": string;
|
||||
};
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentUpdateResponse"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"device-id": string;
|
||||
};
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeleteDocumentVersion"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/documents/{document_id}/json": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"device-id": string;
|
||||
};
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateDocumentVersion"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentUpdateResponse"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
vault_update_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentVersion"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
vault_update_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description byte stream */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/octet-stream": unknown;
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/vaults/{vault_id}/ping": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
authorization?: string;
|
||||
};
|
||||
path: {
|
||||
vault_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PingResponse"];
|
||||
};
|
||||
};
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
/** @example {
|
||||
* "causes": [],
|
||||
* "message": "An error has occurred"
|
||||
* } */
|
||||
"application/json": components["schemas"]["SerializedError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
Array_of_uint8: number[];
|
||||
CreateDocumentPathParams: {
|
||||
vault_id: string;
|
||||
};
|
||||
CreateDocumentVersion: {
|
||||
contentBase64: string;
|
||||
deviceId?: string | null;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description 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.
|
||||
*/
|
||||
documentId?: string | null;
|
||||
relativePath: string;
|
||||
};
|
||||
CreateDocumentVersionMultipart: {
|
||||
content: components["schemas"]["Array_of_uint8"];
|
||||
device_id?: string | null;
|
||||
/** Format: uuid */
|
||||
document_id?: string | null;
|
||||
relative_path: string;
|
||||
};
|
||||
DeleteDocumentPathParams: {
|
||||
/** Format: uuid */
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
DeleteDocumentVersion: {
|
||||
deviceId?: string | null;
|
||||
relativePath: string;
|
||||
};
|
||||
/** @description Response to an update document request. */
|
||||
DocumentUpdateResponse:
|
||||
| {
|
||||
/** Format: uint64 */
|
||||
contentSize: number;
|
||||
deviceId: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
isDeleted: boolean;
|
||||
relativePath: string;
|
||||
/** @enum {string} */
|
||||
type: "FastForwardUpdate";
|
||||
/** Format: date-time */
|
||||
updatedDate: string;
|
||||
userId: string;
|
||||
/** Format: int64 */
|
||||
vaultUpdateId: number;
|
||||
}
|
||||
| {
|
||||
contentBase64: string;
|
||||
deviceId: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
isDeleted: boolean;
|
||||
relativePath: string;
|
||||
/** @enum {string} */
|
||||
type: "MergingUpdate";
|
||||
/** Format: date-time */
|
||||
updatedDate: string;
|
||||
userId: string;
|
||||
/** Format: int64 */
|
||||
vaultUpdateId: number;
|
||||
};
|
||||
DocumentVersion: {
|
||||
contentBase64: string;
|
||||
deviceId: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
isDeleted: boolean;
|
||||
relativePath: string;
|
||||
/** Format: date-time */
|
||||
updatedDate: string;
|
||||
userId: string;
|
||||
/** Format: int64 */
|
||||
vaultUpdateId: number;
|
||||
};
|
||||
DocumentVersionWithoutContent: {
|
||||
/** Format: uint64 */
|
||||
contentSize: number;
|
||||
deviceId: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
isDeleted: boolean;
|
||||
relativePath: string;
|
||||
/** Format: date-time */
|
||||
updatedDate: string;
|
||||
userId: string;
|
||||
/** Format: int64 */
|
||||
vaultUpdateId: number;
|
||||
};
|
||||
FetchDocumentVersionContentPathParams: {
|
||||
/** Format: uuid */
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
/** Format: int64 */
|
||||
vault_update_id: number;
|
||||
};
|
||||
FetchDocumentVersionPathParams: {
|
||||
/** Format: uuid */
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
/** Format: int64 */
|
||||
vault_update_id: number;
|
||||
};
|
||||
FetchLatestDocumentVersionPathParams: {
|
||||
/** Format: uuid */
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
FetchLatestDocumentsPathParams: {
|
||||
vault_id: string;
|
||||
};
|
||||
/** @description Response to a fetch latest documents request. */
|
||||
FetchLatestDocumentsResponse: {
|
||||
/**
|
||||
* Format: int64
|
||||
* @description The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: number;
|
||||
latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][];
|
||||
};
|
||||
PingPathParams: {
|
||||
vault_id: string;
|
||||
};
|
||||
/** @description Response to a ping request. */
|
||||
PingResponse: {
|
||||
/** @description Whether the client is authenticated based on the sent Authorization header. */
|
||||
isAuthenticated: boolean;
|
||||
/** @description Semantic version of the server. */
|
||||
serverVersion: string;
|
||||
};
|
||||
QueryParams: {
|
||||
/** Format: int64 */
|
||||
since_update_id?: number | null;
|
||||
};
|
||||
SerializedError: {
|
||||
causes: string[];
|
||||
message: string;
|
||||
};
|
||||
UpdateDocumentPathParams: {
|
||||
/** Format: uuid */
|
||||
document_id: string;
|
||||
vault_id: string;
|
||||
};
|
||||
UpdateDocumentVersion: {
|
||||
contentBase64: string;
|
||||
deviceId?: string | null;
|
||||
/** Format: int64 */
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
};
|
||||
UpdateDocumentVersionMultipart: {
|
||||
content: components["schemas"]["Array_of_uint8"];
|
||||
deviceId?: string | null;
|
||||
/** Format: int64 */
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
};
|
||||
WebsocketPathParams: {
|
||||
vault_id: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export type operations = Record<string, never>;
|
||||
8
frontend/sync-client/src/services/types/ClientCursors.ts
Normal file
8
frontend/sync-client/src/services/types/ClientCursors.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorSpan } from "./CursorSpan";
|
||||
|
||||
export interface ClientCursors {
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
cursors: Partial<Record<string, CursorSpan[]>>;
|
||||
}
|
||||
|
|
@ -0,0 +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[];
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorSpan } from "./CursorSpan";
|
||||
|
||||
export interface CursorPositionFromClient {
|
||||
documentToCursors: Partial<Record<string, CursorSpan[]>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ClientCursors } from "./ClientCursors";
|
||||
|
||||
export interface CursorPositionFromServer {
|
||||
clients: ClientCursors[];
|
||||
}
|
||||
6
frontend/sync-client/src/services/types/CursorSpan.ts
Normal file
6
frontend/sync-client/src/services/types/CursorSpan.ts
Normal file
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersion } from "./DocumentVersion";
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to an update document request.
|
||||
*/
|
||||
export type DocumentUpdateResponse =
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||
12
frontend/sync-client/src/services/types/DocumentVersion.ts
Normal file
12
frontend/sync-client/src/services/types/DocumentVersion.ts
Normal file
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to a fetch latest documents request.
|
||||
*/
|
||||
export interface FetchLatestDocumentsResponse {
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
}
|
||||
16
frontend/sync-client/src/services/types/PingResponse.ts
Normal file
16
frontend/sync-client/src/services/types/PingResponse.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -0,0 +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[];
|
||||
}
|
||||
|
|
@ -0,0 +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[];
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||
|
||||
export type WebSocketClientMessage =
|
||||
| ({ type: "handshake" } & WebSocketHandshake)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||
|
|
@ -0,0 +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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||
|
||||
export type WebSocketServerMessage =
|
||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export interface WebSocketVaultUpdate {
|
||||
documents: DocumentVersionWithoutContent[];
|
||||
isInitialSync: boolean;
|
||||
}
|
||||
209
frontend/sync-client/src/services/websocket-manager.ts
Normal file
209
frontend/sync-client/src/services/websocket-manager.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { Database } from "../persistence/database";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import type { Settings, SyncSettings } from "../persistence/settings";
|
||||
import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
|
||||
import type { Syncer } from "../sync-operations/syncer";
|
||||
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
|
||||
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
|
||||
import type { ClientCursors } from "./types/ClientCursors";
|
||||
|
||||
export class WebSocketManager {
|
||||
private readonly webSocketStatusChangeListeners: (() => unknown)[] = [];
|
||||
private readonly remoteCursorsUpdateListeners: ((
|
||||
cursors: ClientCursors[]
|
||||
) => unknown)[] = [];
|
||||
|
||||
private refreshWebSocketInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
private webSocket: WebSocket | undefined;
|
||||
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
private readonly settings: Settings,
|
||||
private readonly syncer: Syncer,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateWebSocket(settings.getSettings());
|
||||
|
||||
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
|
||||
if (
|
||||
newSettings.remoteUri !== oldSettings.remoteUri ||
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.token !== oldSettings.token ||
|
||||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
|
||||
) {
|
||||
this.updateWebSocket(newSettings);
|
||||
}
|
||||
});
|
||||
|
||||
this.setWebSocketRefreshInterval();
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
return (
|
||||
this.webSocket?.readyState ===
|
||||
this.webSocketFactoryImplementation.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
public addWebSocketStatusChangeListener(listener: () => void): void {
|
||||
this.webSocketStatusChangeListeners.push(listener);
|
||||
}
|
||||
|
||||
public addRemoteCursorsUpdateListener(
|
||||
listener: (cursors: ClientCursors[]) => void
|
||||
): void {
|
||||
this.remoteCursorsUpdateListeners.push(listener);
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
this.setWebSocketRefreshInterval();
|
||||
this.updateWebSocket(this.settings.getSettings());
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
clearInterval(this.refreshWebSocketInterval);
|
||||
|
||||
try {
|
||||
this.webSocket?.close();
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to close WebSocket: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
|
||||
if (!this.isWebSocketConnected) {
|
||||
this.logger.warn(
|
||||
"WebSocket is not connected, cannot send cursor positions"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message: WebSocketClientMessage = {
|
||||
type: "cursorPositions",
|
||||
...cursorPositions
|
||||
};
|
||||
this.webSocket?.send(JSON.stringify(message));
|
||||
this.logger.info(
|
||||
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
|
||||
);
|
||||
}
|
||||
|
||||
private updateWebSocket(settings: SyncSettings): void {
|
||||
try {
|
||||
this.webSocket?.close();
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to close WebSocket: ${e}`);
|
||||
}
|
||||
|
||||
if (!settings.isSyncEnabled) {
|
||||
this.webSocket = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUri = new URL(settings.remoteUri);
|
||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
|
||||
|
||||
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
||||
|
||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||
|
||||
this.webSocket.onmessage = async (event): Promise<void> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = JSON.parse(event.data) as WebSocketServerMessage;
|
||||
|
||||
if (message.type === "vaultUpdate") {
|
||||
try {
|
||||
await Promise.all(
|
||||
message.documents.map(async (document) =>
|
||||
this.syncer.syncRemotelyUpdatedFile(document)
|
||||
)
|
||||
);
|
||||
|
||||
if (message.isInitialSync && message.documents.length > 0) {
|
||||
this.database.setLastSeenUpdateId(
|
||||
message.documents
|
||||
.map((document) => document.vaultUpdateId)
|
||||
.reduce((a, b) => Math.max(a, b))
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to sync remotely updated file: ${e}`
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if (message.type === "cursorPositions") {
|
||||
this.logger.info(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
);
|
||||
this.remoteCursorsUpdateListeners.forEach((listener) => {
|
||||
listener(
|
||||
message.clients.filter(
|
||||
(client) => client.deviceId !== this.deviceId
|
||||
)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Received unknown message type: ${JSON.stringify(message)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
|
||||
this.webSocket.onopen = (): void => {
|
||||
this.logger.info("WebSocket connection opened");
|
||||
this.webSocketStatusChangeListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
|
||||
const message: WebSocketClientMessage = {
|
||||
type: "handshake",
|
||||
deviceId: this.deviceId,
|
||||
token: settings.token,
|
||||
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
|
||||
};
|
||||
this.webSocket?.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
this.webSocket.onclose = (event): void => {
|
||||
this.logger.warn(
|
||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||
);
|
||||
this.webSocketStatusChangeListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private setWebSocketRefreshInterval(): void {
|
||||
this.refreshWebSocketInterval = setInterval(() => {
|
||||
if (
|
||||
this.webSocket?.readyState ===
|
||||
this.webSocketFactoryImplementation.CLOSED
|
||||
) {
|
||||
this.logger.info("WebSocket is closed, reconnecting...");
|
||||
this.updateWebSocket(this.settings.getSettings());
|
||||
}
|
||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue