Skip to content

Rooms

A room is a realtime channel that several browsers join from an invite your app shares, and every message in it is sealed before it leaves the tab. This page covers what a room is, how an invite creates and joins one, how messages travel, the permission model, what survives a reconnect, and the identity a member carries.

export type RoomPermission = 'send' | 'receive' | 'remove' | 'block'
export type RoomPermissions = Readonly<Record<RoomPermission, boolean>>
export type RoomDefaults = Readonly<{ send: boolean, receive: boolean }>
export type RoomMember = { id: string, permissions: RoomPermissions }
export type RoomMessage = { seq: number, from: string, at: number, text: string }
export type RoomEvent =
| { type: 'message', message: RoomMessage }
| { type: 'joined', member: RoomMember }
| { type: 'left', id: string, reason: 'left' | 'removed' | 'blocked' }
| { type: 'permissions', id: string, permissions: RoomPermissions }
| { type: 'defaults', defaults: RoomDefaults }
export type RoomsErrorCode =
| 'invalid' | 'not-found' | 'bad-key' | 'full' | 'blocked' | 'denied'
| 'rate-limited' | 'too-large' | 'unavailable' | 'closed'
export type RoomsError = Error & { code: RoomsErrorCode }
export type RoomEnd = { reason: 'left' | 'removed' | 'blocked' | 'ended' | 'unavailable' }
export type CreateOptions = { signal?: AbortSignal, members?: number, defaults?: Partial<RoomDefaults> }
export type JoinOptions = { signal?: AbortSignal }
export type Room = {
readonly id: string
readonly key: string
readonly invite: string
readonly self: RoomMember
readonly owner: string
defaults: () => RoomDefaults
members: () => Promise<RoomMember[]>
send: (text: string) => Promise<void>
setDefault: (permission: 'send' | 'receive', value: boolean) => Promise<void>
grant: (id: string, permission: RoomPermission) => Promise<void>
revoke: (id: string, permission: RoomPermission) => Promise<void>
remove: (id: string) => Promise<void>
block: (id: string) => Promise<void>
unblock: (id: string) => Promise<void>
on: (listener: (event: RoomEvent) => void) => Promise<() => void>
leave: () => Promise<void>
readonly closed: Promise<RoomEnd>
}
export const available: () => Promise<boolean>
export const create: (options?: CreateOptions) => Promise<Room>
export const join: (invite: string, options?: JoinOptions) => Promise<Room>

Three functions and one object. available, create and join are the whole entry, and everything you do afterwards is a method on the Room they resolve. The rest of the block is types.

Import from @fkn/lib/rooms, or use the rooms namespace on the root entry. The examples on this page live in app.ts, the page of the media library the other guides build, and each block picks up where the previous one left off.

A room is three values. The id is a v4 uuid the platform assigns, and it encodes nothing: not where the room runs, not when it was made. The key is 32 random bytes minted in your browser by the FKN broker, and the platform never holds it. The invite is the two joined by a dot, 80 characters, and it is the only thing another browser needs.

app.ts
import * as
import rooms
rooms
from '@fkn/lib/rooms'
if (await
import rooms
rooms
.
function available(): Promise<boolean>
export available

Whether this realm can join a room: false in Node, false in a worker nothing bridged, false against a shell older than rooms. Answers rather than rejecting.

available
()) {
const
const room: rooms.Room
room
= await
import rooms
rooms
.
function create(options?: rooms.CreateOptions): Promise<rooms.Room>
export create

Open a room and become its owner. Share room.invite to let anyone else in.

create
()
const room: rooms.Room
room
.
id: string

the room's uuid

id
// a v4 uuid, which encodes nothing about where the room runs
const room: rooms.Room
room
.
key: string

the room key, base64url. The server never sees it. Anyone holding it and the id can join.

key
// the room key, base64url, and the platform never holds it
const room: rooms.Room
room
.
invite: string

id and key as one string, the thing to put in a link

invite
// the id and the key joined by a dot, 80 characters
var location: Location

The Window.location read-only property returns a Location object with information about the current location of the document.

MDN Reference

location
.
Location.hash: string

The hash property of the Location interface is a string containing a '#' followed by the fragment identifier of the location URL.

MDN Reference

hash
=
const room: rooms.Room
room
.
invite: string

id and key as one string, the thing to put in a link

invite
// a fragment, so the invite reaches no server log
}

The invite is a capability. Anyone holding it can attempt a join, subject to blocks, so share it the way you would share a private link. Put it in a URL fragment, as the block does, and it never reaches a server log: browsers keep everything after # out of the request.

