Skip to content

Run a chat room

By the end of this recipe two browsers share one room, a message typed in either shows up in both, and the invite that got them there sits in the page address, with every message sealed before it leaves the tab.

  • Uses available, create and join from @fkn/lib/rooms, and on, send, grant, setDefault, leave and closed on the Room they resolve
  • Needs nothing installed
  • Proven by the two-browser journey in the fkn.app release gate, which runs this recipe on every promotion

A room is a realtime channel the platform relays between browsers. The invite is its uuid and its key joined by a dot, and the key never reaches the platform: your browser seals every message under it, and the broker, the connection your app holds into FKN through a hidden fkn.app frame, does the sealing and the reconnecting. The app is the media library from the other pages, growing a chat beside its catalog.

Every step is [Page], defined on recipes, because a room is window work: the broker frame holds the connection, so the page that mounts it is where the calls belong.

available answers rather than rejecting. It is false in Node and in a worker nobody relayed, and true in a page with a broker, so the first thing the page does is ask.

app.ts
import * as
import rooms
rooms
from '@fkn/lib/rooms'
const
const log: HTMLUListElement
log
=
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
ParentNode.querySelector<HTMLUListElement>(selectors: string): HTMLUListElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
<
interface HTMLUListElement

The HTMLUListElement interface provides special properties (beyond those defined on the regular HTMLElement interface it also has available to it by inheritance) for manipulating unordered list (ul) elements.

MDN Reference

HTMLUListElement
>('#log')!
const
const line: (text: string) => void
line
= (
text: string
text
: string) => { const
const li: HTMLLIElement
li
=
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.createElement<"li">(tagName: "li", options?: ElementCreationOptions): HTMLLIElement (+2 overloads)

In an HTML document, the document.createElement() method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.

MDN Reference

createElement
('li');
const li: HTMLLIElement
li
.
Element.textContent: string | null
textContent
=
text: string
text
;
const log: HTMLUListElement
log
.
ParentNode.append(...nodes: (Node | string)[]): void

Inserts nodes after the last child of node, while replacing strings in nodes with equivalent Text nodes.

Throws a "HierarchyRequestError" DOMException if the constraints of the node tree are violated.

MDN Reference

append
(
const li: HTMLLIElement
li
) }
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 line: (text: string) => void
line
('rooms need a browser tab') // false in Node, and in a worker nobody relayed

The fallback line tells the person why nothing else will happen, and it costs one round trip to the broker. Everything after this step assumes the answer was true.

