Add API for propagating cursor locations (#61)

This commit is contained in:
Andras Schmelczer 2025-06-08 20:20:52 +01:00 committed by GitHub
parent f97193e287
commit e8b9bf40c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1930 additions and 2229 deletions

View file

@ -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> {