// ── @presence support (server side) ───────────────────────────────────────────
//
// `@presence` props are filled with the live member list of a broadcast presence
// channel. Broadcasting is an OPTIONAL peer: flow does not depend on it, so we
// resolve `Broadcast` through a variable-specifier dynamic import (not type-checked,
// so no hard dependency) and no-op gracefully when it isn't installed.
import type { Component } from "./Component.ts";
import { getPresenceProps, resolvePresenceChannel } from "./decorators.ts";
/**
* One member of a broadcast presence channel — the shape used to populate a
* `@presence` prop (the live "who's here" list).
*
* Each member carries a required `id` plus whatever additional member data the
* broadcasting layer attached when the client joined (name, avatar, etc.).
*
* @remarks
* `@presence` props are server-controlled: flow refills them with the current
* member list before render and on each join/leave, so they are read-only on the
* client. Presence requires the optional `@zerotal/broadcasting` peer; without
* it, populating presence is a no-op and props keep their default.
*
* @example
* ```tsx
* class RoomHeader extends Component {
* @locked roomId = "";
* @presence((self) => `room.${self.roomId}`)
* members: PresenceMember[] = [];
*
* async render() {
* return
{this.members.length} online
;
* }
* }
* ```
*/
export interface PresenceMember {
/** Stable identifier for the member, as reported by broadcasting. */
id: string | number;
/** Arbitrary extra member data supplied when the client joined the channel. */
[key: string]: unknown;
}
interface BroadcastFacade {
getMembers(channel: string): PresenceMember[];
}
let _broadcast: BroadcastFacade | null | undefined;
async function _resolveBroadcast(): Promise {
if (_broadcast !== undefined) return _broadcast;
try {
// Variable specifier → TypeScript doesn't require the module to exist (optional peer).
const spec = "@zerotal/broadcasting";
const mod = (await import(spec)) as { Broadcast?: BroadcastFacade };
_broadcast = mod.Broadcast ?? null;
} catch {
_broadcast = null; // broadcasting not installed → presence is a no-op
}
return _broadcast;
}
/**
* Fill every `@presence` prop on `page` with the current member list for its channel.
* Called before render on the initial GET and on each `$presence` action (join/leave).
* No-op when the component has no presence props or broadcasting isn't installed.
*/
export async function populatePresence(page: Component): Promise {
const props = getPresenceProps(page);
if (props.size === 0) return;
const Broadcast = await _resolveBroadcast();
if (!Broadcast) return;
for (const [prop, channel] of props) {
const name = resolvePresenceChannel(channel, page);
try {
(page as unknown as Record)[prop] = Broadcast.getMembers(`presence-${name}`);
} catch {
/* leave the prop at its default on any lookup error */
}
}
}