From 42b01217e06530b9cbf276538780832b7b552387 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 27 Sep 2026 20:41:15 +0100 Subject: [PATCH] Update http service --- .../sync-client/src/services/server-config.ts | 29 ++--- .../sync-client/src/services/sync-service.ts | 123 +++++++++--------- 2 files changed, 72 insertions(+), 80 deletions(-) diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index 66daa8a6..3f980744 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -4,24 +4,20 @@ import { ServerVersionMismatchError } from "../errors/errors"; import type { SyncService } from "./sync-service"; - -export interface ServerConfigData { - mergeableFileExtensions: string[]; - supportedApiVersion: number; - isAuthenticated: boolean; -} +import type { PingResponse } from "./types/PingResponse"; export class ServerConfig { - private config: Promise | undefined; + private config?: Promise; - public constructor(private readonly syncService: SyncService) { } + public constructor(private readonly syncService: SyncService) {} - private static validateConfig(config: ServerConfigData): void { + private static validateConfig(config: PingResponse): void { if (config.supportedApiVersion !== SUPPORTED_API_VERSION) { const shouldUpgradeClient = config.supportedApiVersion > SUPPORTED_API_VERSION; throw new ServerVersionMismatchError( - `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${shouldUpgradeClient ? "client" : "sync-server" + `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${ + shouldUpgradeClient ? "client" : "sync-server" } to ensure compatibility` ); } @@ -33,11 +29,6 @@ export class ServerConfig { } } - // warm the cache - public async initialize(): Promise { - await this.getConfig(); - } - public async checkConnection(): Promise<{ isSuccessful: boolean; message: string; @@ -59,7 +50,7 @@ export class ServerConfig { } } - public async getConfig(): Promise { + public async getConfig(): Promise { if (!this.config) { const pending = this.syncService .ping() @@ -68,12 +59,16 @@ export class ServerConfig { return config; }) .catch((error: unknown) => { - if (this.config === pending) this.config = undefined; + if (this.config === pending) { + this.config = undefined; + } + throw error; }); this.config = pending; } + return this.config; } diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 561c6928..5f91e1e8 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -1,6 +1,6 @@ +import type { DocumentId, VaultUpdateId } from "../persistence/database"; import type { Settings } from "../persistence/settings"; import { abortable } from "../utils/abortable"; -import { VaultHistoryStatus } from "../types/vault-history-status"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { EventBatch } from "./types/EventBatch"; @@ -17,26 +17,22 @@ import { } 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, - private readonly fetchImplementation: typeof fetch = globalThis.fetch, + fetchImplementation: typeof fetch | undefined, private readonly history: { get: () => string | undefined; save: (checkpoint: string | undefined) => Promise; } ) { + this.fetchImplementation = fetchImplementation ?? globalThis.fetch; this.pause(); } - public get vaultHistoryStatus(): VaultHistoryStatus { - return this.history.get() !== undefined - ? VaultHistoryStatus.STORED - : VaultHistoryStatus.MISSING; - } - public pause(): void { this.session.abort(new SyncResetError()); } @@ -50,31 +46,33 @@ export class SyncService { } public async ping(): Promise { - return this.request("/ping", undefined, true); + return this.request("/ping", { ignoreAborts: true }); } public async getVaultSnapshot(): Promise { return this.request("/vault-snapshot"); } - public async getEvents(after: number): Promise { + public async getEvents(after: VaultUpdateId): Promise { return this.request(`/events-since?after=${after}`); } public async pushFileManifest( request: PushFileManifest ): Promise { - return this.request("/file-manifest", request); + return this.request("/file-manifest", { body: request }); } public async putFileContent( - id: string, + id: DocumentId, request: PutFileContent ): Promise { - return this.request(`/documents/${id}`, request); + return this.request(`/documents/${id}`, { body: request }); } - public async getDocumentMetadata(id: string): Promise { + public async getDocumentMetadata( + id: DocumentId + ): Promise { return this.request(`/documents/${id}/metadata`); } @@ -82,78 +80,71 @@ export class SyncService { documentId, vaultUpdateId }: { - documentId: string; - vaultUpdateId: number; + documentId: DocumentId; + vaultUpdateId: VaultUpdateId; }): Promise { return this.request( `/documents/${documentId}/versions/${vaultUpdateId}/content`, - undefined, - false, - async (response) => new Uint8Array(await response.arrayBuffer()) + { + decode: async (response) => + new Uint8Array(await response.arrayBuffer()) + } ); } - private getHistoryHeaders(): Record { - const checkpoint = this.history.get(); - return checkpoint !== undefined && checkpoint !== "" - ? { "X-Vault-Link-History": checkpoint } - : {}; - } - private async recordHistory(response: Response): Promise { const checkpoint = response.headers.get("x-vault-link-history"); - if ( - checkpoint !== null && - checkpoint !== "" - ) { - const previous = this.history.get(); - if (checkpoint === previous) return; - if ( - previous === undefined || - previous === "" || - Number(checkpoint.split(":")[0]) >= - Number(previous.split(":")[0]) - ) - await this.history.save(checkpoint); + if (checkpoint === null || checkpoint === "") { + throw new PermanentSyncError("Missing server history checkpoint HTTP header"); } - } - private checkHistory(response: Response): void { - if (response.headers.get("x-vault-link-history-mismatch") === "1") - throw new ServerHistoryChangedError( - "Server history changed; recovering local work" - ); - } + const previous = this.history.get(); + if (checkpoint === previous) { + return; + } - private getUrl(path: string): string { - const { remoteUri, vaultName } = this.settings.getSettings(); - return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`; + if ( + previous === undefined || + Number(checkpoint.split(":")[0]) >= Number(previous.split(":")[0]) + ) { + await this.history.save(checkpoint); + } } private async request( path: string, - body?: unknown, - raw = false, - decode: (response: Response) => Promise = async (response) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. - response.json() as Promise + { + body, + ignoreAborts = false, + 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; + decode?: (response: Response) => Promise; + } = {} ): Promise { - const timeout = AbortSignal.timeout( - this.settings.getSettings().requestTimeoutMs - ); + 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 signal = raw + const timeout = AbortSignal.timeout(requestTimeoutMs); + const signal = ignoreAborts ? timeout : AbortSignal.any([timeout, this.session.signal]); const response = await abortable(signal, async () => - this.fetchImplementation(this.getUrl(path), { + this.fetchImplementation(url, { method: body === undefined ? "GET" : "PUT", body: body === undefined ? undefined : JSON.stringify(body), signal, headers: { - ...(raw ? {} : this.getHistoryHeaders()), - Authorization: `Bearer ${this.settings.getSettings().token}`, + ...(checkpoint === undefined + ? {} + : { "X-Vault-Link-History": checkpoint }), + Authorization: `Bearer ${token}`, "Device-Id": this.deviceId, "Content-Type": "application/json" } @@ -161,7 +152,11 @@ export class SyncService { ); signal.throwIfAborted(); - this.checkHistory(response); + if (response.headers.get("x-vault-link-history-mismatch") === "1") { + throw new ServerHistoryChangedError( + "Server history changed; recovering local work" + ); + } if (!response.ok) { this.throwForErrorResponse( @@ -171,7 +166,9 @@ export class SyncService { } // Finish metadata saves even if the transport is paused meanwhile. - if (!raw) await this.recordHistory(response); + if (!ignoreAborts) { + await this.recordHistory(response); + } return abortable(signal, async () => decode(response)); }