The page address decides. A fragment holds an invite, so the tab joins, and no fragment means this tab is the first, so it creates. Either way open resolves the same Room.

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
()
}
const
const room: rooms.Room
room
= await
const open: () => Promise<rooms.Room>
open
().
Promise<Room>.catch<never>(onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<rooms.Room>

Attaches a callback for only the rejection of the Promise.

@paramonrejected The callback to execute when the Promise is rejected.

@returnsA Promise for the completion of the callback.

catch
((
error: unknown
error
: unknown) => {
const
const code: rooms.RoomsErrorCode
code
= (
error: unknown
error
as
import rooms
rooms
.
type RoomsError = Error & {
code: rooms.RoomsErrorCode;
}
export RoomsError

Thrown by every member of this namespace. Match on code, never on the message.

RoomsError
).
code: rooms.RoomsErrorCode
code
const line: (text: string) => void
line
(
const code: rooms.RoomsErrorCode
code
=== 'not-found' ? 'that room has ended' :
const code: "invalid" | "bad-key" | "full" | "blocked" | "denied" | "rate-limited" | "too-large" | "unavailable" | "closed"
code
=== 'bad-key' ? 'that link is wrong' :
const code: "invalid" | "full" | "blocked" | "denied" | "rate-limited" | "too-large" | "unavailable" | "closed"
code
=== 'blocked' ? 'you cannot rejoin that room' : 'could not open the room')
throw
error: unknown
error
})
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
// the invite is the whole page address to share
const line: (text: string) => void
line
('share this page address to invite people')

The catch branches on code, never on the message: not-found for a room that has ended, bad-key for an invite whose key is not that room’s, blocked for a member the owner turned away, and everything else as a plain failure. A code survives a library update and a reworded message, and a message match does not. On success the invite becomes the page address, so sharing the address is inviting.

One listener receives every event the room produces, and one function renders them. Members have no names, so the page invents labels as ids appear: you for this tab, then guest 1, guest 2 and so on in order of arrival.

app.ts
const
const names: Map<string, string>
names
= new
var Map: MapConstructor
new <string, string>(iterable?: Iterable<readonly [string, string]> | null | undefined) => Map<string, string> (+3 overloads)
Map
<string, string>([[
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
, 'you']])
const
const nameFor: (id: string) => string
nameFor
= (
id: string
id
: string) =>
const names: Map<string, string>
names
.
Map<string, string>.get(key: string): string | undefined

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
(
id: string
id
) ?? (
const names: Map<string, string>
names
.
Map<string, string>.set(key: string, value: string): Map<string, string>

Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.

set
(
id: string
id
, `guest ${
const names: Map<string, string>
names
.
Map<string, string>.size: number

@returnsthe number of elements in the Map.

size
}`),
const names: Map<string, string>
names
.
Map<string, string>.get(key: string): string | undefined

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
(
id: string
id
)!)
const
const render: (event: rooms.RoomEvent) => void
render
= (
event: rooms.RoomEvent
event
:
import rooms
rooms
.
type RoomEvent = {
type: "message";
message: rooms.RoomMessage;
} | {
type: "joined";
member: rooms.RoomMember;
} | {
type: "left";
id: string;
reason: "left" | "removed" | "blocked";
} | {
type: "permissions";
id: string;
permissions: rooms.RoomPermissions;
} | {
type: "defaults";
defaults: rooms.RoomDefaults;
}
export RoomEvent
RoomEvent
) => {
if (
event: rooms.RoomEvent
event
.
type: "message" | "joined" | "left" | "permissions" | "defaults"
type
=== 'message')
const line: (text: string) => void
line
(`${
const nameFor: (id: string) => string
nameFor
(
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
}`) // the text, in this browser
if (
event: rooms.RoomEvent
event
.
type: "message" | "joined" | "left" | "permissions" | "defaults"
type
=== 'joined')
const line: (text: string) => void
line
(`${
const nameFor: (id: string) => string
nameFor
(
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
(`${
const nameFor: (id: string) => string
nameFor
(
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
}
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
(
const render: (event: rooms.RoomEvent) => void
render
) // hold the unsubscribe and await it in your cleanup

Hold the unsubscribe and await it in your cleanup. The ids are display values with no meaning outside this room, so the label map is the right place to keep whatever the person should see, and a message’s from is only ever looked up there.

The form hands its text to send, which resolves once the platform has taken the sealed message. It rejects rather than truncating: text over 4,096 bytes comes back as too-large with nothing sent.

app.ts
const
const form: HTMLFormElement
form
=
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
ParentNode.querySelector<HTMLFormElement>(selectors: string): HTMLFormElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
<
interface HTMLFormElement

The HTMLFormElement interface represents a form element in the DOM.

MDN Reference

HTMLFormElement
>('#say')!
const
const field: HTMLInputElement
field
=
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
ParentNode.querySelector<HTMLInputElement>(selectors: string): HTMLInputElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
<
interface HTMLInputElement

The HTMLInputElement interface provides special properties and methods for manipulating the options, layout, and presentation of input elements.

MDN Reference

HTMLInputElement
>('#text')!
const form: HTMLFormElement
form
.
HTMLFormElement.addEventListener<"submit">(type: "submit", listener: (this: HTMLFormElement, ev: SubmitEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

addEventListener
('submit', async
submit: SubmitEvent
submit
=> {
submit: SubmitEvent
submit
.
Event.preventDefault(): void

The preventDefault() method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be.

MDN Reference

preventDefault
()
const
const text: string
text
=
const field: HTMLInputElement
field
.
HTMLInputElement.value: string

The value property of the HTMLInputElement interface represents the current value of the input element as a string.

MDN Reference

value
.
String.trim(): string

Removes the leading and trailing white space and line terminator characters from a string.

trim
()
const field: HTMLInputElement
field
.
HTMLInputElement.value: string

The value property of the HTMLInputElement interface represents the current value of the input element as a string.

MDN Reference

value
= ''
if (!
const text: string
text
) return
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
(
const text: string
text
).
Promise<void>.catch<void>(onrejected?: ((reason: any) => void | PromiseLike<void>) | null | undefined): Promise<void>

Attaches a callback for only the rejection of the Promise.

@paramonrejected The callback to execute when the Promise is rejected.

@returnsA Promise for the completion of the callback.

catch
((
error: unknown
error
: unknown) =>
const line: (text: string) => void
line
((
error: unknown
error
as
import rooms
rooms
.
type RoomsError = Error & {
code: rooms.RoomsErrorCode;
}
export RoomsError

Thrown by every member of this namespace. Match on code, never on the message.

RoomsError
).
code: rooms.RoomsErrorCode
code
=== 'rate-limited' ? 'slow down a moment' : 'not sent'))
})

A rate-limited refusal is a wait, not a failure. The member’s allowance refills at 10 messages a second, so the right response is a short pause and a retry, and the page says so instead of dropping the text. Ten refusals inside ten seconds close the connection, so a retry loop needs a delay in it.

closed settles once and never rejects, whatever ended the room. Await it after the listener is up, and the page has one place to say what happened.

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
await
const off: () => void
off
()
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
})`) // 'left', 'removed', 'blocked', 'ended' or 'unavailable'

Each reason means something different to the UI. left is your own leave, removed and blocked are the owner’s doing, and only after removed can a deliberate join on the same invite bring the person back, as a fresh member. ended is the room ending under you, and unavailable is a connection the broker could not restore within the hold. The unsubscribe is awaited before the message, so no event lands on a page that has already said goodbye.

Open the page in one browser and copy its address once the invite line appears. Open that address in a second browser, or a private window, and type in either: the line shows up in both, prefixed you on the sending side and guest 1 on the other, and the platform never saw the text. Close one tab and the other reports the departure once the hold runs out, about 20 seconds later.

  1. The second browser reports that room has ended or that link is wrong: the first tab left, so the room was deleted with it, or the address was copied without its fragment. Copy the whole address while the first tab is still open (rooms: no such room, rooms: wrong room key)
  2. A rejoin reports you cannot rejoin that room: the owner blocked that member, and the block holds for the room’s life (rooms: you are blocked from this room)
  3. send rejects with denied: the room’s send default is off and this member was not granted, so check room.self.permissions.send before showing the composer (rooms: you cannot send here, permissions)
  4. The page says slow down a moment: the per-member rate was passed, so wait and retry rather than resending in a loop (rooms: sending too fast, per member)
  5. The page says not sent for a long message: the text is over the cap, whole and untrimmed, so shorten it (rooms: the message is too large, 4,096 bytes)
  6. closed settles unavailable: the connection dropped and the broker could not restore it inside the hold, so call join again with the invite (rooms: rooms are unavailable, the hold)

A broadcast room is one where only the owner speaks, and anyone the owner grants. Pass defaults: { send: false } at create, and every joiner starts silent.

app.ts
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
({
defaults?: Partial<Readonly<{
send: boolean;
receive: boolean;
}>> | undefined
defaults
: {
send?: boolean | undefined
send
: false } }) // the owner speaks, and nobody else until they are granted
await
const room: rooms.Room
room
.
grant: (id: string, permission: rooms.RoomPermission) => Promise<void>
grant
(
const id: string
id
, 'send') // one member, while the default still holds for the rest
const room: rooms.Room
room
.
defaults: () => rooms.RoomDefaults
defaults
() // { send: false, receive: true }

A grant is an override, so it survives a later change of the default, and revoking it puts that member back under the default. room.defaults() answers synchronously, so a composer can read it before it renders.