Update http service
This commit is contained in:
parent
82cd37b61d
commit
42b01217e0
2 changed files with 72 additions and 80 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
if (checkpoint === previous) return;
|
|
||||||
if (
|
|
||||||
previous === undefined ||
|
|
||||||
previous === "" ||
|
|
||||||
Number(checkpoint.split(":")[0]) >=
|
|
||||||
Number(previous.split(":")[0])
|
|
||||||
)
|
|
||||||
await this.history.save(checkpoint);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private checkHistory(response: Response): void {
|
const previous = this.history.get();
|
||||||
if (response.headers.get("x-vault-link-history-mismatch") === "1")
|
if (checkpoint === previous) {
|
||||||
throw new ServerHistoryChangedError(
|
return;
|
||||||
"Server history changed; recovering local work"
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUrl(path: string): string {
|
if (
|
||||||
const { remoteUri, vaultName } = this.settings.getSettings();
|
previous === undefined ||
|
||||||
return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`;
|
Number(checkpoint.split(":")[0]) >= Number(previous.split(":")[0])
|
||||||
|
) {
|
||||||
|
await this.history.save(checkpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type.
|
decode = async (response): Promise<T> =>
|
||||||
response.json() as Promise<T>
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type.
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue