Referência de API
Referência de API
Pergunta: "qual a assinatura exata de
createHost/joinRoom/etc.?"
Gerada a partir do TSDoc dos três pontos de entrada do pacote (., ./react, ./qr) — não editar à mão. Rode cd packages/event-room && npm run docs:generate depois de mudar uma assinatura ou comentário público; npm run build (no mesmo diretório) falha se este arquivo divergir da regeneração.
Core (@sofya-sdk/event-room)
AccessMode
export type AccessMode = 'open' | 'code' | 'approval' | 'approval_and_code';buildJoinUrl
Builds the URL a host prints into a QR: guestUrl with the join target
hung on its query string. Never appends a path and never resolves
relatively against guestUrl — new URL('join', 'myapp:') throws and
new URL('join', 'myapp://') produces a triple slash
(19-esquema-do-guesturl F3) — so the integrator's address is always the
final address, and the library only ever calls searchParams.set on it.
Never append, either: it would duplicate the key and the guest's get
would read the old value back.
buildJoinUrl(guestUrl: string, target: JoinTarget): string
buildServerError
Builds the shared EventRoomError for a server-sent code at a call site that has a promise to reject.
buildServerError(code: string, message?: string, context?: Record<string, unknown>): EventRoomError
classifyServerError
Classifies a code (+ optional raw message) the server sent with no
specific call already narrowing it — the generic, never-silenced
"service/unknown failure" observation this ticket owns. An unknown code
always resolves non-terminal/non-retryable with a generic remedy plus
the raw message attached, never dropped.
classifyServerError(code: string, message?: string): ResolvedServerError
CONTRACT_DIGEST_MAP_BUDGET_BYTES
const CONTRACT_DIGEST_MAP_BUDGET_BYTES: number
ContractEvent
export interface ContractEvent<TPayload = unknown> {
readonly layer: 'shape' | 'standard-schema' | 'opaque';
readonly digest: string | 'opaque';
validate(value: unknown): ValidationResult<TPayload>;
}ContractEventInput
export type ContractEventInput = ShapeNode | StandardSchemaV1 | OpaquePayload<unknown>;ContractMapInput
export type ContractMapInput = Record<string, ContractEventInput>;createErrorDeduper
createErrorDeduper(): ErrorDeduper
createHost
createHost opens the transport and the first room in a single call
(spec §2.2/D1 of ticket 18): startRoom exists only for restarts.
createHost<C extends ContractMapInput = ContractMapInput>(options: CreateHostOptions<C>): Promise<{ host: Host<C>; room: Room<C>; }>
CreateHostOptions
export interface CreateHostOptions<C extends ContractMapInput = ContractMapInput> {
url: string;
credential: string;
guestUrl: string;
displayName?: string;
protocolEvent?: string;
peerReadinessTimeoutMs?: number;
room: RoomArgs;
contract?: EventContract<C>;
_connection?: HostConnectionFactory;
}CreateRoomResult
export interface CreateRoomResult {
roomId: string;
accessMode: AccessMode;
effectiveMaxGuests: number | null;
joinPayload: Record<string, unknown>;
creatorToken: string;
}defineContract
defineContract<const C extends ContractMapInput>(map: C): EventContract<C>
deriveDisplayStatus
Read-only view for a single display indicator. Never the source of
truth — callers that need both axes read getStatus() directly.
deriveDisplayStatus(status: RoomSessionStatus): TransportState | MembershipState
DisplayNameRequiredError
class DisplayNameRequiredError extends EventRoomError
new DisplayNameRequiredError(accessMode: AccessMode): DisplayNameRequiredError
EndedReason
The 9 terminal reasons. contract_mismatch and protocol_version_mismatch
are asymmetric by construction: only a RoomSessionStateMachine can reach
them — there is no server verb to expel an already-admitted guest, so the
host's own connection (which has no membership axis at all) never reaches
this terminal value either way.
export type EndedReason = "left" | "room_closed" | "room_expired" | "creator_replaced" | "rejected" | "idle_timeout" | "control_lost" | "contract_mismatch" | "protocol_version_mismatch";ErrorCatalogEntry
export interface ErrorCatalogEntry {
category: EventRoomErrorCategory;
remedy: string;
retryable?: boolean;
}ErrorDeduper
Deduplication (10-superficie-de-erro): an unsolicited {type:'error', code}
describing a still-open condition and a later signal reporting the exact
same code again are one underlying condition, not two — a caller should
see it reported once, not spammed on every wire frame. Reporting a
different code, or the same code again after something cleared the
condition (room closed/recreated, control resumed, etc.), always goes
through.
Deliberately tiny and call-site-owned — this is not a generic error handler; each connection wires its own deduper instance around whichever of its own signals (a repeated error frame, a close that follows one for the same reason) it knows can coincide.
export interface ErrorDeduper {
shouldReport(code: string): boolean;
clear(): void;
}EventContract
export type EventContract<C extends ContractMapInput> = {
readonly events: {
[K in keyof C]: ContractEvent<InferEventPayload<C[K]>>;
};
readonly digestMap: Readonly<Record<keyof C & string, string | 'opaque'>>;
readonly _payloads: {
[K in keyof C]: InferEventPayload<C[K]>;
};
};EventRoomError
class EventRoomError extends Error
new EventRoomError(init: EventRoomErrorInit): EventRoomError
readonly code: string;
readonly category: EventRoomErrorCategory;
readonly remedy: string;
readonly retryable: boolean;
readonly serverMessage?: string;
readonly context?: Record<string, unknown>;EventRoomErrorCategory
Minimal error shape used by defineContract (ticket 04). The full catalog
(registry of ~30 codes, deduplication, anti-divergence check) is ticket 10's
job — this is deliberately just enough of the agreed shape
(code, category, remedy, retryable, context?) for the two synchronous
"usage" refusals this ticket owns, so ticket 10 can absorb it without a
breaking shape change.
export type EventRoomErrorCategory = 'usage' | 'flow' | 'ended' | 'infra';EventRoomErrorInit
export interface EventRoomErrorInit {
code: string;
category: EventRoomErrorCategory;
remedy: string;
retryable?: boolean;
serverMessage?: string;
context?: Record<string, unknown>;
}getEventPayloadBudgetBytes
The exact number of bytes left for payload once the envelope's own
kind/name framing is subtracted from the 16 KiB wire budget — the
budget this ticket exposes to the caller instead of making them discover
it by trial and error. name is required to be ASCII (server regex
^[a-zA-Z0-9_.:-]{1,48}$), so the JSON scaffolding around it is exactly
one byte per character and the arithmetic below is exact, not an
estimate.
getEventPayloadBudgetBytes(name: string): number
GuestCredentialRequiredError
Every one of these extends the shared EventRoomError shape
(code/category/remedy/retryable) so callers can catch on that
base class alone; each subclass just fixes its own code/category/
remedy and adds whatever extra context is useful. Every one of these is
thrown synchronously, before any I/O.
class GuestCredentialRequiredError extends EventRoomError
new GuestCredentialRequiredError(): GuestCredentialRequiredError
GuestHandle
export interface GuestHandle {
sendEvent(name: string, payload: unknown): void;
getStatus(): RoomSessionStatus;
close(): void;
}GuestRole
ClientRole is an open string on the wire — the server never validates it (room_registry.py). This enum exists only on the SDK, so joinRoom checks it at runtime too, not just via the static type.
export type GuestRole = 'desktop' | 'mic' | 'unknown';GuestRoleRequiredError
class GuestRoleRequiredError extends EventRoomError
new GuestRoleRequiredError(): GuestRoleRequiredError
GuestUrlReservedParamError
class GuestUrlReservedParamError extends EventRoomError
new GuestUrlReservedParamError(param: string): GuestUrlReservedParamError
readonly param: string;Host
export interface Host<C extends ContractMapInput = ContractMapInput> {
readonly transport: HostTransportStateMachine;
startRoom(args: RoomArgs): Promise<Room<C>>;
close(): Promise<void>;
}HostConnection
The seam between this module's lifecycle rules and the wire. Deliberately
shaped like the 05 prototype's connectHost/handle split (transport.mjs),
translated into something DI-friendly: every method that talks to the
server is async and named for the server verb it performs.
export interface HostConnection {
connect(): Promise<void>;
isOpen(): boolean;
create(args: {
roomId: string;
accessMode: AccessMode;
maxGuests?: number;
}): Promise<{
ok: true;
result: CreateRoomResult;
} | {
ok: false;
collision: boolean;
}>;
closeRoom(): Promise<void>;
disconnectTransport(): void;
resume(args: {
roomId: string;
creatorToken: string;
}): Promise<{
ok: true;
} | {
ok: false;
reason: 'control_lost';
}>;
onJoinRequest(cb: (request: JoinRequest) => void): void;
approveJoin(requestId: string): void;
rejectJoin(requestId: string): void;
onNonTerminalObservation(cb: (observation: NonTerminalObservation) => void): void;
onTerminal(cb: (reason: 'control_lost' | 'room_expired') => void): void;
onTransportDropped(cb: () => void): void;
sendEvent(name: string, payload: unknown): void;
onAppEvent(cb: (event: {
name: string;
payload: unknown;
}) => void): void;
getPeerFingerprint(): ContractDigestMap | null;
resetForNewRoom(): void;
}HostConnectionFactory
export interface HostConnectionFactory {
(init: {
url: string;
credential: string;
displayName?: string;
protocolEvent?: string;
contractDigestMap?: ContractDigestMap;
}): HostConnection;
}HostTransportStateMachine
The host's own connection has only the transport axis. It has no
membership and therefore no ended — by type, not just by runtime
refusal.
class HostTransportStateMachine
new HostTransportStateMachine(): HostTransportStateMachine
readonly transportAxis: unknown;InferShape
export type InferShape<S> = S extends PrimitiveToken ? InferPrimitive<S> : S extends readonly [
infer Only
] ? InferShape<Only>[] : S extends readonly [
infer A,
...infer Rest
] ? InferShape<A> | InferShape<Rest[number]> : S extends readonly (infer El)[] ? InferShape<El>[] : S extends {
readonly [key: string]: ShapeNode;
} ? {
[K in keyof S]: InferShape<S[K]>;
} : never;InvalidGuestUrlError
class InvalidGuestUrlError extends EventRoomError
new InvalidGuestUrlError(guestUrl: string): InvalidGuestUrlError
InvalidJoinTargetError
class InvalidJoinTargetError extends EventRoomError
new InvalidJoinTargetError(reason: string): InvalidJoinTargetError
JOIN_TARGET_FORMAT_VERSION
The one query-param format version this parser recognizes (09-superficie-guest / 08-qr-e-handoff).
const JOIN_TARGET_FORMAT_VERSION: "1" = '1'
JoinCodeRequiredError
Thrown wherever a JoinTarget is accepted as a value (not parsed from a
URL) and its accessMode requires a joinCode that is missing or empty:
buildJoinUrl and joinRoom's hand-built-target path (./guest.ts) both
throw this exact class, via assertJoinCodePresentIfRequired below, so
the requirement is enforced identically wherever a JoinTarget value
enters the library rather than being re-derived per call site.
class JoinCodeRequiredError extends EventRoomError
new JoinCodeRequiredError(accessMode: AccessMode): JoinCodeRequiredError
readonly accessMode: AccessMode;JoinFailedError
Wraps whatever {type:'error', code, message} the server sent back for
this join attempt. Always rejects joinRoom's own promise — this is the
"journey condition rejects the causing call" category ('flow')
regardless of the code's canonical catalog bucket, since it is, by
construction, the response to this specific call. The remedy still comes
from the shared catalog (10-superficie-de-erro) — including the
"usage code from the server = library bug" remedy override — so the same
code never gets a different explanation depending on where it surfaces.
class JoinFailedError extends EventRoomError
new JoinFailedError(code: string, serverMessage?: string): JoinFailedError
JoinRejectedError
Rejects the joinRoom call that caused it (spec §5.2: "an error that responds to a call returns via that call"). Never retried automatically — a fresh admission is a new joinRoom call.
class JoinRejectedError extends EventRoomError
new JoinRejectedError(): JoinRejectedError
JoinRequest
export interface JoinRequest {
id: string;
displayName?: string;
}joinRoom
The guest's single entry verb. Nothing here survives a reload: target,
credential, and whatever the server hands back live only in this
call's closure and in the returned handle's in-memory state — there is
no localStorage/sessionStorage write anywhere in this module.
Deliberately not declared async: an async function returns a Promise
even for a throw on its very first line, which turns a usage error into a
rejected promise instead of a synchronous throw. Validating here, before
handing off to the async connection work below, is what makes the
synchronous-throw guarantee real rather than incidental.
joinRoom<C extends ContractMapInput>(args: JoinRoomArgs<C>): Promise<GuestHandle>
JoinRoomArgs
export interface JoinRoomArgs<C extends ContractMapInput = ContractMapInput> {
url: string;
credential: string;
target: JoinTarget;
displayName?: string;
role: GuestRole;
contract?: EventContract<C>;
protocolEvent?: string;
pendingApprovalTimeoutMs?: number;
}JoinTarget
export interface JoinTarget {
readonly roomId: string;
readonly accessMode: AccessMode;
readonly joinCode?: string;
}MalformedJoinTargetError
class MalformedJoinTargetError extends EventRoomError
new MalformedJoinTargetError(reason: string): MalformedJoinTargetError
readonly reason: string;MAX_EVENT_PAYLOAD_BYTES
The wire limit is a server constant (16 KiB, measured on the compact JSON of
payload only — see spec §3.7 / harness ticket 17 findings). The contract's
digest map travels inside the handshake envelope (ticket 05), so it has to
leave "folga" (headroom) for the envelope framing, protocolVersion, and
future contract growth. Half the wire limit is a generous, easy-to-explain
margin — not a measured number, a deliberately conservative one.
const MAX_EVENT_PAYLOAD_BYTES: number
MembershipState
export type MembershipState = "unassociated" | "associating" | "awaiting_approval" | "member" | "ended";NonTerminalObservation
export interface NonTerminalObservation {
code: string;
known: boolean;
}OpaquePayload
export interface OpaquePayload<T> {
readonly [OPAQUE_MARKER]: true;
readonly __type?: T;
}parseJoinTarget
Reads a scanned QR/deep-link string (or an equivalent string assembled
for a typed code) and returns the target it encodes, or throws a named
error — never silently. Same function for both origins: the QR is just
UX over the identical {roomId, accessMode, joinCode?} shape a typed
code assembles by hand.
Deliberately reads only searchParams — never .host, .hostname,
.pathname, or .protocol — because a custom (non-special) scheme
assigns the same text segment to different URL properties depending on
slash count, and does not case-fold .host the way http(s) does
(19-esquema-do-guesturl F2). Comparing the scanned string against an
expected guestUrl is therefore impossible, not just unperformed: there
is no canonical form to compare against.
parseJoinTarget(scanned: string): JoinTarget
payload
Type-only opt-out: no runtime validation, documented as giving up the fingerprint's network for this event.
payload<T>(): OpaquePayload<T>
PayloadTooLargeError
Thrown by sendEvent when the serialized envelope — measured exactly like the server measures it, not the outer wire frame — exceeds the 16 KiB budget. There is no slicing: the app owns the semantics of what it sends, the library only owns the byte count.
class PayloadTooLargeError extends EventRoomError
new PayloadTooLargeError(actualBytes: number, budgetBytes: number): PayloadTooLargeError
readonly actualBytes: number;
readonly budgetBytes: number;REFUSED_GUEST_URL_SCHEMES
The fixed, closed, non-configurable blacklist of schemes a guestUrl may
never use (20-politica-de-esquema-do-guesturl, decision from Gabriel).
Each either executes content, embeds content, or isn't an application
destination at all (file:) — never a plausible deep-link scheme. The
list does not grow by reflex: about:, chrome:, ms-settings: etc. all
parse cleanly and are deliberately left out, because the guard defends
against integrator error (a guestUrl template that became a data:
URL by accident), not against an adversary who already controls the
host's own code.
const REFUSED_GUEST_URL_SCHEMES: readonly ["javascript", "data", "blob", "vbscript", "file"]
RESERVED_EVENT_PREFIX
Prefix the library reserves for its own envelope kinds (spec §2.4 D6).
const RESERVED_EVENT_PREFIX: "sofya." = 'sofya.'
RESERVED_JOIN_URL_PARAMS
The four query params joinRoom's wire format reserves on a guestUrl
(09-superficie-guest / 19-esquema-do-guesturl / 20-politica-de-esquema-do-guesturl).
Fixed, closed: not something an integrator's own query params are allowed
to collide with (buildJoinUrl fails loud rather than silently overwriting).
const RESERVED_JOIN_URL_PARAMS: readonly ["v", "roomId", "accessMode", "joinCode"]
ResolvedServerError
export interface ResolvedServerError {
code: string;
category: EventRoomErrorCategory;
remedy: string;
retryable: boolean;
known: boolean;
}Room
export interface Room<C extends ContractMapInput = ContractMapInput> {
readonly roomId: string;
readonly accessMode: AccessMode;
readonly effectiveMaxGuests: number | null;
readonly joinPayload: Record<string, unknown>;
readonly session: RoomSessionStateMachine;
onJoinRequest(cb: (request: JoinRequest) => void): void;
approveJoin(requestId: string): void;
rejectJoin(requestId: string): void;
onObservation(cb: (observation: NonTerminalObservation) => void): void;
send<K extends keyof C & string>(name: K, payload: EventContract<C>['_payloads'][K]): void;
on<K extends keyof C & string>(name: K, cb: (payload: EventContract<C>['_payloads'][K]) => void): void;
close(): Promise<void>;
}RoomArgs
The exact same type is used for the room block of createHost and as
startRoom's whole argument (spec §2.2/D3 of ticket 18) — no default
inheritance between calls is possible because there is only one type,
and every call site must fill it out fresh.
export interface RoomArgs {
accessMode: AccessMode;
roomId?: string;
maxGuests?: number;
}RoomSessionStateMachine
class RoomSessionStateMachine
new RoomSessionStateMachine(): RoomSessionStateMachine
readonly transportAxis: unknown;RoomSessionStatus
export interface RoomSessionStatus {
transport: TransportState;
membership: MembershipState;
endedReason: EndedReason | null;
}ShapeNode
The shape mini-language: a primitive token, an object (nested), or an array. A single-element array is "array of that shape"; two-or-more is a simple union of alternatives. This convention is this ticket's own call — the spec left the exact grammar in the fog (docs/specs/.../spec.md §2.4).
export type ShapeNode = PrimitiveToken | readonly ShapeNode[] | {
readonly [key: string]: ShapeNode;
};TransportState
The state machine at the heart of a room session (07-maquina-de-estados).
Two axes, never flattened: transport (nothing terminal — offline is
rest, not death) and membership (one terminal value, ended, with a
discriminated reason). The host's own connection uses
HostTransportStateMachine instead, which has only the transport axis
and therefore no way to express ended at all — the host's own end of
life is a different kind of terminality (object death, ticket 08/21),
not a value on this axis.
export type TransportState = "offline" | "connecting" | "online" | "retrying";UnknownGuestRoleError
class UnknownGuestRoleError extends EventRoomError
new UnknownGuestRoleError(role: string): UnknownGuestRoleError
readonly role: string;UnsupportedGuestUrlSchemeError
class UnsupportedGuestUrlSchemeError extends EventRoomError
new UnsupportedGuestUrlSchemeError(scheme: string): UnsupportedGuestUrlSchemeError
readonly scheme: string;
readonly refused: readonly string[];ValidationResult
export type ValidationResult<T> = {
success: true;
data: T;
} | {
success: false;
issues?: readonly unknown[];
};React hooks (@sofya-sdk/event-room/react)
useGuest
Joins a room for the lifetime of the calling component, using whatever
args were passed on first render. Same one-shot shape as useHost:
joinRoom is a single verb, not something this hook re-invokes on prop
changes.
useGuest<C extends ContractMapInput = ContractMapInput>(args: JoinRoomArgs<C>): UseGuestResult
UseGuestResult
export interface UseGuestResult {
guest: GuestHandle | null;
error: unknown;
}useHost
Opens a host + its first room for the lifetime of the calling component,
using whatever options were passed on first render — matching
createHost's own one-shot shape (§2.2/D1: it opens the transport and
the first room in a single call). Re-render with different options
does not restart anything; call createHost directly for that kind of
control.
useHost(options: CreateHostOptions): UseHostResult
UseHostResult
export interface UseHostResult {
host: Host | null;
room: Room | null;
error: unknown;
}QR image (@sofya-sdk/event-room/qr)
generateQrImageDataUrl
Renders url (a buildJoinUrl output) as a data:image/png;base64,...
URL, directly usable as an <img src>. Never touches the wire, never
reads or writes target/credential state — this is presentation only.
generateQrImageDataUrl(url: string, options?: GenerateQrImageOptions): Promise<string>
GenerateQrImageOptions
export interface GenerateQrImageOptions {
size?: number;
margin?: number;
}QR_SUPPORTED_RUNTIME
The one runtime this subpath supports, declared explicitly rather than
left to whatever the underlying qrcode dependency happens to run on.
Generating a QR image for display is a browser-UI concern; enforced by
generateQrImageDataUrl checking for document before doing anything
else, so a caller using this from a non-browser runtime gets a named
error instead of a dependency-specific crash.
const QR_SUPPORTED_RUNTIME: "browser"
QrUrlRequiredError
class QrUrlRequiredError extends EventRoomError
new QrUrlRequiredError(): QrUrlRequiredError
UnsupportedQrRuntimeError
class UnsupportedQrRuntimeError extends EventRoomError
new UnsupportedQrRuntimeError(): UnsupportedQrRuntimeError
Last updated on