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.
103 lines
4.9 KiB
TypeScript
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`);
|
|
}
|