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.
Usesavailable, 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.
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.
In an HTML document, the document.createElement() method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.
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()))
constline: (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
constopen: () =>Promise<rooms.Room>
open= ():
interfacePromise<T>
Represents the completion of an asynchronous operation
@param ― start The index to the beginning of the specified portion of stringObj.
@param ― end 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.
id and key as one string, the thing to put in a link
invite// the invite is the whole page address to share
constline: (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
constnames: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>([[
constroom:rooms.Room
room.
self: rooms.RoomMember
this app's member record, as this room sees it. The id is fresh in every room.
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.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
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.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
on: (listener: (event:rooms.RoomEvent) =>void) =>Promise<() =>void>
Await the returned unsubscribe in cleanup, the account.onChange shape.
on(
constrender: (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
constform:HTMLFormElement
form=
var document:Document
window.document returns a reference to the document contained in the window.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
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.
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.
reason==='ended'?'the room ended':`you left the room (${
constend: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.
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)
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)
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)
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)
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.
id, 'send') // one member, while the default still holds for the rest
constroom: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.