diff --git a/packages/api/dom/elements.ts b/packages/api/dom/elements.ts index 6f40386d..4682718d 100644 --- a/packages/api/dom/elements.ts +++ b/packages/api/dom/elements.ts @@ -5,6 +5,7 @@ import { attachForFocus, attachForStatus, attachForText } from './elements-html. import { buildShadow, css, html, SignalHTMLElement, SizingElement } from 'thorish/dom'; import { rafRunner } from 'thorish'; import { globalAllDocs, globalAllDocsErrorNotify } from '../lib/global.ts'; +import { clientUserInfo } from '../lib/user-info.ts'; const attrControl = (target: HTMLElement, attr: string, on?: boolean | string | number) => { if (on) { @@ -71,7 +72,7 @@ export abstract class GumnutNodeElement extends SignalHTMLElement { get clients(): ReadonlyMap { const o = new Map(); for (const clientId of this._node?.clients() ?? []) { - const data = this._node?.doc.userForClientId(clientId); + const data = clientUserInfo(clientId, this._node?.projectId); if (data) { o.set(clientId, data); } @@ -453,7 +454,7 @@ export class GumnutTextElement extends GumnutNodeElement { } protected refreshNode(signal: AbortSignal, node: GumnutNode) { - node.doc.ready.then(() => { + node.ready.then(() => { if (!signal.aborted) { this.firstConfigured = true; } @@ -592,7 +593,7 @@ export class GumnutFocusElement extends SignalHTMLElement { this.main.textContent = ''; for (const clientId of d.clients()) { - const userInfo = d.userForClientId(clientId); + const userInfo = clientUserInfo(clientId, d.projectId); const h = new GumnutUserHeadElement(); h.data = formatUserInfo(clientId, userInfo); this.main.append(h); diff --git a/packages/api/dom/upgrade.ts b/packages/api/dom/upgrade.ts index 968864cc..ecfc3c78 100644 --- a/packages/api/dom/upgrade.ts +++ b/packages/api/dom/upgrade.ts @@ -3,6 +3,7 @@ import { changeBetween } from '../shared/helpers/ot.ts'; import { colorForPeerHue } from '../shared/helpers/color.ts'; import { fastFrameRunner, once, rafRunner } from 'thorish'; import type { GumnutMod, GumnutNode, UserInfo } from '#release'; +import { clientUserInfo } from '../lib/user-info.ts'; export type UpgradeInputArgs = { input: HTMLInputElement | HTMLTextAreaElement; @@ -226,7 +227,7 @@ export function buildRenderHelper( const prepare = () => { const cursorsToDraw: CursorToDraw[] = []; for (const [clientId, sel] of cursors.entries()) { - const out = formatUserInfo(clientId, node.doc.userForClientId(clientId)); + const out = formatUserInfo(clientId, clientUserInfo(clientId, node.projectId)); cursorsToDraw.push({ ...out, sel }); } render(cursorsToDraw); diff --git a/packages/api/lib/doc-api.ts b/packages/api/lib/doc-api.ts index 45cd92e7..0ab12d17 100644 --- a/packages/api/lib/doc-api.ts +++ b/packages/api/lib/doc-api.ts @@ -13,6 +13,7 @@ import { GumnutProjectApi, type LowLevelDoc } from './project-api.ts'; import { clientPart, nodeAtPath, NodeToClientCache } from './node.ts'; import { useActualModel } from './secret-keys.ts'; import type { ValueOp } from '../shared/types/value.ts'; +import { clientUserInfo, filterUserId } from './user-info.ts'; connectToGumnutDoc satisfies (typeof checkTypes)['connectToGumnutDoc']; @@ -23,10 +24,6 @@ interface InternalDocArg extends DocArg { _projectApi?: GumnutProjectApi; } -function filterUserId(userId?: string) { - return userId && !userId.startsWith('!'); -} - export function connectToGumnutDoc(arg: InternalDocArg): { doc: GumnutDoc; shutdown: () => void } { const { docId } = arg; @@ -280,20 +277,12 @@ export function connectToGumnutDoc(arg: InternalDocArg): { doc: GumnutDoc; shutd const doc: GumnutDoc = { ...{ [useActualModel]: () => internalDoc }, - userForClientId(clientId) { - const userId = clientId && internalDoc.userIdForClientId(clientId); - if (!userId || userId.startsWith('!')) { - return undefined; - } - return projectApi.userInfo(userId); - }, - clients() { // we need to filter out admin/other clients, makes this "slow" const out: string[] = []; for (const clientId of internalDoc.clients()) { - const ui = this.userForClientId(clientId); - if (filterUserId(ui?.userId)) { + const info = clientUserInfo(clientId); + if (info !== undefined) { out.push(clientId); } } diff --git a/packages/api/lib/global.ts b/packages/api/lib/global.ts index 8fa2cf49..3a3c92ae 100644 --- a/packages/api/lib/global.ts +++ b/packages/api/lib/global.ts @@ -18,6 +18,13 @@ export function configureGumnut(arg: ConfigArg) { globalConfig = { ...arg }; } +export function implicitProjectId(): string { + if (globalConfig === undefined) { + throw new Error(`can't get implicit projectId, not configured`); + } + return globalConfig.projectId; +} + /** * Attempts to configure Gumnut implicitly through various env var locations. */ diff --git a/packages/api/lib/index.ts b/packages/api/lib/index.ts index 24c04893..e5062e2d 100644 --- a/packages/api/lib/index.ts +++ b/packages/api/lib/index.ts @@ -1,6 +1,7 @@ export { configureGumnut } from './global.ts'; export { connectToGumnutDoc } from './doc-api.ts'; export { buildTestToken } from './token/helpers.ts'; +export { clientUserInfo } from './user-info.ts'; // -- typescript inline check below -- // TODO: does this compile out fine diff --git a/packages/api/lib/internal/internal.ts b/packages/api/lib/internal/internal.ts index 919dd165..4873a2d5 100644 --- a/packages/api/lib/internal/internal.ts +++ b/packages/api/lib/internal/internal.ts @@ -1,6 +1,7 @@ import { type NamedListeners, namedListeners, promiseWithResolvers, randomId } from 'thorish'; import type { DocType } from '../../shared/docType.js'; import type { HostRead } from './protocol.d.ts'; +import { provideClientToUser, userForClient } from '../user-info.ts'; export type AuthorOp = { clientId: string; // blank is self @@ -83,17 +84,10 @@ export class InternalDoc implements GenericDoc { this.userId.then((userId) => (this.syncUserId = userId)); } - private clientIdToUserId = new Map(); + private clientsHere = new Set(); clients() { - return this.clientIdToUserId.keys(); - } - - /** - * Exposes the synchronous `clientId` => `userId` mapping. - */ - userIdForClientId(clientId: string): string { - return this.clientIdToUserId.get(clientId) || ''; + return this.clientsHere.keys(); } get ready() { @@ -101,8 +95,8 @@ export class InternalDoc implements GenericDoc { } process(data: HostRead) { - const userId = this.syncUserId; - if (userId === undefined) { + const selfUserId = this.syncUserId; + if (selfUserId === undefined) { throw new Error(`cannot process ops until syncUserId?`); } @@ -144,23 +138,23 @@ export class InternalDoc implements GenericDoc { if (data.ac) { // complete reset - for (const [clientId, userId] of this.clientIdToUserId) { - e.set(clientId, { userId, status: false }); + for (const clientId of this.clientsHere.keys()) { + e.set(clientId, { userId: userForClient(clientId), status: false }); } - this.clientIdToUserId.clear(); + this.clientsHere.clear(); } for (const [clientId, userId] of Object.entries(data.c)) { if (userId) { - this.clientIdToUserId.set(clientId, userId); + this.clientsHere.add(clientId); + provideClientToUser(clientId, userId); if (e.get(clientId)?.status === false) { e.delete(clientId); } else { e.set(clientId, { userId, status: true }); } - } else if (this.clientIdToUserId.has(clientId)) { - const userId = this.clientIdToUserId.get(clientId)!; - this.clientIdToUserId.delete(clientId); - e.set(clientId, { userId, status: false }); + } else if (this.clientsHere.has(clientId)) { + this.clientsHere.delete(clientId); + e.set(clientId, { userId: userForClient(clientId), status: false }); } } @@ -188,7 +182,7 @@ export class InternalDoc implements GenericDoc { const toApply = this.localOps.slice(0, this.sentLocalOps.count); updateOps.push( ...toApply.map((x) => { - return { clientId: '', userId, ...x, known: true }; + return { clientId: '', userId: selfUserId, ...x, known: true }; }), ); @@ -203,7 +197,7 @@ export class InternalDoc implements GenericDoc { } else { // our changes were moved _but not transformed_, so we don't have a derivedDoc for (const op of toApply) { - const change = this.docType.do(this.doc, userId, op.op); + const change = this.docType.do(this.doc, selfUserId, op.op); op.change = change; } } @@ -222,7 +216,7 @@ export class InternalDoc implements GenericDoc { throw new Error(`dup self payload: ${entry}`); } hadSelfPayload = true; - authorUserId = userId; + authorUserId = selfUserId; // this is still our key, but we got transformed if (this.sentLocalOps?.key !== entry.k) { @@ -237,7 +231,7 @@ export class InternalDoc implements GenericDoc { transformOver.push(...entry.o); // use blank string if we don't know their userId (transient user/admin/etc) - authorUserId = this.clientIdToUserId.get(entry.c) || ''; + authorUserId = userForClient(entry.c); authorClientId = entry.c; } @@ -261,17 +255,17 @@ export class InternalDoc implements GenericDoc { this.derivedDoc = structuredClone(this.doc); for (let i = 0; i < this.localOps.length; ++i) { if (transformOver.length) { - const update = this.docType.transform(userId, this.localOps[i].op, transformOver); + const update = this.docType.transform(selfUserId, this.localOps[i].op, transformOver); if (update) { this.localOps[i].op = update; } } - const change = this.docType.do(this.derivedDoc, userId, this.localOps[i].op); + const change = this.docType.do(this.derivedDoc, selfUserId, this.localOps[i].op); this.localOps[i].change = change; } updateOps.push( ...this.localOps.map((x) => { - return { clientId: '', userId, ...x, known: true }; + return { clientId: '', userId: selfUserId, ...x, known: true }; }), ); this.maybeSendOps(); diff --git a/packages/api/lib/model/node.ts b/packages/api/lib/model/node.ts index 7c331617..eaf34258 100644 --- a/packages/api/lib/model/node.ts +++ b/packages/api/lib/model/node.ts @@ -4,7 +4,7 @@ export abstract class AbstractGumnutNodeImpl implements GumnutNode { abstract projectId: string; abstract docId: string; abstract node: string; - abstract doc: GumnutDoc; + abstract ready: Promise; abstract isDirty(): boolean; diff --git a/packages/api/lib/node.ts b/packages/api/lib/node.ts index b14e7d89..2e46cf2b 100644 --- a/packages/api/lib/node.ts +++ b/packages/api/lib/node.ts @@ -87,7 +87,7 @@ export function nodeAtPath(arg: { projectId = projectApi.projectId; docId = docId; node = nodeName; - doc = doc; + ready = doc.ready; // TODO: might not be enough addListener(type: any, cb: (x: any) => void, signal: AbortSignal) { nodeEvents.addListener(type, cb, signal); diff --git a/packages/api/lib/project-api.ts b/packages/api/lib/project-api.ts index dba173db..a81cd1fd 100644 --- a/packages/api/lib/project-api.ts +++ b/packages/api/lib/project-api.ts @@ -1,16 +1,11 @@ -import type { ConfigArg, GetToken, UserInfo } from '#release'; -import { - afterSignal, - asyncGeneratorQueue, - promiseWithResolvers, - SimpleCache, - timeout, -} from 'thorish'; +import type { ConfigArg, GetToken } from '#release'; +import { afterSignal, asyncGeneratorQueue, promiseWithResolvers, timeout } from 'thorish'; import type { ActiveCall } from '../util/keySocket.ts'; import { type PersistentKeySocket, persistentKeySocket } from '../util/persistKeySocket.ts'; import { buildTestToken, parseToken } from './token/helpers.ts'; import { buildUrl } from './url.ts'; import { createBackoff } from '../util/backoff.ts'; +import { type InternalUserInfo, provideUserInfo } from './user-info.ts'; /** * Keep a connection open for this long while it is not being used. @@ -34,9 +29,10 @@ export class GumnutProjectApi { public isLocalDev: boolean; constructor(arg: ConfigArg, extraArg?: { connTimeout?: number }) { + arg = { ...arg }; const connTimeout = extraArg?.connTimeout ?? CONN_TIMEOUT; - this.c = new ConnectionByUserMap(arg, this.processUserInfo.bind(this), connTimeout); + this.c = new ConnectionByUserMap(arg, connTimeout); this.projectId = arg.projectId; this.isLocalDev = Boolean(arg.localDevKey); } @@ -145,27 +141,6 @@ export class GumnutProjectApi { safeRefreshCall(); } - - private users = new SimpleCache>((userId) => { - return Object.freeze({ userId, info: {} }); - }); - - private processUserInfo(i: InternalUserInfo) { - const curr = this.users.get(i.u); - Object.assign(curr.info, i.r || {}); - } - - /** - * Synchronously reads user information. - * - * This always returns the same ref for the given userId. - */ - userInfo(userId: string): UserInfo | undefined { - if (!userId) { - return undefined; - } - return this.users.get(userId); - } } class TokenManager { @@ -266,11 +241,7 @@ class TokenManager { } class ConnectionByUserMap { - constructor( - private arg: ConfigArg, - private processUserInfo: (u: InternalUserInfo) => void, - private connTimeout: number, - ) {} + constructor(private arg: ConfigArg, private connTimeout: number) {} private active: Map< string, @@ -296,7 +267,7 @@ class ConnectionByUserMap { } const c = new AbortController(); - const outer = this; + const projectId = this.arg.projectId; const sock = persistentKeySocket({ signal: c.signal, @@ -306,7 +277,7 @@ class ConnectionByUserMap { if ('userInfo' in message) { const ui = message.userInfo as InternalUserInfo[]; for (const u of ui ?? []) { - outer.processUserInfo(u); + provideUserInfo(u, projectId); } } }, @@ -336,12 +307,3 @@ class ConnectionByUserMap { prev.safe = () => clearTimeout(t); } } - -type InternalUserInfo = { - u: string; - r?: { - name?: string; - email?: string; - picture?: string; - }; -}; diff --git a/packages/api/lib/user-info.ts b/packages/api/lib/user-info.ts new file mode 100644 index 00000000..e13530db --- /dev/null +++ b/packages/api/lib/user-info.ts @@ -0,0 +1,60 @@ +import type { UserInfo } from '#release'; +import { implicitProjectId } from './global.ts'; + +const userInfoMap = new Map(); +const clientUserMap = new Map(); + +/** + * @fileoverview Stuff to aggregate user/client data synchronously. + * + * We know that clientId is unique globally right now, and/or customers will only ever use one project. + */ + +/** + * Exported method to map `clientId` to user. + */ +export function clientUserInfo(clientId: string, projectId?: string): UserInfo | undefined { + const userId = clientUserMap.get(clientId); + if (filterUserId(userId)) { + projectId ??= implicitProjectId(); + + const key = `${userId}/${projectId}`; + return userInfoMap.get(key); + } + + return undefined; +} + +export function userForClient(clientId: string): string { + return clientUserMap.get(clientId) ?? ''; +} + +export function provideClientToUser(clientId: string, userId: string) { + if (userId) { + clientUserMap.set(clientId, userId); + } else { + clientUserMap.delete(clientId); + } +} + +export function provideUserInfo(i: InternalUserInfo, projectId: string) { + // TODO: we never clear user information for a given project. + // It's probably fine. + if (i.r !== undefined) { + const key = `${i.u}/${projectId}`; + userInfoMap.set(key, { userId: i.u, info: i.r }); + } +} + +export type InternalUserInfo = { + u: string; + r?: { + name?: string; + email?: string; + picture?: string; + }; +}; + +export function filterUserId(userId?: string): userId is string { + return Boolean(userId && !userId.startsWith('!')); +} diff --git a/packages/api/release/index.d.ts b/packages/api/release/index.d.ts index 163b5990..9c73bca5 100644 --- a/packages/api/release/index.d.ts +++ b/packages/api/release/index.d.ts @@ -18,6 +18,7 @@ export interface DocArg { /** * Configures Gumnut with your project ID. + * Only one project ID can be used on a given page. * * This should be called before you connect to any documents, but may be skipped if you provide * your project ID and optional `localDevKey` as env vars, e.g., in `process.env` or @@ -57,6 +58,15 @@ export function buildTestToken( arg?: { name?: string; email?: string; picture?: string }, ): string; +/** + * Synchronously reads user information for the given `clientId`. + * This contains information such as the user's name, email address and picture, if available. + * + * This will be provided by the Gumnut server before you're made aware of the given client. + * In general it won't update over time. + */ +export function clientUserInfo(clientId: string): UserInfo | undefined; + /** * Returned from a commit action. * The timestamp is optional from user-space, and represents the timestamp saved to your server. @@ -154,11 +164,6 @@ export type GumnutDoc = { */ readonly error?: string | Error; - /** - * Read the user information for this given client ID. - */ - userForClientId(clientId: string): UserInfo | undefined; - /** * Take a snapshot of the current clients connected to this doc. * @@ -222,7 +227,7 @@ export interface GumnutReadNode { readonly projectId: string; readonly docId: string; readonly node: string; - readonly doc: GumnutDoc; + readonly ready: Promise; /** * Is this considered dirty?