ubiquitous-invention/apps/web/lib/yjs-provider.ts

263 lines
6.9 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import * as Y from "yjs";
import {
HocuspocusProvider,
WebSocketStatus,
type onStatusParameters,
type onSyncedParameters,
} from "@hocuspocus/provider";
/** Minimal surface used for presence (avoids y-protocols direct import). */
export type AwarenessApi = {
getStates(): Map<number, { user?: Record<string, unknown> }>;
on(event: "change", fn: () => void): void;
off(event: "change", fn: () => void): void;
};
export type UseCollaborationOptions = {
/** Auth token or resolver (session); placeholder used when unset */
token?: string | (() => string | Promise<string | null>) | null;
};
type CacheEntry = {
doc: Y.Doc;
provider: HocuspocusProvider;
refCount: number;
};
const collabCache = new Map<string, CacheEntry>();
function resolveToken(
token: UseCollaborationOptions["token"],
): string | (() => string) | (() => Promise<string>) | null {
if (token === undefined || token === null) {
return "dev-placeholder-token";
}
if (typeof token === "string") {
return token;
}
return async () => {
const raw = await Promise.resolve(token());
return typeof raw === "string" ? raw : "";
};
}
function acquireEntry(
documentName: string,
options: UseCollaborationOptions | undefined,
): CacheEntry {
let entry = collabCache.get(documentName);
if (!entry) {
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
name: documentName,
document: doc,
url: getCollabUrl(),
token: resolveToken(options?.token),
});
entry = { doc, provider, refCount: 0 };
collabCache.set(documentName, entry);
}
entry.refCount += 1;
return entry;
}
function releaseEntry(documentName: string) {
const entry = collabCache.get(documentName);
if (!entry) return;
entry.refCount -= 1;
if (entry.refCount <= 0) {
try {
entry.provider.destroy();
} catch {
/* ignore */
}
try {
entry.doc.destroy();
} catch {
/* ignore */
}
collabCache.delete(documentName);
}
}
/**
* WebSocket URL for the collaboration server.
* Uses `NEXT_PUBLIC_COLLAB_URL`, then `COLLAB_SERVER_URL`, then ws://localhost:1234.
*/
/**
* Ask the cached provider for this document to open the WebSocket again (e.g. user clicked Retry).
*/
export function reconnectCollaboration(documentName: string) {
const entry = collabCache.get(documentName);
const ws = entry?.provider?.configuration?.websocketProvider;
if (ws && typeof (ws as { connect?: () => void }).connect === "function") {
void (ws as { connect: () => Promise<unknown> }).connect();
}
}
export function getCollabUrl(): string {
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL) {
return process.env.NEXT_PUBLIC_COLLAB_URL;
}
if (typeof process !== "undefined" && process.env.COLLAB_SERVER_URL) {
return process.env.COLLAB_SERVER_URL;
}
return "ws://localhost:1234";
}
export type CollaborationConnection = {
doc: Y.Doc | null;
provider: HocuspocusProvider | null;
awareness: AwarenessApi | null;
isConnected: boolean;
isSynced: boolean;
wsStatus: WebSocketStatus;
error: Error | null;
};
/**
* Yjs document + Hocuspocus provider for a named document.
* Providers are cached per `documentName` and shared across hook instances (ref-counted).
*/
export function useCollaboration(
documentName: string,
options?: UseCollaborationOptions,
): CollaborationConnection {
const [doc, setDoc] = React.useState<Y.Doc | null>(null);
const [provider, setProvider] = React.useState<HocuspocusProvider | null>(null);
const [isConnected, setIsConnected] = React.useState(false);
const [isSynced, setIsSynced] = React.useState(false);
const [wsStatus, setWsStatus] = React.useState<WebSocketStatus>(
WebSocketStatus.Disconnected,
);
const [error, setError] = React.useState<Error | null>(null);
const optionsRef = React.useRef(options);
optionsRef.current = options;
React.useEffect(() => {
if (typeof window === "undefined") return;
setError(null);
let entry: CacheEntry;
try {
entry = acquireEntry(documentName, optionsRef.current);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
setDoc(null);
setProvider(null);
return;
}
const { doc: yDoc, provider: hp } = entry;
setDoc(yDoc);
setProvider(hp);
setIsConnected(hp.configuration.websocketProvider.status === WebSocketStatus.Connected);
setWsStatus(hp.configuration.websocketProvider.status);
setIsSynced(hp.synced);
const onStatus = ({ status }: onStatusParameters) => {
setWsStatus(status);
setIsConnected(status === WebSocketStatus.Connected);
};
const onSynced = ({ state }: onSyncedParameters) => {
setIsSynced(state);
};
hp.on("status", onStatus);
hp.on("synced", onSynced);
return () => {
hp.off("status", onStatus);
hp.off("synced", onSynced);
releaseEntry(documentName);
setDoc(null);
setProvider(null);
setIsConnected(false);
setIsSynced(false);
setWsStatus(WebSocketStatus.Disconnected);
};
}, [documentName]);
return {
doc,
provider,
awareness: (provider?.awareness as AwarenessApi | null) ?? null,
isConnected,
isSynced,
wsStatus,
error,
};
}
export type AwarenessPeer = {
clientId: number;
name: string;
color: string;
imageUrl?: string;
};
/** Deterministic HSL color from a display name (stable cursor / avatar border). */
export function colorFromUserName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i += 1) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue} 65% 45%)`;
}
/**
* Remote peers from Yjs awareness (excludes local client).
*/
export function useAwarenessPeers(
awareness: AwarenessApi | null,
localClientId: number | null,
): AwarenessPeer[] {
const [peers, setPeers] = React.useState<AwarenessPeer[]>([]);
React.useEffect(() => {
if (!awareness || localClientId === null) {
setPeers([]);
return;
}
const read = () => {
const states = awareness.getStates() as Map<
number,
{ user?: { name?: string; color?: string; image?: string } }
>;
const next: AwarenessPeer[] = [];
states.forEach((state, clientId) => {
if (clientId === localClientId) return;
const u = state.user;
if (!u?.name) return;
const name = u.name;
const color = u.color ?? colorFromUserName(name);
next.push({
clientId,
name,
color,
imageUrl: typeof u.image === "string" ? u.image : undefined,
});
});
next.sort((a, b) => a.name.localeCompare(b.name));
setPeers(next);
};
read();
awareness.on("change", read);
return () => {
awareness.off("change", read);
};
}, [awareness, localClientId]);
return peers;
}