The key never leaves the browsers that hold it. Messages are sealed under a key derived from it, and the platform verifies that a joiner holds the right key without learning what it is, so a wrong invite is refused before a single frame is relayed.

Anyone can create a room, with an FKN account or without one. The creator is the room’s first member and its owner for as long as the room lives. A join needs both halves of the invite, and an invite with no key is refused before any request is made.

app.ts
const
const open: () => Promise<rooms.Room>
open
= ():
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
import rooms
rooms
.
type Room = {
readonly id: string;
readonly key: string;
readonly invite: string;
readonly self: rooms.RoomMember;
readonly owner: string;
defaults: () => rooms.RoomDefaults;
members: () => Promise<rooms.RoomMember[]>;
send: (text: string) => Promise<void>;
setDefault: (permission: "send" | "receive", value: boolean) => Promise<void>;
grant: (id: string, permission: rooms.RoomPermission) => Promise<void>;
revoke: (id: string, permission: rooms.RoomPermission) => Promise<void>;
... 5 more ...;
readonly closed: Promise<rooms.RoomEnd>;
}
export Room

A joined room. The same object survives a broker replacement, so it is safe to hold for as long as the chat lasts.

Room
> => {
const
const invite: string
invite
=
var location: Location

The Window.location read-only property returns a Location object with information about the current location of the document.

MDN Reference

location
.
Location.hash: string

The hash property of the Location interface is a string containing a '#' followed by the fragment identifier of the location URL.

MDN Reference

hash
.
String.slice(start?: number, end?: number): string

Returns a section of a string.

@paramstart The index to the beginning of the specified portion of stringObj.

@paramend The index to the end of the specified portion of stringObj. The substring includes the characters up to, but not including, the character indicated by end. If this value is not specified, the substring continues to the end of stringObj.

slice
(1)
return
const invite: string
invite
?
import rooms
rooms
.
function join(invite: string, options?: rooms.JoinOptions): Promise<rooms.Room>
export join

invite is room.invite, or an id and a key joined by a dot.

join
(
const invite: string
invite
) :
import rooms
rooms
.
function create(options?: rooms.CreateOptions): Promise<rooms.Room>
export create

Open a room and become its owner. Share room.invite to let anyone else in.

create
({
members?: number | undefined
members
: 8 })
}
const
const room: rooms.Room
room
= await
const open: () => Promise<rooms.Room>
open
() // the room this tab opened, or the one the invite named
const room: rooms.Room
room
.
self: rooms.RoomMember

this app's member record, as this room sees it. The id is fresh in every room.

self
.
id: string
id
// this member's id, fresh in every room
const room: rooms.Room
room
.
owner: string
owner
// the id of whoever created it, for the life of the room
const room: rooms.Room
room
.
defaults: () => rooms.RoomDefaults
defaults
() // { send: true, receive: true } unless create set otherwise

create takes an optional members cap, clamped to 2 to 64, and optional defaults for the two permissions every joiner starts with. join takes the invite as one string. Both resolve once the room is open, and both reject with a RoomsError whose code says why.

The room lives while one member remains in it. When the last member leaves, the platform deletes the room with every permission and every block, and the invite then answers rooms: no such room.

A message is text of at most 4,096 bytes. Your browser seals it under a key derived from the room key before it leaves the tab, and the platform relays ciphertext it cannot read to every member who may receive. The browser on the other end unseals it, so your listener sees plain text and never a byte of ciphertext.

app.ts
const
const off: () => void
off
= await
const room: rooms.Room
room
.
on: (listener: (event: rooms.RoomEvent) => void) => Promise<() => void>

Await the returned unsubscribe in cleanup, the account.onChange shape.

on
(
event: rooms.RoomEvent
event
=> {
if (
event: rooms.RoomEvent
event
.
type: "message" | "joined" | "left" | "permissions" | "defaults"
type
=== 'message')
const line: (text: string) => void
line
(`${
event: {
type: "message";
message: rooms.RoomMessage;
}
event
.
message: rooms.RoomMessage
message
.
from: string
from
}: ${
event: {
type: "message";
message: rooms.RoomMessage;
}
event
.
message: rooms.RoomMessage
message
.
text: string
text
}`) // plaintext, unsealed in this browser
if (
event: rooms.RoomEvent
event
.
type: "message" | "joined" | "left" | "permissions" | "defaults"
type
=== 'joined')
const line: (text: string) => void
line
(`${
event: {
type: "joined";
member: rooms.RoomMember;
}
event
.
member: rooms.RoomMember
member
.
id: string
id
} joined`)
if (
event: rooms.RoomEvent
event
.
type: "message" | "joined" | "left" | "permissions" | "defaults"
type
=== 'left')
const line: (text: string) => void
line
(`${
event: {
type: "left";
id: string;
reason: "left" | "removed" | "blocked";
}
event
.
id: string
id
} ${
event: {
type: "left";
id: string;
reason: "left" | "removed" | "blocked";
}
event
.
reason: "left" | "removed" | "blocked"
reason
}`) // left, removed or blocked
})
await
const room: rooms.Room
room
.
send: (text: string) => Promise<void>

at most 4,096 bytes of UTF-8, sealed before it leaves the browser. Rejects too-large, never truncates.

send
('the catalog moved to library/catalog.json') // resolves once the platform took it
await
const off: () => void
off
() // await the unsubscribe in your cleanup

Every message carries a seq, a counter the room increments once per delivered message. It is a total order: every member sees the same messages in the same order, and you see your own message with the seq it was delivered under, so there is nothing to reconcile with a local echo. A gap in seq means your own connection missed something, never that the room reordered.

There is no history. A member who joins sees nothing that was sent before it arrived, and the platform holds no ciphertext it could replay. An app that wants a transcript keeps its own, and it has the plain text to do so.

Permissions come in two layers. A room carries defaults for send and receive, and a member carries overrides that the owner grants and revokes. Where a member has an override it wins, and where it does not the default applies, so the four permissions and their defaults are:

PermissionRoom defaultThe owner
sendtrue, unless create passed defaults: { send: false }always, and it cannot be revoked
receivetrue, unless create passed defaults: { receive: false }always, and it cannot be revoked
removenone, false until the owner grants italways
blocknone, false until the owner grants italways

remove and block have no room default. They are false until the owner grants them, because the member on the other end cannot undo them. grant, revoke and setDefault belong to the owner alone. A member holding remove may remove and may not grant it to anyone else, a member holding block may block and unblock, and nobody may remove or block the owner.

app.ts
await
const room: rooms.Room
room
.
setDefault: (permission: "send" | "receive", value: boolean) => Promise<void>
setDefault
('send', false) // the owner alone, and every member without an override stops sending
await
const room: rooms.Room
room
.
grant: (id: string, permission: rooms.RoomPermission) => Promise<void>
grant
(
const id: string
id
, 'send') // an override, which outlives a later change of the default
await
const room: rooms.Room
room
.
grant: (id: string, permission: rooms.RoomPermission) => Promise<void>
grant
(
const id: string
id
, 'remove') // a delegated moderator, who may remove and may not grant
await
const room: rooms.Room
room
.
remove: (id: string) => Promise<void>
remove
(
const id: string
id
) // they leave with reason 'removed', and may join again
await
const room: rooms.Room
room
.
block: (id: string) => Promise<void>
block
(
const id: string
id
) // they leave with reason 'blocked', and the rejoin is refused
await
const room: rooms.Room
room
.
unblock: (id: string) => Promise<void>
unblock
(
const id: string
id
) // the room forgets the block
const room: rooms.Room
room
.
self: rooms.RoomMember

this app's member record, as this room sees it. The id is fresh in every room.

self
.
permissions: Readonly<Record<rooms.RoomPermission, boolean>>
permissions
.
block: boolean
block
// what this app may do here, without asking

Changing a default moves every member who has no override for it, joined already or joining later, and each member it moved receives a permissions event so room.self.permissions and members() stay current. An override outlives any later change of the default. Removing a member ends their membership, and they may join again with the same invite. Blocking removes them and refuses their return.

A block holds for the room’s life: from any device, any app and any network for a member with an account, and from the same tab and the same network for a guest. It dies with the room, along with every other permission, so a new room starts clean.

Reconnecting is the broker’s job, never yours. When a connection drops, the FKN broker re-dials on your behalf and rejoins as the same member, and your Room object keeps working. What you see depends on what happened:

What happenedWhat the app sees
a network blip, a phone locking, a broker reloadthe same Room object and a gap in seq
a platform deploythe same gap, once the first rejoin restores the room
the hold running outroom.closed settles unavailable, and a fresh join on the invite works while another member is still there
a hard restart of the platformroom.closed settles unavailable, and the invite answers rooms: no such room
room.leave()room.closed settles left, with no hold

The hold is 20 seconds, and it is what turns a Wi-Fi handover, a phone locking, the shell’s Update button and a platform deploy into a gap in seq rather than a departure. The broker re-dials at 500 ms, then 1, 2, 4 and 8 seconds, and gives up when the hold runs out. A room the platform snapshots on a deploy comes back on the first rejoin, with its members, permissions and blocks intact.

app.ts
const
const end: rooms.RoomEnd
end
= await
const room: rooms.Room
room
.
closed: Promise<rooms.RoomEnd>

Settles once, when the room ends for this app. Never rejects.

closed
// settles once, and never rejects
const end: rooms.RoomEnd
end
.
reason: "left" | "removed" | "blocked" | "ended" | "unavailable"
reason
// 'left', 'removed', 'blocked', 'ended' or 'unavailable'
const line: (text: string) => void
line
(
const end: rooms.RoomEnd
end
.
reason: "left" | "removed" | "blocked" | "ended" | "unavailable"
reason
=== 'ended' ? 'the room ended' : `you left the room (${
const end: rooms.RoomEnd
end
.
reason: "left" | "removed" | "blocked" | "unavailable"
reason
})`)

A member id never changes silently. If a rejoin would seat you as a different member, because the hold ran out or a guest’s tab lost its seed, the broker reports the room closed instead of continuing as someone else. Call join again with the invite for a new Room with a new self.

Every member gets a fresh id in every room. The platform derives it from who you are and from a value your browser derives from the room key, so it survives your reconnects and is unrelated to your id in any other room. With an FKN account it is also the same in your other tabs and on your other devices, and a guest carries one id per tab. No app, and no room owner, can recognise the same person across two rooms from the ids alone.

app.ts
const room: rooms.Room
room
.
self: rooms.RoomMember

this app's member record, as this room sees it. The id is fresh in every room.

self
.
id: string
id
// this member, in this room, and nowhere else
const
const members: rooms.RoomMember[]
members
= await
const room: rooms.Room
room
.
members: () => Promise<rooms.RoomMember[]>
members
() // the ids you address in grant, revoke, remove and block
const members: rooms.RoomMember[]
members
[0]?.
permissions: Readonly<Record<rooms.RoomPermission, boolean>>
permissions
.
send: boolean
send
// what that member may do here

Ids are display values. Use them to address a member in grant, revoke, remove and block, and to tell members apart on screen, and give them nothing more: a room has no names, no avatars and no account details, and a member with an FKN account looks exactly like one without.

What the platform can see is who is in a room, who sent each message, when, and how large it was. What it cannot see is a byte of content. The room key is never on its side, and the value it derives member ids from is zeroed when the room ends, so a room that is over holds nothing that maps an id back to a person. Timing, writing style and any nickname your app asks for are outside that guarantee, since they are yours and not the platform’s.

Rooms are bounded per member, per room and per address, and each bound refuses by name rather than trimming or stalling.

Every number a room carries is on limits and timeouts.

Some of what a room does not do is a decision and some is a limit of today’s platform. Either way, plan around these:

  • A hard restart. A platform deploy snapshots every room and brings it back on the first rejoin. A hard restart of the platform, a crash or a node reboot, writes no snapshot, and every room it held ends.
  • One region. The service runs in one region. A member far from it pays that round trip on every message, and a room does not follow its members around the world.
  • History. A joiner sees nothing sent before it arrived, and there is nothing to replay: the platform holds no ciphertext once it has been relayed.
  • A durable block on a guest. A guest is blocked by the tab and by the network. Close the tab and change network, and they are a new visitor. The network half also reaches a bystander on the same address.
  • A guest owner who closes the tab. A guest owner’s identity lives in the tab. Close it and nobody can grant or revoke in that room again, although delegated moderators keep working. Alone in the room, a guest owner ends it by leaving, and the app creates a new one.
  • A lost invite. There is no list of your rooms. Lose the invite and the room is unreachable, and an account keeps that room’s slot against its cap until the room empties.
  • Moderation after the room ends. The platform cannot read a message and keeps no record of a room once it ends. Moderation is the owner’s, and it happens live or not at all.
  • A local counterpart. Every other capability can run against your own machine instead of the cloud. A room is a rendezvous between strangers, and it is cloud only.

The six rows a reader of this page meets most, each linked to its row on every error:

MessageWhat happened
rooms: no such roomThe invite names a room that has ended, or that a hard restart took with it. Create a new room and share the new invite.
rooms: wrong room keyThe invite’s uuid names a live room and its key is not that room’s key. Share the whole invite, room.invite, and never the id alone.
rooms: you are blocked from this roomThe owner, or a member holding block, blocked this member, and the block holds for the room’s life. There is nothing to retry.
rooms: you cannot send hereThe room’s send default is off and this member has no override, or the owner revoked it. Ask the owner for grant, or read room.self.permissions before showing a composer.
rooms: the message is too largeThe text is over 4,096 bytes of UTF-8. Nothing was sent and nothing was trimmed, so split it or shorten it.
rooms: the room has endedYou called a method on a Room after closed settled. Drop the object and, if the invite is still good, join again.

Every other message has its row on every error, and how to match one is on handling errors.