Skip to content

Instantly share code, notes, and snippets.

@IAmJSD
Last active September 4, 2026 23:27
Show Gist options
  • Select an option

  • Save IAmJSD/a8872306d4c3b50369636e17b29330db to your computer and use it in GitHub Desktop.

Select an option

Save IAmJSD/a8872306d4c3b50369636e17b29330db to your computer and use it in GitHub Desktop.
// Original draft: https://gist.github.com/IAmJSD/a8872306d4c3b50369636e17b29330db
import * as v from "valibot";
import * as Y from "yjs";
import { encode, decode } from "@msgpack/msgpack";
import {
object,
url,
string,
pipe,
parse,
number,
InferOutput,
array,
union,
literal,
boolean,
strictTuple,
optional,
} from "valibot";
export const AuthenticationAndCodeExchangeURL = object({
authentication_url: pipe(string(), url()),
code_exchange_url: pipe(string(), url()),
browser_authentication: optional(object({
method: literal("post_message"),
code_challenge_method: literal("S256-hex"),
origins: array(string()),
})),
});
/**
* Returns a authentication and code exchange URL. The URLs MUST not be relative. The "state" parameter is injected into authentication_url.
* The authentication URL should do the flow in a browser and return with schist://ig-callback?state=<state here>&code=<your code>
*/
export async function getAuthenticationAndCodeExchangeURLs(
state: string,
domain: string,
) {
domain = domain.toLowerCase();
if (domain.startsWith("http://")) throw new Error("Domain cannot be http");
if (!domain.startsWith("https://")) domain = `https://${domain}`;
const schistUrlsUrl = new URL("/.schist/auth-urls.json", domain);
const res = await fetch(schistUrlsUrl);
if (!res.ok)
throw new Error(`/.schist/auth-urls.json returned status ${res.status}`);
const j = parse(AuthenticationAndCodeExchangeURL, await res.json());
const authUrl = new URL(j.authentication_url);
authUrl.searchParams.set("state", state);
return {
authentication_url: authUrl.toString(),
code_exchange_url: j.code_exchange_url,
browser_authentication: j.browser_authentication,
};
}
export const CodeExchangeResponse = object({
access_token: string(),
refresh_token: string(),
expires_at: number(),
generation_endpoint_url: string(),
/** Optional extension: one authenticated WSS connection for the remote workspace. */
workspace_websocket_url: optional(pipe(string(), url())),
/** If the app wishes to logout, it just performs a DELETE to this URL with no additional body/headers/params. */
logout_url: string(),
});
export const SCHIST_BROWSER_ORIGIN = "https://try.schist.app";
export const SCHIST_CLOUD_ORIGIN = "https://cloud.schist.app";
function officialCloudUrl(raw: string, websocket = false): string {
const u = new URL(raw);
if (u.origin !== (websocket ? "wss://cloud.schist.app" : SCHIST_CLOUD_ORIGIN) || u.username || u.password)
throw new Error("Browser cloud connections are restricted to cloud.schist.app");
return u.toString();
}
// Only file upload bodies may go to this private Neon storage endpoint.
// Access/refresh tokens, auth callbacks and WebSockets stay on the cloud origin.
export const SCHIST_UPLOAD_ORIGIN = "https://br-bitter-cake-ayadqsoa.storage.c-5.us-east-2.aws.neon.tech";
function officialUploadUrl(raw: string): string {
const u = new URL(raw);
if (![SCHIST_CLOUD_ORIGIN, SCHIST_UPLOAD_ORIGIN].includes(u.origin) || u.username || u.password)
throw new Error("Unrecognized Schist upload endpoint");
return u.toString();
}
function officialBrowser() {
if (typeof window === "undefined" || window.location.origin !== SCHIST_BROWSER_ORIGIN)
throw new Error("Browser cloud is available at try.schist.app");
}
function browserCredentials(credentials: InferOutput<typeof CodeExchangeResponse>) {
officialCloudUrl(credentials.generation_endpoint_url);
officialCloudUrl(credentials.logout_url);
if (!credentials.workspace_websocket_url) throw new Error("No workspace endpoint");
officialCloudUrl(credentials.workspace_websocket_url, true);
return credentials;
}
/** Call directly from a click handler; credentials remain in the current tab's memory. */
export async function signIntoSchistCloud() {
officialBrowser();
const popup = window.open("about:blank", "_blank", "popup,width=520,height=720");
if (!popup) throw new Error("Allow the sign-in popup and try again");
const hex = (bytes: Uint8Array) => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
const state = crypto.randomUUID();
const verifier = hex(crypto.getRandomValues(new Uint8Array(32)));
let listener: ((event: MessageEvent) => void) | undefined;
let timer: ReturnType<typeof setInterval> | undefined;
try {
const response = await fetch(`${SCHIST_CLOUD_ORIGIN}/.schist/auth-urls.json`, {
credentials: "omit", redirect: "error", signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`Cloud discovery failed (${response.status})`);
const discovery = parse(AuthenticationAndCodeExchangeURL, await response.json());
if (!discovery.browser_authentication?.origins.includes(SCHIST_BROWSER_ORIGIN))
throw new Error("Provider does not support this browser sign-in flow");
const codeExchangeUrl = officialCloudUrl(discovery.code_exchange_url);
const authorization = new URL(officialCloudUrl(discovery.authentication_url));
const challenge = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
authorization.searchParams.set("state", state);
authorization.searchParams.set("return_origin", SCHIST_BROWSER_ORIGIN);
authorization.searchParams.set("code_challenge", challenge);
const code = await new Promise<string>((resolve, reject) => {
const deadline = Date.now() + 600_000;
listener = event => {
if (event.origin !== SCHIST_CLOUD_ORIGIN || event.source !== popup) return;
const data = event.data;
if (data?.type === "schist.authorization" && data.state === state && typeof data.code === "string" && data.code.length > 0 && data.code.length <= 256)
resolve(data.code);
};
window.addEventListener("message", listener);
timer = setInterval(() => {
if (popup.closed || Date.now() >= deadline) reject(new Error("Sign-in closed or expired"));
}, 100);
popup.location.href = authorization.toString();
});
const credentials = browserCredentials(await performCodeExchange(codeExchangeUrl, code, state, verifier));
return { codeExchangeUrl, credentials, browserCloud: true as const };
} finally {
if (listener) window.removeEventListener("message", listener);
if (timer) clearInterval(timer);
popup.close();
}
}
/**
* Performs the code exchange with the code_exchange_url we got earlier and the code/state we were
* just redirected with (called in response to schist://ig-callback?state=<state here>&code=<your code>).
*/
export async function performCodeExchange(
codeExchangeUrl: string,
code: string,
state: string,
codeVerifier?: string,
) {
const body = {
response_type: "code",
...(codeVerifier === undefined ? {} : { code_verifier: codeVerifier }),
code,
state,
schist_spec_version: 1,
};
const res = await fetch(codeExchangeUrl, {
credentials: "omit",
redirect: "error",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
method: "POST",
});
if (!res.ok) throw new Error("failed to exchange code");
return parse(CodeExchangeResponse, await res.json());
}
/** Perform a refresh with the refresh token and the code_exchange_url we got earlier. */
export async function performTokenRefresh(
codeExchangeUrl: string,
refreshToken: string,
) {
const body = {
response_type: "refresh_token",
refresh_token: refreshToken,
schist_spec_version: 1,
};
const res = await fetch(codeExchangeUrl, {
credentials: "omit",
redirect: "error",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
method: "POST",
});
if (!res.ok) throw new Error("failed to exchange refresh token");
return parse(CodeExchangeResponse, await res.json());
}
async function refreshIfNeeded(
codeExchangeUrl: string,
body: InferOutput<typeof CodeExchangeResponse>,
) {
if (Date.now() / 1000 <= body.expires_at) {
// This is fine
return null;
}
return await performTokenRefresh(codeExchangeUrl, body.refresh_token);
}
/** A single-line/free-form text input the user fills in. The value is sent back keyed by `id`. */
const TextBox = object({
t: literal("text"),
title: string(),
description: string(),
/** If true, the app should refuse to generate until the user has given a value. */
required: boolean(),
/** The key this field's value is sent under in the generation/preview body. */
id: string(),
});
/** A pick-from-a-list input. The chosen value id(s) are sent back keyed by `id`. */
const SelectBox = object({
t: literal("select"),
title: string(),
description: string(),
/** If true, the app should refuse to generate until the user has picked a value. */
required: boolean(),
/** The key this field's value is sent under in the generation/preview body. */
id: string(),
/** If true, more than one of `values` may be selected at once. */
multiple: boolean(),
/** The selectable options. `id` is what gets sent, `text` is what the user sees. */
values: array(
object({
id: string(),
text: string(),
}),
),
});
/**
* Not an input - a block of server-rendered text that reflects what the user has entered so far.
* The app should POST the current field values to `live_preview_url` (see getLiveTextPreview)
* whenever they change and display the text that comes back.
*/
const LiveTextPreview = object({
t: literal("live_text_preview"),
live_preview_url: pipe(string(), url()),
});
/** One item in the generation form. Discriminate on `t` to figure out which kind it is. */
const GeneratedStructureItem = union([TextBox, SelectBox, LiveTextPreview]);
/** The whole generation form, in the order the items should be displayed. */
const GenerationStructure = array(GeneratedStructureItem);
/**
* Fetches the form the app should render for the user, from the generation_endpoint_url we got
* during the code exchange. Refreshes the token first if it has expired, and if it did,
* writeCodeExchangeResponse is called with the new body so the caller can persist it.
*/
export async function getGenerationStructure(
codeExchangeUrl: string,
codeExchangeResponse: InferOutput<typeof CodeExchangeResponse>,
writeCodeExchangeResponse: (
body: InferOutput<typeof CodeExchangeResponse>,
) => Promise<void>,
) {
const resetBody = await refreshIfNeeded(
codeExchangeUrl,
codeExchangeResponse,
);
if (resetBody) await writeCodeExchangeResponse(resetBody);
const res = await fetch(
resetBody?.generation_endpoint_url ||
codeExchangeResponse.generation_endpoint_url,
{
headers: {
Authorization: `Bearer ${resetBody?.access_token || codeExchangeResponse.access_token}`,
},
},
);
if (!res.ok) throw new Error("failed to get generation structure");
return parse(GenerationStructure, await res.json());
}
/**
* Renders a live_text_preview item by POSTing the current form values (keyed by each item's `id`)
* to that item's live_preview_url, and returns the text to display. Refreshes the token first if it
* has expired, and if it did, writeCodeExchangeResponse is called with the new body so the caller
* can persist it.
*/
export async function getLiveTextPreview(
codeExchangeUrl: string,
codeExchangeResponse: InferOutput<typeof CodeExchangeResponse>,
writeCodeExchangeResponse: (
body: InferOutput<typeof CodeExchangeResponse>,
) => Promise<void>,
previewUrl: string,
body: Record<string, string | string[]>,
) {
const resetBody = await refreshIfNeeded(
codeExchangeUrl,
codeExchangeResponse,
);
if (resetBody) await writeCodeExchangeResponse(resetBody);
const res = await fetch(previewUrl, {
headers: {
Authorization: `Bearer ${resetBody?.access_token || codeExchangeResponse.access_token}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("failed to get live preview text");
return await res.text();
}
/** The shape of the output: an ordered list of parts, each holding some number of image slots. */
const Layout = array(
object({
part_name: string(),
children_count: number(),
}),
);
/**
* A terminal status for a single image slot, sent as JSON rather than bytes:
* `[index]` means that slot is complete, `[index, reason]` means the server refused to generate it.
*/
const GenerationStatus = union([
strictTuple([number()]),
strictTuple([number(), string()]),
]);
/** One image slot of the layout, addressed on the wire by its flat index. */
export type GeneratedChild = {
/** The flat index this slot is addressed by on the wire. */
index: number;
/** Every image the generator finished for this slot, in the order they arrived. */
images: Uint8Array[];
/** Why the server refused to generate this slot, if it did. */
rejected: string | null;
};
/** One part of the layout, with its image slots in the order the layout declared them. */
export type GeneratedPart = {
part_name: string;
children: GeneratedChild[];
};
/** Joins the chunks buffered for one image into a single array. */
function concatChunks(chunks: Uint8Array[]) {
const out = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0));
let at = 0;
for (const chunk of chunks) {
out.set(chunk, at);
at += chunk.length;
}
return out;
}
/**
* Starts a generation and drains the websocket the generation endpoint hands back.
*
* The layout is always the first message, as JSON, and is passed to writeLayout as soon as it lands
* so the app can put placeholders up before any image data arrives. Every message after that
* addresses one slot of the layout by its flat index across all of it - with parts of 3 and 2
* children, index 4 is the second child of the second part.
*
* Those messages are either:
* - bytes, where the top bit of the first byte says whether this is the last chunk of an image and
* the low 7 bits are the index. The rest of the array is a chunk, buffered here until the done
* bit shows up and we can join them into a finished image.
* - JSON, either `[index]` (that slot is complete) or `[index, reason]` (the server rejected it).
*
* A slot can finish several images before it is marked complete, so the done bit only means "that
* image is whole", never "that slot is over" - only the JSON status ends a slot. Each finished
* image is handed straight to writeImage, along with whether its slot is now complete. Every slot
* ends with exactly one terminal call: writeRejection if the server refused it, otherwise a final
* writeImage with a null image, so a slot that completes with no image trailing its status still
* gets an end.
*
* Resolves with the whole layout once every slot in it is complete or rejected.
*/
export async function generateImages(
codeExchangeUrl: string,
codeExchangeResponse: InferOutput<typeof CodeExchangeResponse>,
writeCodeExchangeResponse: (
body: InferOutput<typeof CodeExchangeResponse>,
) => Promise<void>,
body: Record<string, string | string[]>,
writeLayout: (layout: InferOutput<typeof Layout>) => void,
writeImage: (
index: number,
image: Uint8Array | null,
complete: boolean,
) => void,
writeRejection: (index: number, reason: string) => void,
) {
const resetBody = await refreshIfNeeded(
codeExchangeUrl,
codeExchangeResponse,
);
if (resetBody) await writeCodeExchangeResponse(resetBody);
const res = await fetch(
resetBody?.generation_endpoint_url ||
codeExchangeResponse.generation_endpoint_url,
{
headers: {
Authorization: `Bearer ${resetBody?.access_token || codeExchangeResponse.access_token}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify(body),
},
);
if (!res.ok) throw new Error("failed to get generation websocket url");
const websocketUrl = new URL(await res.text());
if (websocketUrl.protocol !== "wss:")
throw new Error("requires secure websockets");
return new Promise<GeneratedPart[]>((resolve, reject) => {
const ws = new WebSocket(websocketUrl);
ws.binaryType = "arraybuffer";
/** Built from the first message. Nothing else on the socket means anything until we have it. */
let parts: GeneratedPart[] | null = null;
/** Flat index -> where in `parts` that slot lives. Its length is the total slot count. */
const locations: { part: number; child: number }[] = [];
/** Chunks held for the image a slot is part way through, keyed by flat index. */
const chunks = new Map<number, Uint8Array[]>();
const complete = new Set<number>();
let settled = false;
const fail = (err: unknown) => {
if (settled) return;
settled = true;
ws.close();
reject(err);
};
const done = () => {
settled = true;
ws.close();
resolve(parts!);
};
const childAt = (index: number) => {
const at = locations[index];
if (!at) throw new Error(`index ${index} is outside the layout`);
return parts![at.part].children[at.child];
};
ws.onmessage = (ev) => {
try {
if (typeof ev.data === "string") {
const json = JSON.parse(ev.data);
// The layout comes first, and only once.
if (!parts) {
const layout = parse(Layout, json);
parts = layout.map((part) => ({
part_name: part.part_name,
children: [],
}));
for (let part = 0; part < layout.length; part++) {
for (
let child = 0;
child < layout[part].children_count;
child++
) {
parts[part].children.push({
index: locations.length,
images: [],
rejected: null,
});
locations.push({ part, child });
}
}
writeLayout(layout);
// A layout with no slots in it has nothing left to wait for.
if (!locations.length) done();
return;
}
const status = parse(GenerationStatus, json);
const index = status[0];
const child = childAt(index);
if (status.length === 2) {
// Rejected, so the half finished image we were holding is never going to be one.
child.rejected = status[1];
}
chunks.delete(index);
// The status is what ends a slot, whether or not any image came with it.
if (!complete.has(index)) {
complete.add(index);
if (child.rejected !== null) writeRejection(index, child.rejected);
else writeImage(index, null, true);
}
if (complete.size >= locations.length) done();
return;
}
if (!parts)
throw new Error("expected the layout before any image data");
const frame = new Uint8Array(ev.data as ArrayBuffer);
if (!frame.length) throw new Error("got an empty frame");
// Top bit of the header byte is the done flag, the low 7 bits are the flat index.
const isDone = (frame[0] & 0b1000_0000) !== 0;
const index = frame[0] & 0b0111_1111;
const child = childAt(index);
const buffered = chunks.get(index) || [];
buffered.push(frame.subarray(1));
if (!isDone) {
chunks.set(index, buffered);
return;
}
// One whole image, but the slot stays open for more until its status turns up.
chunks.delete(index);
const image = concatChunks(buffered);
child.images.push(image);
writeImage(index, image, complete.has(index));
} catch (err) {
fail(err);
}
};
ws.onerror = () => fail(new Error("generation websocket errored"));
ws.onclose = () =>
fail(
new Error("generation websocket closed before every slot was complete"),
);
});
}
// Remote workspace extension (workspace protocol version 1).
export const Id = v.pipe(v.string(), v.minLength(1), v.maxLength(256));
export const Count = v.pipe(v.number(), v.safeInteger(), v.minValue(0));
export const HttpsURL = v.pipe(
v.string(),
v.url(),
v.check((s) => new URL(s).protocol === "https:"),
);
export const WssURL = v.pipe(
v.string(),
v.url(),
v.check((s) => new URL(s).protocol === "wss:"),
);
// MessagePack bin values decode to Uint8Array; strings and numeric arrays are invalid.
export const Bytes = v.instance(Uint8Array);
// west > east represents a boundary crossing the antimeridian.
export const Bounds = v.pipe(
v.object({
south: v.pipe(v.number(), v.minValue(-90), v.maxValue(90)),
north: v.pipe(v.number(), v.minValue(-90), v.maxValue(90)),
west: v.pipe(v.number(), v.minValue(-180), v.maxValue(180)),
east: v.pipe(v.number(), v.minValue(-180), v.maxValue(180)),
}),
v.check((b) => b.south <= b.north),
);
/** Apply all filters before ranking and pagination; missing EXIF fails date/location filters. */
export const Filters = v.object({
// Match any listed MIME type (an empty list matches nothing), but all listed tags.
mime_types: v.optional(v.array(v.string())),
tags: v.optional(v.array(v.string())),
edited: v.optional(v.boolean()),
content: v.optional(v.picklist(["all", "safe", "flagged"])),
// Inclusive Unix seconds. Rating 0 means unrated.
captured_after: v.optional(Count),
captured_before: v.optional(Count),
min_rating: v.optional(v.pipe(Count, v.maxValue(5))),
bounds: v.optional(Bounds),
});
export type Filters = v.InferOutput<typeof Filters>;
// Unknown or forbidden IDs must produce an error, never fall back to the whole library.
export const Scope = v.variant("kind", [
v.object({ kind: v.literal("library") }),
v.object({
kind: v.literal("folder"),
id: Id,
recursive: v.optional(v.boolean(), true),
}),
v.object({ kind: v.literal("bucket"), id: Id }),
]);
// Search only within scope. Break sort ties by asset ID; offset/limit form a live window.
export const AssetQuery = v.object({
scope: Scope,
text: v.optional(v.string(), ""),
filters: v.optional(Filters, {}),
sort: v.optional(
v.picklist(["relevance", "name", "captured_desc", "modified_desc"]),
"name",
),
offset: v.optional(Count, 0),
limit: v.optional(v.pipe(Count, v.minValue(1), v.maxValue(500)), 100),
});
export type AssetQuery = v.InferInput<typeof AssetQuery>;
export const Folder = v.object({
id: Id,
parent_id: v.nullable(Id),
name: v.string(),
revision: Count,
});
// Bucket contents are manual members UNION rule matches, deduplicated by asset ID.
// Rules may scope to library/folder, never another bucket (which could create cycles).
export const BucketRule = v.object({
scope: Scope,
text: v.string(),
filters: Filters,
});
export const Bucket = v.object({
id: Id,
name: v.string(),
revision: Count,
rule: v.nullable(BucketRule),
});
/** A remote file. Its ID also identifies its collaborative document. */
export const Asset = v.object({
id: Id,
// Folder membership belongs to the asset, not to an editor session or a bucket.
// null means an unfiled asset (for example, a local file dropped into a bucket).
folder_id: v.nullable(Id),
name: v.string(),
mime_type: v.string(),
revision: Count,
size: Count,
edited: v.boolean(),
tags: v.array(v.string()),
rating: v.pipe(Count, v.maxValue(5)),
captured_at: v.nullable(Count),
modified_at: Count,
thumbnail_url: v.nullable(HttpsURL),
});
export type Asset = v.InferOutput<typeof Asset>;
// Catalogue text searches folder/bucket names; AssetQuery text searches their contents.
export const CatalogueQuery = v.object({
text: v.optional(v.string(), ""),
offset: v.optional(Count, 0),
limit: v.optional(v.pipe(Count, v.minValue(1), v.maxValue(500)), 100),
});
export type CatalogueQuery = v.InferInput<typeof CatalogueQuery>;
export const WatchQuery = v.variant("kind", [
v.object({ kind: v.literal("folders"), query: CatalogueQuery }),
v.object({ kind: v.literal("buckets"), query: CatalogueQuery }),
v.object({ kind: v.literal("assets"), query: AssetQuery }),
]);
export type WatchQuery = v.InferInput<typeof WatchQuery>;
const windowFields = { revision: Count, total: Count, offset: Count };
// Each push replaces the requested window, including empty results and deletions.
// Revision increases within a subscription; total counts all matches before pagination.
export const Snapshot = v.variant("kind", [
v.object({
kind: v.literal("folders"),
...windowFields,
items: v.array(Folder),
}),
v.object({
kind: v.literal("buckets"),
...windowFields,
items: v.array(Bucket),
}),
v.object({
kind: v.literal("assets"),
...windowFields,
items: v.array(Asset),
}),
]);
export type Snapshot = v.InferOutput<typeof Snapshot>;
/** Optional headless document engine extension; transport stays workspace v1. */
export const WorkspaceCapabilities = v.object({
document_models: v.array(Id),
formats: v.array(
v.object({
id: Id,
name: v.string(),
extensions: v.array(v.string()),
can_export: v.boolean(),
// A codec can require a runtime library, e.g. HEIC needs libheif/HEVC.
runtime_requirement: v.nullable(v.string()),
}),
),
max_frame_bytes: Count,
max_document_bytes: Count,
default_edited_export: v.string(),
original_download: v.boolean(),
});
/**
* Native image model: schist.image.v1 (all root-map values are Uint8Array).
* The shared schist-document Rust library is the desktop/provider reference
* implementation. Importers and exporters use the same built-in codec registry.
* A provider initializes an existing asset from saved bytes exactly once under
* the room's join transaction, persists before replying, and never re-seeds an
* existing native model from an older export. Reserve Yjs client ID 1 for the
* deterministic initial seed; ordinary participants use other client IDs.
*
* Keys:
* - document/size: MessagePack [width, height, resolution_dpi]
* - document/title: UTF-8
* - document/metadata: layerless 1x1 PSD preserving document metadata
* - document/comps: MessagePack [stable_layer_references, layer_comps]
* - layer/<id>/placement: MessagePack [parent_id ("root" at top), sibling_rank]
* - layer/<id>/template: one-layer 1x1 PSD, no raster/mask tiles or child layers
* - layer/<id>/name: UTF-8
* - layer/<id>/visible, locked, clipping: one boolean byte
* - layer/<id>/opacity, fill: little-endian float32
* - layer/<id>/blend: four-byte PSD blend key
* - layer/<id>/pixels/<x>/<y>: depth byte 8/16/32, then one full RGBA tile;
* multibyte samples are little-endian (Schist tiles are 256x256 pixels)
* - layer/<id>/mask/<x>/<y>: one full single-channel 8-bit mask tile
*
* Initial IDs are deterministic seed/<sibling index>/... paths; new layers use
* UUIDs. Placement is atomic, ordered by rank then stable ID. Each property and
* tile merges independently. Concurrent writes to one tile use Yjs resolution,
* not a per-pixel merge. Local selections, transient tools and undo history are
* not serialized. The provider validates the materialized model before accepting
* edits and exports current committed state; it retains original upload bytes.
* Source-format and export-format capabilities are independent, and retain the
* reference codec's actual fidelity limits. User-installed codecs must also be
* installed/trusted on a provider before it advertises support for them.
*
* A provider may impose a lower document limit than the 256 MiB wire ceiling.
* Discover it with workspace.capabilities; oversized edits fail explicitly.
* This extension does not change the legacy image-generation socket format.
*/
export const Failure = v.object({ code: Id, message: v.string() });
export const ServerMessage = v.variant("type", [
v.object({ type: v.literal("ready"), protocol: v.literal(1) }),
v.object({ type: v.literal("result"), id: Id, value: v.unknown() }),
v.object({ type: v.literal("error"), id: Id, error: Failure }),
v.object({
type: v.literal("snapshot"),
subscription_id: Id,
snapshot: Snapshot,
}),
v.object({
type: v.literal("watch_error"),
subscription_id: Id,
error: Failure,
}),
v.object({
type: v.literal("document_update"),
document_id: Id,
update: Bytes,
}),
v.object({
type: v.literal("document_error"),
document_id: Id,
error: Failure,
}),
v.object({ type: v.literal("auth_expiring") }),
v.object({ type: v.literal("pong") }),
]);
export type ServerMessage = v.InferOutput<typeof ServerMessage>;
type ServerReply = Extract<ServerMessage, { type: "result" | "error" }>;
type Failure = v.InferOutput<typeof Failure>;
// Every workspace WebSocket message is one MessagePack map in a binary frame.
// Field names remain strings; updates/state vectors use bin values (Uint8Array).
// Omit absent optional fields; use nil only where the schema explicitly allows null.
// Requests/replies and pushes share one connection. Request IDs correlate replies;
// subscription/document IDs route updates independently of requests in flight.
type ClientMessage =
| { type: "hello"; protocol: 1; access_token: string }
| { type: "request"; id: string; method: string; params: unknown }
| { type: "subscribe"; subscription_id: string; query: WatchQuery }
| { type: "unsubscribe"; subscription_id: string }
| { type: "ping" };
export class RemoteError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
}
}
type ConnectionState = "connecting" | "connected" | "disconnected" | "closed";
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export type RemoteWorkspaceOptions = {
/** WASM/browser builds must enable the official origin and endpoint restrictions. */
browserCloud?: boolean;
codeExchangeUrl: string;
credentials: InferOutput<typeof CodeExchangeResponse>;
writeCredentials: (
body: InferOutput<typeof CodeExchangeResponse>,
) => Promise<void>;
onError: (error: Error) => void;
onState?: (state: ConnectionState) => void;
/** Injectable for native hosts/tests; production must preserve TLS validation. */
socketFactory?: (url: string) => WebSocket;
};
type Watch = {
query: WatchQuery;
revision: number;
receive: (snapshot: Snapshot) => void;
error: (error: Error) => void;
};
type DocumentBinding = {
doc: Y.Doc;
joined: boolean;
epoch: number;
pending: Uint8Array[];
sending: Promise<void> | null;
joining: Promise<void> | null;
listener: (update: Uint8Array, origin: unknown) => void;
error: (error: Error) => void;
};
export type BucketDrop =
| { kind: "asset"; id: string }
| { kind: "folder"; id: string; recursive?: boolean }
| { kind: "local"; name: string; bytes: Blob; relative_path?: string };
const MutationResult = v.object({ id: Id, revision: Count });
const UploadTicket = v.object({ upload_id: Id, put_url: HttpsURL });
const JoinResult = v.object({ update: Bytes, state_vector: Bytes });
const Ack = v.object({});
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
const HANDSHAKE_TIMEOUT_MS = 15_000;
const REQUEST_TIMEOUT_MS = 30_000;
const HEARTBEAT_INTERVAL_MS = 20_000;
const HEARTBEAT_TIMEOUT_MS = 60_000;
const TOKEN_REFRESH_MARGIN_SECONDS = 30;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
/**
* One instance per signed-in account: folders, buckets and editors share its socket.
* workspace protocol version 1 is independent of the original schist_spec_version.
* Uses valibot, yjs and @msgpack/msgpack. Legacy generation keeps its existing wire format.
*/
export class RemoteWorkspace {
private credentials: InferOutput<typeof CodeExchangeResponse>;
private socket: WebSocket | null = null;
private ready = false;
private stopped = false;
private retry = 0;
private refreshRequired = false;
private retryTimer?: ReturnType<typeof setTimeout>;
private heartbeat?: ReturnType<typeof setInterval>;
private openTimer?: ReturnType<typeof setTimeout>;
private lastMessage = Date.now();
private pending = new Map<string, PendingRequest>();
private watches = new Map<string, Watch>();
private documents = new Map<string, DocumentBinding>();
constructor(private readonly options: RemoteWorkspaceOptions) {
if (options.browserCloud) {
officialBrowser();
officialCloudUrl(options.codeExchangeUrl);
browserCredentials(options.credentials);
if (options.socketFactory) throw new Error("Browser cloud uses the browser WebSocket implementation");
}
this.credentials = options.credentials;
if (!this.credentials.workspace_websocket_url)
throw new Error("provider has no remote workspace endpoint");
v.parse(WssURL, this.credentials.workspace_websocket_url);
void this.connect();
}
get connected() {
return this.ready;
}
private report(error: unknown) {
try {
this.options.onError(asError(error));
} catch {
// Reporting a UI error must not interrupt transport cleanup or report recursively.
}
}
private notify(callback: () => void) {
try {
callback();
} catch (error) {
// User callbacks are isolated from protocol errors: a broken view need not disconnect.
this.report(error);
}
}
private state(state: ConnectionState) {
this.notify(() => this.options.onState?.(state));
}
private async connect() {
if (this.stopped) return;
this.state("connecting");
try {
await this.refreshCredentials();
if (this.stopped) return;
const url = v.parse(WssURL, this.credentials.workspace_websocket_url);
if (this.options.browserCloud) officialCloudUrl(url, true);
const createSocket =
this.options.socketFactory ?? ((url) => new WebSocket(url));
this.attachSocket(createSocket(url));
} catch (error) {
this.report(error);
this.scheduleReconnect();
}
}
private async refreshCredentials() {
const refreshAt = Date.now() / 1000 + TOKEN_REFRESH_MARGIN_SECONDS;
if (!this.refreshRequired && this.credentials.expires_at > refreshAt)
return;
const fresh = await performTokenRefresh(
this.options.codeExchangeUrl,
this.credentials.refresh_token,
);
// Refresh tokens can rotate. Keep the new token even if persistence fails.
if (this.options.browserCloud) browserCredentials(fresh);
this.credentials = fresh;
this.refreshRequired = false;
await this.options.writeCredentials(fresh);
}
private attachSocket(socket: WebSocket) {
// Decode synchronously in arrival order; Blob conversion could reorder messages.
socket.binaryType = "arraybuffer";
this.socket = socket;
this.openTimer = setTimeout(
() => this.disconnect(new Error("workspace handshake timed out")),
HANDSHAKE_TIMEOUT_MS,
);
// These callbacks only dispatch. A single boundary catches malformed frames,
// socket send failures and late events from a superseded connection.
socket.onopen = () =>
this.handleSocketEvent(socket, () => this.authenticate());
socket.onmessage = (event) =>
this.handleSocketEvent(socket, () => this.receive(event.data));
socket.onerror = () =>
this.handleSocketEvent(socket, () => {
throw new Error("workspace socket failed");
});
socket.onclose = (event) => this.handleClose(socket, event.code);
}
private handleSocketEvent(socket: WebSocket, handle: () => void) {
if (this.socket !== socket || this.stopped) return;
try {
handle();
} catch (error) {
this.disconnect(error);
}
}
private authenticate() {
// Before ready, only hello is permitted. Authenticate in the frame rather than
// putting a bearer token in a URL that proxies or browser history could retain.
// Server authenticates before ready and checks permissions on every request,
// subscription and document update, including after access is revoked.
this.send({
type: "hello",
protocol: 1,
access_token: this.credentials.access_token,
});
}
private receive(data: unknown) {
// A frame queued before disconnect must not reopen a connection being closed.
if (this.socket?.readyState !== WebSocket.OPEN) return;
if (!(data instanceof ArrayBuffer) || data.byteLength > MAX_FRAME_BYTES) {
throw new Error("invalid workspace frame");
}
// decode consumes exactly one value, rejecting truncated or trailing data.
// Bound advertised lengths too, before a malformed packet can allocate a huge container.
const message = v.parse(
ServerMessage,
decode(new Uint8Array(data), {
maxStrLength: MAX_FRAME_BYTES,
maxBinLength: MAX_FRAME_BYTES,
maxArrayLength: MAX_FRAME_BYTES,
maxMapLength: MAX_FRAME_BYTES,
maxExtLength: 0,
}),
);
this.lastMessage = Date.now();
if (message.type !== "ready" && !this.ready) {
throw new Error("expected workspace ready");
}
switch (message.type) {
case "ready":
return this.handleReady();
case "result":
case "error":
return this.handleReply(message);
case "snapshot":
return this.handleSnapshot(message.subscription_id, message.snapshot);
case "watch_error":
return this.handleWatchError(message.subscription_id, message.error);
case "document_update":
return this.handleDocumentUpdate(message.document_id, message.update);
case "document_error":
return this.handleDocumentError(message.document_id, message.error);
case "auth_expiring":
this.refreshRequired = true;
return this.disconnect(new Error("workspace token renewal"));
case "pong":
return; // Receipt already refreshed lastMessage for the heartbeat.
}
}
private handleReady() {
if (this.ready) throw new Error("duplicate workspace handshake");
clearTimeout(this.openTimer);
this.ready = true;
this.retry = 0;
this.heartbeat = setInterval(
() => this.checkHeartbeat(),
HEARTBEAT_INTERVAL_MS,
);
// Watches restart with full snapshots. Documents instead exchange state vectors
// so both sides recover missing edits, including changes made while offline.
for (const [id, watch] of this.watches) {
watch.revision = -1;
this.send({ type: "subscribe", subscription_id: id, query: watch.query });
}
for (const [assetId, binding] of this.documents)
void this.join(assetId, binding);
this.state("connected");
}
private checkHeartbeat() {
if (!this.socket || !this.ready) return;
this.handleSocketEvent(this.socket, () => {
if (Date.now() - this.lastMessage > HEARTBEAT_TIMEOUT_MS) {
throw new Error("workspace heartbeat timed out");
}
this.send({ type: "ping" });
});
}
private handleReply(message: ServerReply) {
const request = this.pending.get(message.id);
if (!request) return; // A late reply cannot resolve a request that already timed out.
this.pending.delete(message.id);
clearTimeout(request.timer);
if (message.type === "error") {
request.reject(
new RemoteError(message.error.code, message.error.message),
);
} else {
request.resolve(message.value);
}
}
private handleSnapshot(subscriptionId: string, snapshot: Snapshot) {
const watch = this.watches.get(subscriptionId);
// Query replacement uses a new ID; ignore cancelled watches and stale results.
if (!watch || snapshot.revision <= watch.revision) return;
if (watch.query.kind !== snapshot.kind)
throw new Error("wrong subscription snapshot kind");
watch.revision = snapshot.revision;
this.notify(() => watch.receive(snapshot));
}
private handleWatchError(subscriptionId: string, error: Failure) {
const watch = this.watches.get(subscriptionId);
// A rejected/revoked subscription is terminal; reconnect must not resurrect it.
this.watches.delete(subscriptionId);
this.notify(() => watch?.error(new RemoteError(error.code, error.message)));
}
private handleDocumentUpdate(assetId: string, update: Uint8Array) {
const binding = this.documents.get(assetId);
if (!binding) return;
// Mark the transaction as remote so the local listener does not echo it back.
Y.applyUpdate(binding.doc, update, this);
}
private handleDocumentError(assetId: string, error: Failure) {
const binding = this.documents.get(assetId);
if (!binding) return;
this.detachDocument(assetId, binding);
this.notify(() =>
binding.error(new RemoteError(error.code, error.message)),
);
}
private handleClose(socket: WebSocket, code: number) {
if (this.socket !== socket) return;
if (code === 4401) this.refreshRequired = true;
this.socket = null;
this.reset(
new Error(
"workspace disconnected; outstanding mutation outcomes may be unknown",
),
);
// Reconnect only after close, including token renewal: never overlap sockets.
this.scheduleReconnect();
}
private scheduleReconnect() {
if (this.stopped || this.retryTimer) return;
this.state("disconnected");
// Cap exponential backoff and add jitter so reconnecting clients spread out.
const delay =
Math.min(30_000, 500 * 2 ** Math.min(this.retry++, 6)) *
(0.75 + Math.random() / 2);
this.retryTimer = setTimeout(() => {
this.retryTimer = undefined;
void this.connect();
}, delay);
}
private reset(error: Error) {
this.ready = false;
clearTimeout(this.openTimer);
clearInterval(this.heartbeat);
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(error);
}
this.pending.clear();
for (const binding of this.documents.values()) {
binding.joined = false;
binding.epoch++;
binding.sending = null;
binding.joining = null;
}
}
private disconnect(error: unknown) {
this.report(error);
this.reset(asError(error));
if (this.socket) this.socket.close();
else this.scheduleReconnect();
}
private send(message: ClientMessage) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
throw new Error("workspace is not connected");
// Preserve Uint8Array as MessagePack bin, and keep optional fields absent.
const frame = encode(message, { ignoreUndefined: true });
if (frame.byteLength > MAX_FRAME_BYTES)
throw new Error(
"workspace frame exceeds 256 MiB; split document transactions",
);
if (this.socket.bufferedAmount > MAX_FRAME_BYTES)
throw new Error("workspace send buffer is full");
this.socket.send(frame);
}
// Mutations carry a stable mutation_id in params, distinct from this request ID.
// The server persists/replays identical results per principal + mutation_id and
// rejects reuse with different payloads. Never blindly replay a timed-out mutation.
private request(method: string, params: unknown): Promise<unknown> {
if (!this.ready)
return Promise.reject(new Error("workspace is not connected"));
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new RemoteError(
"timeout",
`${method} timed out; mutation outcome may be unknown`,
),
);
}, REQUEST_TIMEOUT_MS);
this.pending.set(id, { resolve, reject, timer });
try {
this.send({ type: "request", id, method, params });
} catch (err) {
clearTimeout(timer);
this.pending.delete(id);
reject(err);
}
});
}
/** Watch a live result window; close this watch and create another to change its query. */
watch(
query: WatchQuery,
receive: (snapshot: Snapshot) => void,
onError: (error: Error) => void = (e) => this.report(e),
) {
if (this.stopped) throw new Error("workspace is closed");
const id = crypto.randomUUID();
const parsed = v.parse(WatchQuery, query);
// The server must register and snapshot atomically, then reevaluate membership,
// filters, ranking and paging on changes. It may coalesce snapshots for slow readers,
// but must not drop document updates; disconnect if the outgoing queue overflows.
this.watches.set(id, {
query: parsed,
receive,
error: onError,
revision: -1,
});
if (this.ready) {
try {
this.send({ type: "subscribe", subscription_id: id, query: parsed });
} catch (err) {
this.disconnect(err);
}
}
return () => {
if (!this.watches.delete(id) || !this.ready) return;
try {
this.send({ type: "unsubscribe", subscription_id: id });
} catch (err) {
this.disconnect(err);
}
};
}
watchFolders(query: CatalogueQuery, receive: (snapshot: Snapshot) => void) {
return this.watch({ kind: "folders", query }, receive);
}
watchBuckets(query: CatalogueQuery, receive: (snapshot: Snapshot) => void) {
return this.watch({ kind: "buckets", query }, receive);
}
/** Empty text lists assets; nonempty text searches the same live, filtered scope. */
watchAssets(query: AssetQuery, receive: (snapshot: Snapshot) => void) {
return this.watch({ kind: "assets", query }, receive);
}
async createBucket(
name: string,
rule: v.InferInput<typeof BucketRule> | null = null,
mutationId: string = crypto.randomUUID(),
) {
if (rule?.scope.kind === "bucket")
throw new Error("a bucket rule cannot scope to another bucket");
return v.parse(
Bucket,
await this.request("bucket.create", {
name,
rule: v.parse(v.nullable(BucketRule), rule),
mutation_id: v.parse(Id, mutationId),
}),
);
}
async updateBucket(
id: string,
revision: number,
name: string,
rule: v.InferInput<typeof BucketRule> | null,
mutationId: string = crypto.randomUUID(),
) {
if (rule?.scope.kind === "bucket")
throw new Error("a bucket rule cannot scope to another bucket");
return v.parse(
Bucket,
await this.request("bucket.update", {
id: v.parse(Id, id),
revision: v.parse(Count, revision),
name,
rule: v.parse(v.nullable(BucketRule), rule),
mutation_id: v.parse(Id, mutationId),
}),
);
}
/** Delete the bucket and its membership, never the original assets. */
async deleteBucket(
id: string,
revision: number,
mutationId: string = crypto.randomUUID(),
) {
v.parse(
Ack,
await this.request("bucket.delete", {
id: v.parse(Id, id),
revision: v.parse(Count, revision),
mutation_id: v.parse(Id, mutationId),
}),
);
}
async createFolder(
name: string,
parentId: string | null = null,
mutationId: string = crypto.randomUUID(),
) {
return v.parse(
Folder,
await this.request("folder.create", {
name,
parent_id: v.parse(v.nullable(Id), parentId),
mutation_id: v.parse(Id, mutationId),
}),
);
}
async renameFolder(
id: string,
revision: number,
name: string,
mutationId: string = crypto.randomUUID(),
) {
return v.parse(
Folder,
await this.request("folder.update", {
id: v.parse(Id, id),
revision: v.parse(Count, revision),
name,
mutation_id: v.parse(Id, mutationId),
}),
);
}
/** The server rejects deletion of a nonempty folder. */
async deleteFolder(
id: string,
revision: number,
mutationId: string = crypto.randomUUID(),
) {
v.parse(
Ack,
await this.request("folder.delete", {
id: v.parse(Id, id),
revision: v.parse(Count, revision),
mutation_id: v.parse(Id, mutationId),
}),
);
}
/**
* Add membership without moving originals. Expand remote folders atomically at commit.
* IDs belong to this account; cross-provider items must be downloaded and uploaded.
*/
async addToBucket(
id: string,
items: Array<
| { kind: "asset"; id: string }
| { kind: "folder"; id: string; recursive?: boolean }
>,
mutationId: string = crypto.randomUUID(),
) {
const Item = v.variant("kind", [
v.object({ kind: v.literal("asset"), id: Id }),
v.object({
kind: v.literal("folder"),
id: Id,
recursive: v.optional(v.boolean(), true),
}),
]);
return v.parse(
MutationResult,
await this.request("bucket.add", {
id: v.parse(Id, id),
items: v.parse(v.array(Item), items),
mutation_id: v.parse(Id, mutationId),
}),
);
}
/** Removes manual membership; a matching smart rule can still include the asset. */
async removeFromBucket(
id: string,
assetIds: string[],
mutationId: string = crypto.randomUUID(),
) {
return v.parse(
MutationResult,
await this.request("bucket.remove", {
id: v.parse(Id, id),
asset_ids: v.parse(v.array(Id), assetIds),
mutation_id: v.parse(Id, mutationId),
}),
);
}
/**
* Import a new file into target.folder_id, or save an existing asset with asset_id
* + revision. New files without folder_id are unfiled; saves without it keep their
* current folder. relative_path describes a local import path, not a remote folder ID.
* prepare/commit IDs derive from mutationId, so retrying the same call is safe.
* Server keeps a ticket usable for identical retries until its documented expiry.
*/
async uploadAsset(
file: { name: string; bytes: Blob; relative_path?: string },
target: { folder_id?: string; asset_id?: string; revision?: number } = {},
mutationId: string = crypto.randomUUID(),
signal?: AbortSignal,
) {
v.parse(Id, mutationId);
if ((target.asset_id === undefined) !== (target.revision === undefined))
throw new Error("a save requires asset_id and revision together");
// The server must enforce the same path rules; a client check is only early feedback.
if (
file.relative_path !== undefined &&
(file.relative_path.includes("\\") ||
file.relative_path.includes("\0") ||
file.relative_path
.split("/")
.some((p) => !p || p === "." || p === "..") ||
/^[A-Za-z]:/.test(file.relative_path))
) {
throw new Error("relative_path must be a safe relative path");
}
const parsedTarget = v.parse(
v.object({
folder_id: v.optional(Id),
asset_id: v.optional(Id),
revision: v.optional(Count),
}),
target,
);
signal?.throwIfAborted();
const ticket = v.parse(
UploadTicket,
await this.request("asset.prepare_upload", {
...parsedTarget,
name: file.name,
relative_path: file.relative_path,
mime_type: file.bytes.type || "application/octet-stream",
size: file.bytes.size,
mutation_id: `${mutationId}:prepare`,
}),
);
// Raw bytes go to a short-lived HTTPS upload ticket without an account token.
// Collaboration (including paint data) always travels through document.update.
if (this.options.browserCloud) officialUploadUrl(ticket.put_url);
const response = await fetch(ticket.put_url, {
method: "PUT",
body: file.bytes,
signal,
credentials: "omit",
redirect: "error",
headers: {
"Content-Type": file.bytes.type || "application/octet-stream",
},
});
if (!response.ok) throw new Error(`upload failed (${response.status})`);
signal?.throwIfAborted();
// Server verifies ownership/bytes/size and rechecks the expected revision before
// publishing. Reject stale saves with "conflict" and saves to an active collab room.
// Uncommitted uploads expire. All mutations must commit before replying/publishing.
return v.parse(
Asset,
await this.request("asset.commit_upload", {
upload_id: ticket.upload_id,
mutation_id: `${mutationId}:commit`,
}),
);
}
/**
* Drop a selection of local files, remote assets and/or whole remote folders.
* Enumerate local folders as local items with relative_path (File.webkitRelativePath).
* Uploads finish first; membership is then added atomically. On failure, successful
* uploads may exist in the remote library; retry the same items with the same ID.
*/
async dropIntoBucket(
bucketId: string,
items: BucketDrop[],
mutationId: string = crypto.randomUUID(),
signal?: AbortSignal,
) {
v.parse(Id, bucketId);
v.parse(Id, mutationId);
const remote: Array<
| { kind: "asset"; id: string }
| { kind: "folder"; id: string; recursive?: boolean }
> = [];
for (const [index, item] of items.entries()) {
signal?.throwIfAborted();
if (item.kind === "local") {
const asset = await this.uploadAsset(
item,
{},
`${mutationId}:${index}`,
signal,
);
remote.push({ kind: "asset", id: asset.id });
} else remote.push(item);
}
signal?.throwIfAborted();
return this.addToBucket(bucketId, remote, mutationId);
}
/** Optional extension. Older providers may return method_not_found. */
async getCapabilities() {
return v.parse(WorkspaceCapabilities, await this.request('workspace.capabilities', {}));
}
/**
* Download a revision-consistent export through a short-lived HTTPS URL.
* Omit format for the provider's current editable export (PSD/PSB for the
* schist.image.v1 model); use "original" for immutable imported bytes, or an
* advertised export extension such as "png". Import-only formats remain fully
* editable in collaboration; they do not imply an encoder for that source format.
* Decode by file signature and honor Content-Type/Content-Disposition: an edited
* camera raw or HEIC can be returned as a layered PSD, not its original format.
* A 409 on GET means the asset advanced before the ticket was used: request a
* fresh download URL. Bytes served must correspond to the returned revision.
*/
async downloadAsset(id: string, format?: string) {
const ticket = v.parse(
v.object({ url: HttpsURL, revision: Count }),
await this.request('asset.download', {
id: v.parse(Id, id),
...(format === undefined
? {}
: {
format: v.parse(v.pipe(v.string(), v.regex(/^[a-z0-9]+$/), v.maxLength(20)), format),
}),
}),
);
if (this.options.browserCloud) officialCloudUrl(ticket.url);
return ticket;
}
/**
* Open collaboration for an EXISTING asset: assetId is Asset.id, and the server
* resolves its folder through Asset.folder_id. Opening never creates or moves a file.
* To import into a folder, uploadAsset(file, { folder_id }) first, then open its ID.
* Local/offline changes stay in the caller-owned Y.Doc; persist it to survive exit.
* onError surfaces unsaved/rejected edits. close() does not save or destroy the doc.
*/
openDocument(
assetId: string,
doc: Y.Doc = new Y.Doc(),
onError: (error: Error) => void = (e) => this.report(e),
) {
v.parse(Id, assetId);
if (this.stopped) throw new Error("workspace is closed");
if (this.documents.has(assetId))
throw new Error("document already bound to this workspace");
if ([...this.documents.values()].some((b) => b.doc === doc))
throw new Error("a Y.Doc cannot represent two remote documents");
const binding: DocumentBinding = {
doc,
joined: false,
epoch: 0,
pending: [],
sending: null,
joining: null,
error: onError,
listener: (update, origin) => {
// Applying a remote update uses this workspace as its transaction origin.
if (origin === this) return;
binding.pending.push(update);
// Coalesce local transactions while offline or waiting for an acknowledgement.
if (binding.pending.length > 16)
binding.pending = [Y.mergeUpdates(binding.pending)];
void this.pump(assetId, binding);
},
};
this.documents.set(assetId, binding);
doc.on("update", binding.listener);
if (this.ready) void this.join(assetId, binding);
return {
assetId,
doc,
/** Await the initial join (or retry a failed join) before editing/flush. */
synchronize: async () => {
if (!this.ready || this.documents.get(assetId) !== binding)
throw new Error("workspace/document is not connected");
await this.join(assetId, binding);
if (!binding.joined) throw new Error("document synchronization failed");
},
/** Resolve after queued edits are acknowledged; reject if not joined/connected. */
flush: async () => {
do {
if (!binding.joined || this.documents.get(assetId) !== binding)
throw new Error("document is not synchronized");
await this.pump(assetId, binding);
} while (binding.pending.length || binding.sending);
if (!binding.joined)
throw new Error("document disconnected before flush completed");
},
close: () => {
if (this.documents.get(assetId) !== binding) return;
this.detachDocument(assetId, binding);
if (this.ready)
void this.request("document.leave", { document_id: assetId }).catch(
(e) => this.report(e),
);
},
};
}
private detachDocument(assetId: string, binding: DocumentBinding) {
this.documents.delete(assetId);
binding.doc.off("update", binding.listener);
binding.joined = false;
binding.epoch++;
binding.sending = null;
}
private join(assetId: string, binding: DocumentBinding): Promise<void> {
if (binding.joining) return binding.joining;
if (binding.joined) return Promise.resolve();
// Disconnect/detach advances the epoch, preventing late replies from changing
// a replacement binding or consuming updates queued for a newer connection.
const epoch = ++binding.epoch;
const joining = this.performJoin(assetId, binding, epoch).finally(() => {
if (binding.epoch === epoch) binding.joining = null;
});
binding.joining = joining;
return joining;
}
private async performJoin(
assetId: string,
binding: DocumentBinding,
epoch: number,
) {
try {
// document_id is the asset ID, not a folder ID or a new session identifier.
// Server must resolve an existing authorized asset and initialize its shared
// model from the saved edit once. Atomically join and return a diff + vector
// from the same server state, so edits cannot fall into a snapshot/subscribe gap.
const result = v.parse(
JoinResult,
await this.request("document.join", {
document_id: assetId,
state_vector: Y.encodeStateVector(binding.doc),
}),
);
if (this.documents.get(assetId) !== binding || binding.epoch !== epoch)
return;
Y.applyUpdate(binding.doc, result.update, this);
// Includes local edits made DURING join, as well as edits made offline.
binding.pending = [
Y.encodeStateAsUpdate(binding.doc, result.state_vector),
];
binding.joined = true;
await this.pump(assetId, binding);
} catch (err) {
if (binding.epoch !== epoch) return;
binding.joined = false;
this.notify(() => binding.error(asError(err)));
}
}
private pump(assetId: string, binding: DocumentBinding): Promise<void> {
if (binding.sending) return binding.sending;
if (!this.ready || !binding.joined || !binding.pending.length)
return Promise.resolve();
const epoch = binding.epoch;
// Only one update is in flight per document. Keep the batch until acknowledged;
// server must persist/apply it before replying and broadcasting to joined peers.
// Yjs v1 updates are idempotent: https://docs.yjs.dev/api/document-updates
const update = Y.mergeUpdates(binding.pending);
binding.pending = [];
const sending = this.request("document.update", {
document_id: assetId,
update,
})
.then((result) => {
v.parse(Ack, result);
})
.catch((err) => {
if (binding.epoch !== epoch) return;
binding.pending.unshift(update);
binding.joined = false; // Explicit error; no busy retry loop for forbidden/invalid updates.
this.notify(() => binding.error(asError(err)));
})
.finally(() => {
if (binding.epoch !== epoch) return;
binding.sending = null;
if (binding.joined && binding.pending.length)
void this.pump(assetId, binding);
});
binding.sending = sending;
return sending;
}
close() {
if (this.stopped) return;
this.stopped = true;
clearTimeout(this.retryTimer);
this.reset(new Error("workspace closed"));
for (const [id, binding] of this.documents)
this.detachDocument(id, binding);
this.watches.clear();
this.socket?.close();
this.state("closed");
}
}
/**
* Example (all these views and documents use the same socket):
*
* const remote = new RemoteWorkspace({ codeExchangeUrl, credentials,
* writeCredentials: persistLogin, onError: showError });
* const stopFolders = remote.watchFolders({}, renderFolders);
* const stopBuckets = remote.watchBuckets({ text: "holiday" }, renderBuckets);
* let stopPhotos = remote.watchAssets({
* scope: { kind: "folder", id: "folder-1" }, text: "sunset",
* filters: { content: "safe", edited: true }, sort: "relevance",
* }, renderPhotos);
* // Changing search/scope/filters: cancel the old watch, start a new one.
* stopPhotos();
* stopPhotos = remote.watchAssets({
* scope: { kind: "bucket", id: "bucket-1" }, text: "beach",
* filters: { min_rating: 3 },
* }, renderPhotos);
* // Once onState reports connected, mutations can be sent.
* await remote.dropIntoBucket("bucket-1", [
* { kind: "folder", id: "folder-1", recursive: true },
* { kind: "local", name: file.name, bytes: file, relative_path: file.webkitRelativePath || undefined },
* ]);
* // Folder association is set on import. Later, a gallery click can pass any
* // existing Asset.id directly to openDocument, including assets shown in a bucket.
* const asset = await remote.uploadAsset(
* { name: file.name, bytes: file }, { folder_id: "folder-1" },
* );
* const editing = remote.openDocument(asset.id);
* await editing.synchronize();
* editing.doc.getMap("properties").set("title", "Summer");
* // The editor maps operations to Yjs types: layer order -> Y.Array of stable IDs,
* // layer properties -> nested Y.Maps, paint tiles -> Uint8Array data at stable tile keys.
* // Concurrent writes to a single key use Yjs conflict resolution; this is not a
* // PSD merge algorithm. The Rust editor binding comes later.
* await editing.flush();
* editing.close();
* stopFolders(); stopBuckets(); stopPhotos(); remote.close();
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment