import { HISTORY_HEADER, HISTORY_MISMATCH_HEADER } from "../consts"; import type { DocumentId, VaultUpdateId } from "../persistence/database"; import type { Settings } from "../persistence/settings"; import { abortable } from "../utils/abortable"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { EventBatch } from "./types/EventBatch"; import type { FileManifestUpdateResponse } from "./types/FileManifestUpdateResponse"; import type { PutFileContent } from "./types/PutFileContent"; import type { PushFileManifest } from "./types/PushFileManifest"; import type { VaultSnapshot } from "./types/VaultSnapshot"; import type { ServerConfigResponse } from "./types/ServerConfigResponse"; import { AuthenticationError, PermanentSyncError, ServerHistoryChangedError, SyncResetError } from "../errors/errors"; export class SyncService { private readonly fetchImplementation: typeof fetch; private session = new AbortController(); public constructor( private readonly deviceId: string, private readonly settings: Settings, fetchImplementation: typeof fetch | undefined, private readonly history: { get: () => string | undefined; save: (checkpoint: string | undefined) => Promise; } ) { this.fetchImplementation = fetchImplementation ?? globalThis.fetch; this.pause(); } public pause(): void { this.session.abort(new SyncResetError()); } public resume(): void { this.session = new AbortController(); } public async resetHistory(): Promise { await this.history.save(undefined); } public async getServerConfig( ignoreAborts = true ): Promise { return this.request("/config", { ignoreAborts, recordCheckpoint: false }); } public async getVaultSnapshot(): Promise { return this.request("/vault-snapshot"); } public async getEvents(after: VaultUpdateId): Promise { return this.request(`/events-since?after=${after}`); } public async pushFileManifest( request: PushFileManifest ): Promise { return this.request("/file-manifest", { body: request }); } public async putFileContent( id: DocumentId, request: PutFileContent ): Promise { return this.request(`/documents/${id}`, { body: request }); } public async getDocumentMetadata( id: DocumentId ): Promise { return this.request(`/documents/${id}/metadata`); } public async getDocumentVersionContent({ documentId, vaultUpdateId }: { documentId: DocumentId; vaultUpdateId: VaultUpdateId; }): Promise { return this.request( `/documents/${documentId}/versions/${vaultUpdateId}/content`, { decode: async (response) => new Uint8Array(await response.arrayBuffer()) } ); } private async recordHistory(response: Response): Promise { const checkpoint = response.headers.get(HISTORY_HEADER); if (checkpoint === null || checkpoint === "") { throw new PermanentSyncError( "Missing server history checkpoint HTTP header" ); } const previous = this.history.get(); if (checkpoint === previous) { return; } if ( previous === undefined || Number(checkpoint.split(":")[0]) >= Number(previous.split(":")[0]) ) { await this.history.save(checkpoint); } } private async request( path: string, { body, ignoreAborts = false, recordCheckpoint = true, decode = async (response): Promise => // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. response.json() as Promise }: { body?: unknown; ignoreAborts?: boolean; recordCheckpoint?: boolean; decode?: (response: Response) => Promise; } = {} ): Promise { const { remoteUri, vaultName, token, requestTimeoutMs } = this.settings.getSettings(); const url = `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`; const checkpoint = ignoreAborts ? undefined : this.history.get(); const timeout = AbortSignal.timeout(requestTimeoutMs); const signal = ignoreAborts ? timeout : AbortSignal.any([timeout, this.session.signal]); const response = await abortable(signal, async () => this.fetchImplementation(url, { method: body === undefined ? "GET" : "PUT", body: body === undefined ? undefined : JSON.stringify(body), signal, headers: { ...(checkpoint === undefined ? {} : { [HISTORY_HEADER]: checkpoint }), Authorization: `Bearer ${token}`, "Device-Id": this.deviceId, "Content-Type": "application/json" } }) ); signal.throwIfAborted(); if (response.headers.get(HISTORY_MISMATCH_HEADER) === "1") { throw new ServerHistoryChangedError( "Server history changed; recovering local work" ); } if (!response.ok) { this.throwForErrorResponse( response, `HTTP ${response.status}: ${await abortable(signal, async () => response.text())}` ); } // Finish metadata saves even if the transport is paused meanwhile. if (!ignoreAborts && recordCheckpoint) { await this.recordHistory(response); } return abortable(signal, async () => decode(response)); } private throwForErrorResponse(response: Response, message: string): never { if (response.status === 401 || response.status === 403) { throw new AuthenticationError(message); } if ( response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 ) { throw new PermanentSyncError(message); } throw new Error(message); } }