Sofya Developers

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 guestUrlnew 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

On this page