Files
archify-vscode-ext/src/sync/api.ts
T
root-at-skic c99f3e5fe8 0.10.0: Archify Sync, optional live collaboration on a model
Several people edit one model at once through a server you run yourself
(archify-vscode-ext-sync); the files stay ordinary files on each disk.
Sign-in through the browser, offline-first with later merge, teammates'
cursors in the Source view and text editors, and a Shared section on the
model page with people, versions, Compare and Restore.

Without a server nothing changes: the sync code is a separate bundle that
loads only when a model is shared, after a sign-in, or when a sync command
or invite link is used.
2026-11-06 22:55:01 +02:00

103 lines
4.9 KiB
TypeScript

import {
ApiError,
Invite,
JoinResponse,
Member,
PROTOCOL_VERSION,
ServerInfo,
Space,
TokenResponse,
Version,
VersionFiles,
} from './protocol';
export class SyncApiError extends Error {
constructor(readonly status: number, readonly code: string, message: string) {
super(message);
}
}
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
/**
* The server address as the extension keeps it: http(s), no trailing slash.
* Plain http is refused except on this machine; tokens travel on it.
*/
export function checkServerUrl(raw: string): { url?: string; problem?: string } {
let url: URL;
try {
url = new URL(raw.trim());
} catch {
return { problem: 'Enter the server address, for example https://archify.example.com' };
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' };
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' };
return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` };
}
export function syncSocketUrl(server: string, space: string): string {
return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`;
}
/** Reads `server` and `invite` from an invite link: `https://…/join/<token>` or the extension's `…/join?server=&invite=`. */
export function parseInviteLink(raw: string): { server: string; invite: string } | undefined {
try {
const url = new URL(raw.trim());
const server = url.searchParams.get('server');
const invite = url.searchParams.get('invite');
if (server && invite) return { server: server.replace(/\/+$/, ''), invite };
const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname);
if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] };
} catch {
// not a URL
}
return undefined;
}
/** The server's HTTP API. `token` gives a fresh access token for calls that need one. */
export class SyncApi {
constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise<string | undefined>) {}
private async request<T>(method: string, path: string, body?: unknown, signedIn = true): Promise<T> {
const send = async (force: boolean) => {
const headers: Record<string, string> = { accept: 'application/json' };
if (body !== undefined) headers['content-type'] = 'application/json';
if (signedIn) {
const token = await this.token?.(force);
if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.');
headers.authorization = `Bearer ${token}`;
}
return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
};
let response = await send(false);
// The access token may have expired between the check and the call.
if (response.status === 401 && signedIn) response = await send(true);
const text = await response.text();
if (!response.ok) {
let error: Partial<ApiError> = {};
try {
error = JSON.parse(text) as ApiError;
} catch {
// not JSON
}
throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`);
}
return (text ? JSON.parse(text) : undefined) as T;
}
info = () => this.request<ServerInfo>('GET', '/v1/info', undefined, false);
exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false);
refresh = (refreshToken: string) => this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false);
logout = (refreshToken: string) => this.request<void>('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false);
spaces = () => this.request<Space[]>('GET', '/v1/spaces');
createSpace = (name: string) => this.request<Space>('POST', '/v1/spaces', { name });
members = (space: string) => this.request<Member[]>('GET', `/v1/spaces/${space}/members`);
invite = (space: string) => this.request<Invite>('POST', `/v1/spaces/${space}/invites`, {});
acceptInvite = (token: string) => this.request<JoinResponse>('POST', `/v1/invites/${encodeURIComponent(token)}/accept`);
versions = (space: string) => this.request<Version[]>('GET', `/v1/spaces/${space}/versions`);
publish = (space: string, name: string) => this.request<Version>('POST', `/v1/spaces/${space}/versions`, { name });
versionFiles = (space: string, version: number) => this.request<VersionFiles>('GET', `/v1/spaces/${space}/versions/${version}/files`);
restore = (space: string, version: number) => this.request<Version>('POST', `/v1/spaces/${space}/versions/${version}/restore`);
}