Update http service

This commit is contained in:
Andras Schmelczer 2026-09-27 20:41:15 +01:00
commit 42b01217e0
2 changed files with 72 additions and 80 deletions

View file

@ -4,24 +4,20 @@ import {
ServerVersionMismatchError ServerVersionMismatchError
} from "../errors/errors"; } from "../errors/errors";
import type { SyncService } from "./sync-service"; import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse";
export interface ServerConfigData {
mergeableFileExtensions: string[];
supportedApiVersion: number;
isAuthenticated: boolean;
}
export class ServerConfig { export class ServerConfig {
private config: Promise<ServerConfigData> | undefined; private config?: Promise<PingResponse>;
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) { if (config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient = const shouldUpgradeClient =
config.supportedApiVersion > SUPPORTED_API_VERSION; config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError( 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` } to ensure compatibility`
); );
} }
@ -33,11 +29,6 @@ export class ServerConfig {
} }
} }
// warm the cache
public async initialize(): Promise<void> {
await this.getConfig();
}
public async checkConnection(): Promise<{ public async checkConnection(): Promise<{
isSuccessful: boolean; isSuccessful: boolean;
message: string; message: string;
@ -59,7 +50,7 @@ export class ServerConfig {
} }
} }
public async getConfig(): Promise<ServerConfigData> { public async getConfig(): Promise<PingResponse> {
if (!this.config) { if (!this.config) {
const pending = this.syncService const pending = this.syncService
.ping() .ping()
@ -68,12 +59,16 @@ export class ServerConfig {
return config; return config;
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (this.config === pending) this.config = undefined; if (this.config === pending) {
this.config = undefined;
}
throw error; throw error;
}); });
this.config = pending; this.config = pending;
} }
return this.config; return this.config;
} }

View file

@ -1,6 +1,6 @@
import type { DocumentId, VaultUpdateId } from "../persistence/database";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import { abortable } from "../utils/abortable"; import { abortable } from "../utils/abortable";
import { VaultHistoryStatus } from "../types/vault-history-status";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { EventBatch } from "./types/EventBatch"; import type { EventBatch } from "./types/EventBatch";
@ -17,26 +17,22 @@ import {
} from "../errors/errors"; } from "../errors/errors";
export class SyncService { export class SyncService {
private readonly fetchImplementation: typeof fetch;
private session = new AbortController(); private session = new AbortController();
public constructor( public constructor(
private readonly deviceId: string, private readonly deviceId: string,
private readonly settings: Settings, private readonly settings: Settings,
private readonly fetchImplementation: typeof fetch = globalThis.fetch, fetchImplementation: typeof fetch | undefined,
private readonly history: { private readonly history: {
get: () => string | undefined; get: () => string | undefined;
save: (checkpoint: string | undefined) => Promise<void>; save: (checkpoint: string | undefined) => Promise<void>;
} }
) { ) {
this.fetchImplementation = fetchImplementation ?? globalThis.fetch;
this.pause(); this.pause();
} }
public get vaultHistoryStatus(): VaultHistoryStatus {
return this.history.get() !== undefined
? VaultHistoryStatus.STORED
: VaultHistoryStatus.MISSING;
}
public pause(): void { public pause(): void {
this.session.abort(new SyncResetError()); this.session.abort(new SyncResetError());
} }
@ -50,31 +46,33 @@ export class SyncService {
} }
public async ping(): Promise<PingResponse> { public async ping(): Promise<PingResponse> {
return this.request("/ping", undefined, true); return this.request("/ping", { ignoreAborts: true });
} }
public async getVaultSnapshot(): Promise<VaultSnapshot> { public async getVaultSnapshot(): Promise<VaultSnapshot> {
return this.request("/vault-snapshot"); return this.request("/vault-snapshot");
} }
public async getEvents(after: number): Promise<EventBatch> { public async getEvents(after: VaultUpdateId): Promise<EventBatch> {
return this.request(`/events-since?after=${after}`); return this.request(`/events-since?after=${after}`);
} }
public async pushFileManifest( public async pushFileManifest(
request: PushFileManifest request: PushFileManifest
): Promise<FileManifestUpdateResponse> { ): Promise<FileManifestUpdateResponse> {
return this.request("/file-manifest", request); return this.request("/file-manifest", { body: request });
} }
public async putFileContent( public async putFileContent(
id: string, id: DocumentId,
request: PutFileContent request: PutFileContent
): Promise<DocumentUpdateResponse> { ): Promise<DocumentUpdateResponse> {
return this.request(`/documents/${id}`, request); return this.request(`/documents/${id}`, { body: request });
} }
public async getDocumentMetadata(id: string): Promise<DocumentVersionWithoutContent> { public async getDocumentMetadata(
id: DocumentId
): Promise<DocumentVersionWithoutContent> {
return this.request(`/documents/${id}/metadata`); return this.request(`/documents/${id}/metadata`);
} }
@ -82,78 +80,71 @@ export class SyncService {
documentId, documentId,
vaultUpdateId vaultUpdateId
}: { }: {
documentId: string; documentId: DocumentId;
vaultUpdateId: number; vaultUpdateId: VaultUpdateId;
}): Promise<Uint8Array> { }): Promise<Uint8Array> {
return this.request( return this.request(
`/documents/${documentId}/versions/${vaultUpdateId}/content`, `/documents/${documentId}/versions/${vaultUpdateId}/content`,
undefined, {
false, decode: async (response) =>
async (response) => new Uint8Array(await response.arrayBuffer()) new Uint8Array(await response.arrayBuffer())
);
} }
);
private getHistoryHeaders(): Record<string, string> {
const checkpoint = this.history.get();
return checkpoint !== undefined && checkpoint !== ""
? { "X-Vault-Link-History": checkpoint }
: {};
} }
private async recordHistory(response: Response): Promise<void> { private async recordHistory(response: Response): Promise<void> {
const checkpoint = response.headers.get("x-vault-link-history"); const checkpoint = response.headers.get("x-vault-link-history");
if ( if (checkpoint === null || checkpoint === "") {
checkpoint !== null && throw new PermanentSyncError("Missing server history checkpoint HTTP header");
checkpoint !== "" }
) {
const previous = this.history.get(); const previous = this.history.get();
if (checkpoint === previous) return; if (checkpoint === previous) {
return;
}
if ( if (
previous === undefined || previous === undefined ||
previous === "" || Number(checkpoint.split(":")[0]) >= Number(previous.split(":")[0])
Number(checkpoint.split(":")[0]) >= ) {
Number(previous.split(":")[0])
)
await this.history.save(checkpoint); await this.history.save(checkpoint);
} }
} }
private checkHistory(response: Response): void {
if (response.headers.get("x-vault-link-history-mismatch") === "1")
throw new ServerHistoryChangedError(
"Server history changed; recovering local work"
);
}
private getUrl(path: string): string {
const { remoteUri, vaultName } = this.settings.getSettings();
return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`;
}
private async request<T>( private async request<T>(
path: string, path: string,
body?: unknown, {
raw = false, body,
decode: (response: Response) => Promise<T> = async (response) => ignoreAborts = false,
decode = async (response): Promise<T> =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type.
response.json() as Promise<T> response.json() as Promise<T>
}: {
body?: unknown;
ignoreAborts?: boolean;
decode?: (response: Response) => Promise<T>;
} = {}
): Promise<T> { ): Promise<T> {
const timeout = AbortSignal.timeout( const { remoteUri, vaultName, token, requestTimeoutMs } =
this.settings.getSettings().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 ? timeout
: AbortSignal.any([timeout, this.session.signal]); : AbortSignal.any([timeout, this.session.signal]);
const response = await abortable(signal, async () => const response = await abortable(signal, async () =>
this.fetchImplementation(this.getUrl(path), { this.fetchImplementation(url, {
method: body === undefined ? "GET" : "PUT", method: body === undefined ? "GET" : "PUT",
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
signal, signal,
headers: { headers: {
...(raw ? {} : this.getHistoryHeaders()), ...(checkpoint === undefined
Authorization: `Bearer ${this.settings.getSettings().token}`, ? {}
: { "X-Vault-Link-History": checkpoint }),
Authorization: `Bearer ${token}`,
"Device-Id": this.deviceId, "Device-Id": this.deviceId,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
@ -161,7 +152,11 @@ export class SyncService {
); );
signal.throwIfAborted(); 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) { if (!response.ok) {
this.throwForErrorResponse( this.throwForErrorResponse(
@ -171,7 +166,9 @@ export class SyncService {
} }
// Finish metadata saves even if the transport is paused meanwhile. // 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)); return abortable(signal, async () => decode(response));
} }