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.
This commit is contained in:
1 parent
a9021312b0
commit
c99f3e5fe8
27 files changed
+2899
-14
No files matched your search
+102
@@ -0,0 +1,102 @@
|
||||
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`);
|
||||
}
|
||||
Reference in new issue
Block a user