rspace-online/shared/local-first/compute.ts

182 lines
5.1 KiB
TypeScript

/**
* Layer 5: Compute — Deterministic transforms that run locally when possible,
* delegate to server when not.
*
* | Transform | Local? | Example |
* |---------------------|--------|--------------------------------|
* | Markdown → HTML | Yes | Notes rendering |
* | Search indexing | Yes | Build index from docs |
* | Vote tallies | Yes | Derived from CRDT state |
* | PDF generation | No | Server delegate (Typst) |
* | Image thumbnailing | No | Server delegate (Sharp) |
*
* Server fallback: POST /:space/api/compute/:transformId
*/
// ============================================================================
// TYPES
// ============================================================================
/**
* A transform: a named, deterministic function that converts input → output.
*/
export interface Transform<In = unknown, Out = unknown> {
/** Unique identifier (e.g. "markdown-to-html", "pdf-generate") */
id: string;
/** Whether this transform can run in the browser */
canRunLocally: boolean;
/** Execute the transform */
execute(input: In): Promise<Out>;
}
export interface ComputeEngineOptions {
/** Base URL for server-side compute endpoint (e.g. "/demo/api/compute") */
serverBaseUrl?: string;
/** Auth token for server requests */
authToken?: string;
}
// ============================================================================
// ComputeEngine
// ============================================================================
export class ComputeEngine {
#transforms = new Map<string, Transform<any, any>>();
#serverBaseUrl: string | null;
#authToken: string | null;
constructor(opts: ComputeEngineOptions = {}) {
this.#serverBaseUrl = opts.serverBaseUrl ?? null;
this.#authToken = opts.authToken ?? null;
}
/**
* Register a transform.
*/
register<In, Out>(transform: Transform<In, Out>): void {
this.#transforms.set(transform.id, transform);
}
/**
* Run a transform. Runs locally if possible, delegates to server otherwise.
*/
async run<In, Out>(transformId: string, input: In): Promise<Out> {
const transform = this.#transforms.get(transformId);
if (transform?.canRunLocally) {
return transform.execute(input) as Promise<Out>;
}
if (transform && !transform.canRunLocally && this.#serverBaseUrl) {
return this.#delegateToServer<In, Out>(transformId, input);
}
if (!transform && this.#serverBaseUrl) {
// Transform not registered locally — try server
return this.#delegateToServer<In, Out>(transformId, input);
}
throw new Error(
`Transform "${transformId}" not available: ${
transform ? 'requires server but no serverBaseUrl configured' : 'not registered'
}`
);
}
/**
* Check if a transform is registered and can run locally.
*/
canRunLocally(transformId: string): boolean {
const t = this.#transforms.get(transformId);
return !!t && t.canRunLocally;
}
/**
* Check if a transform is registered.
*/
has(transformId: string): boolean {
return this.#transforms.has(transformId);
}
/**
* List all registered transform IDs.
*/
list(): string[] {
return Array.from(this.#transforms.keys());
}
/**
* Update auth token (e.g. after login/refresh).
*/
setAuthToken(token: string): void {
this.#authToken = token;
}
/**
* Update server base URL.
*/
setServerBaseUrl(url: string): void {
this.#serverBaseUrl = url;
}
// ---------- Private ----------
async #delegateToServer<In, Out>(transformId: string, input: In): Promise<Out> {
if (!this.#serverBaseUrl) {
throw new Error('No server base URL configured for compute delegation');
}
const url = `${this.#serverBaseUrl}/${transformId}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.#authToken) {
headers['Authorization'] = `Bearer ${this.#authToken}`;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ input }),
});
if (!response.ok) {
throw new Error(`Compute server error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return result.output as Out;
}
}
// ============================================================================
// BUILT-IN TRANSFORMS
// ============================================================================
/**
* Helper to create a local transform.
*/
export function localTransform<In, Out>(
id: string,
fn: (input: In) => Promise<Out> | Out
): Transform<In, Out> {
return {
id,
canRunLocally: true,
execute: async (input) => fn(input),
};
}
/**
* Helper to declare a server-only transform (acts as a type contract).
*/
export function serverTransform<In, Out>(id: string): Transform<In, Out> {
return {
id,
canRunLocally: false,
execute: async () => {
throw new Error(`Transform "${id}" must run on server`);
},
};
}