room-kit 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,10 @@ export const chatRoom = defineRoomType<{
61
61
  rpc: {
62
62
  sendMessage: (input: { text: string }): Promise<{ id: string }>;
63
63
  };
64
- }>({ name: "chat", presence: "list" });
64
+ }>({
65
+ name: "chat", // namespace
66
+ presence: "list" // allow clients to list room members
67
+ });
65
68
  ```
66
69
 
67
70
  ```ts
@@ -155,13 +158,23 @@ const joined = await chatClient.join({
155
158
  });
156
159
 
157
160
  // Event payload and metadata are both inferred from the room schema.
158
- joined.on.message((payload, meta) => {
159
- // meta.source.kind is "server" or "member".
160
- console.log(payload.text, meta.source.kind);
161
+ const cleanup = joined.listen({
162
+ events: {
163
+ message: (payload, meta) => {
164
+ // meta.source.kind is "server" or "member".
165
+ console.log(payload.text, meta.source.kind);
166
+ },
167
+ },
168
+ presence: {
169
+ onChange: (presence) => {
170
+ console.log(`${presence.count} members online`);
171
+ },
172
+ },
161
173
  });
162
174
 
163
175
  // Fully typed request/response based on your room definition.
164
176
  await joined.rpc.sendMessage({ text: "hello" });
177
+ cleanup();
165
178
  // Cleanly leave the room when you're done.
166
179
  await joined.leave();
167
180
  ```
@@ -188,7 +201,7 @@ Runtime presence mode is configured in the `defineRoomType` options:
188
201
 
189
202
  `serveRoomType(socket, roomType, handlers, adapter?)` accepts:
190
203
 
191
- - `onAuth(socket)`: optional unless you type a non-`unknown` auth context.
204
+ - `onAuth(socket)`: optional unless you type a non-`unknown` auth context. Return `false` to reject the socket before room initialization.
192
205
  - `onConnect(socket, auth)`: optional transport-connect hook attempted once when the socket handler is attached (after auth resolution).
193
206
  - `revalidateAuth(socket, auth)`: optional per-request auth validation hook; return `{ kind: "ok", auth? }` to continue or `{ kind: "reject" }` to deny.
194
207
  - `initState(joinRequest)`: initializes room server state on first join for a given room instance.
@@ -212,11 +225,11 @@ Server context (`ctx`) includes:
212
225
 
213
226
  `serveRoomType` returns a handle:
214
227
 
215
- - `stop()` unregisters listeners for that socket
216
- - `stop.rooms()` returns snapshots for all rooms on the namespace
217
- - `stop.room(roomId)` returns one room snapshot or `undefined`
218
- - `stop.count(roomId)` returns the current member count for a room (`0` when the room does not exist; throws when room presence mode is `"none"`)
219
- - `stop.members(roomId, query)` returns a paginated presence listing (`{ count: 0, offset: 0, limit: 0, members: [] }` when the room does not exist; throws when room presence mode is not `"list"`)
228
+ - `handle.cleanup()` unregisters listeners for that socket
229
+ - `handle.rooms()` returns snapshots for all rooms on the namespace
230
+ - `handle.room(roomId)` returns one room snapshot or `undefined`
231
+ - `handle.count(roomId)` returns the current member count for a room (`0` when the room does not exist; throws when room presence mode is `"none"`)
232
+ - `handle.members(roomId, query)` returns a paginated presence listing (`{ count: 0, offset: 0, limit: 0, members: [] }` when the room does not exist; throws when room presence mode is not `"list"`)
220
233
 
221
234
  ## Client API
222
235
 
@@ -233,6 +246,7 @@ Server context (`ctx`) includes:
233
246
  - `joinedRoom.rpc.<name>(...args)` for typed RPC calls
234
247
  - `joinedRoom.emit.<event>(payload)` for client-emitted room events
235
248
  - `joinedRoom.on.<event>((payload, meta) => {})` for room event subscriptions
249
+ - `joinedRoom.listen({ events, presence })` batches event and presence subscriptions and returns one cleanup function
236
250
  - `joinedRoom.leave()` to leave the room and unregister the joined-room handle
237
251
  - `joinedRoom.presence` is part of the typed API when room presence mode is `"count"` or `"list"`; it exposes `current`, `onChange(handler)`, `count()`, and `list({ offset, limit })` when presence mode is `"list"`.
238
252
 
@@ -240,6 +254,7 @@ Server context (`ctx`) includes:
240
254
 
241
255
  - Throw `ClientSafeError` for messages you want sent to clients.
242
256
  - Non-`ClientSafeError` exceptions are sanitized to: `"An internal server error occurred."`
257
+ - `onAuth` may return `false` to reject the socket before any room state is initialized.
243
258
  - RPC and event dispatch only allow own properties (`Object.hasOwn`) to prevent prototype-based handler access.
244
259
  - Client event names are default-deny unless explicitly declared in `handlers.events`.
245
260
  - Do not trust client payloads for authorization; derive identity in `onAuth`.
@@ -26,271 +26,268 @@ const keyInput = document.getElementById("key-input") as HTMLInputElement;
26
26
  const messageInput = document.getElementById("message-input") as HTMLInputElement;
27
27
 
28
28
  let joinedRoom: JoinedRoom<typeof chatRoomType> | null = null;
29
- let stopMessageListener: (() => void) | null = null;
30
- let stopSystemListener: (() => void) | null = null;
31
- let stopPresenceListener: (() => void) | null = null;
29
+ let stopRoomListeners: (() => void) | null = null;
32
30
  let presenceOffset = 0;
33
31
  const presencePageSize = 4;
34
32
 
35
33
  function setStatus(message: string): void {
36
- status.textContent = message;
34
+ status.textContent = message;
37
35
  }
38
36
 
39
37
  function setWorkspaceVisible(visible: boolean): void {
40
- workspace.classList.toggle("hidden", !visible);
41
- joinPanel.classList.toggle("hidden", visible);
38
+ workspace.classList.toggle("hidden", !visible);
39
+ joinPanel.classList.toggle("hidden", visible);
42
40
  }
43
41
 
44
42
  function clearMessages(): void {
45
- messages.innerHTML = "";
43
+ messages.innerHTML = "";
46
44
  }
47
45
 
48
46
  function renderMessage(message: ChatMessage): void {
49
- const item = document.createElement("article");
50
- item.className = "message";
51
- item.innerHTML = `
47
+ const item = document.createElement("article");
48
+ item.className = "message";
49
+ item.innerHTML = `
52
50
  <div class="message-header">
53
51
  <span class="message-name">${escapeHtml(message.name)}</span>
54
52
  <span class="message-time">${formatTime(message.sentAt)}</span>
55
53
  </div>
56
54
  <div class="message-text">${escapeHtml(message.text)}</div>
57
55
  `;
58
- messages.appendChild(item);
59
- messages.scrollTop = messages.scrollHeight;
56
+ messages.appendChild(item);
57
+ messages.scrollTop = messages.scrollHeight;
60
58
  }
61
59
 
62
60
  function renderSystemNotice(text: string, sentAt: string): void {
63
- const item = document.createElement("article");
64
- item.className = "message";
65
- item.innerHTML = `
61
+ const item = document.createElement("article");
62
+ item.className = "message";
63
+ item.innerHTML = `
66
64
  <div class="message-header">
67
65
  <span class="message-name">System</span>
68
66
  <span class="message-time">${formatTime(sentAt)}</span>
69
67
  </div>
70
68
  <div class="message-text">${escapeHtml(text)}</div>
71
69
  `;
72
- messages.appendChild(item);
73
- messages.scrollTop = messages.scrollHeight;
70
+ messages.appendChild(item);
71
+ messages.scrollTop = messages.scrollHeight;
74
72
  }
75
73
 
76
74
  function renderHistory(history: ChatMessage[]): void {
77
- clearMessages();
78
- for (const message of history) {
79
- renderMessage(message);
80
- }
75
+ clearMessages();
76
+ for (const message of history) {
77
+ renderMessage(message);
78
+ }
81
79
  }
82
80
 
83
81
  function renderPresence(): void {
84
- if (!joinedRoom) {
85
- presenceCount.textContent = "0 online";
86
- presencePage.textContent = "Showing 0 members.";
87
- presenceList.innerHTML = "";
88
- return;
89
- }
90
-
91
- const presence = joinedRoom.presence.current;
92
- presenceCount.textContent = `${presence.count} online`;
93
- presencePage.textContent = `Showing ${Math.min(presenceOffset + 1, presence.count)}-${Math.min(
94
- presenceOffset + presence.members.length,
95
- presence.count,
96
- )} of ${presence.count} members.`;
97
- presenceList.innerHTML = "";
98
-
99
- if (presence.members.length === 0) {
100
- const empty = document.createElement("li");
101
- empty.className = "presence-empty";
102
- empty.textContent = "Nobody is here yet.";
103
- presenceList.appendChild(empty);
104
- return;
105
- }
106
-
107
- for (const entry of presence.members) {
108
- const item = document.createElement("li");
109
- item.className = "presence-item";
110
- item.innerHTML = `
82
+ if (!joinedRoom) {
83
+ presenceCount.textContent = "0 online";
84
+ presencePage.textContent = "Showing 0 members.";
85
+ presenceList.innerHTML = "";
86
+ return;
87
+ }
88
+
89
+ const presence = joinedRoom.presence.current;
90
+ presenceCount.textContent = `${presence.count} online`;
91
+ presencePage.textContent = `Showing ${Math.min(presenceOffset + 1, presence.count)}-${Math.min(
92
+ presenceOffset + presence.members.length,
93
+ presence.count,
94
+ )} of ${presence.count} members.`;
95
+ presenceList.innerHTML = "";
96
+
97
+ if (presence.members.length === 0) {
98
+ const empty = document.createElement("li");
99
+ empty.className = "presence-empty";
100
+ empty.textContent = "Nobody is here yet.";
101
+ presenceList.appendChild(empty);
102
+ return;
103
+ }
104
+
105
+ for (const entry of presence.members) {
106
+ const item = document.createElement("li");
107
+ item.className = "presence-item";
108
+ item.innerHTML = `
111
109
  <span>${escapeHtml(entry.memberProfile.userName)}</span>
112
110
  <span class="presence-pill" aria-hidden="true"></span>
113
111
  `;
114
- presenceList.appendChild(item);
115
- }
112
+ presenceList.appendChild(item);
113
+ }
116
114
  }
117
115
 
118
116
  async function refreshPresence(): Promise<void> {
119
- if (!joinedRoom) {
120
- return;
121
- }
122
-
123
- const [count, page] = await Promise.all([
124
- joinedRoom.presence.count(),
125
- joinedRoom.presence.list({ offset: presenceOffset, limit: presencePageSize }),
126
- ]);
127
-
128
- presenceCount.textContent = `${count} online`;
129
- presencePage.textContent = page.members.length === 0
130
- ? `Showing 0 of ${count} members.`
131
- : `Showing ${page.offset + 1}-${page.offset + page.members.length} of ${count} members.`;
132
-
133
- presenceList.innerHTML = "";
134
- if (page.members.length === 0) {
135
- const empty = document.createElement("li");
136
- empty.className = "presence-empty";
137
- empty.textContent = "Nobody is here yet.";
138
- presenceList.appendChild(empty);
139
- return;
140
- }
141
-
142
- for (const entry of page.members) {
143
- const item = document.createElement("li");
144
- item.className = "presence-item";
145
- item.innerHTML = `
117
+ if (!joinedRoom) {
118
+ return;
119
+ }
120
+
121
+ const [count, page] = await Promise.all([
122
+ joinedRoom.presence.count(),
123
+ joinedRoom.presence.list({ offset: presenceOffset, limit: presencePageSize }),
124
+ ]);
125
+
126
+ presenceCount.textContent = `${count} online`;
127
+ presencePage.textContent = page.members.length === 0
128
+ ? `Showing 0 of ${count} members.`
129
+ : `Showing ${page.offset + 1}-${page.offset + page.members.length} of ${count} members.`;
130
+
131
+ presenceList.innerHTML = "";
132
+ if (page.members.length === 0) {
133
+ const empty = document.createElement("li");
134
+ empty.className = "presence-empty";
135
+ empty.textContent = "Nobody is here yet.";
136
+ presenceList.appendChild(empty);
137
+ return;
138
+ }
139
+
140
+ for (const entry of page.members) {
141
+ const item = document.createElement("li");
142
+ item.className = "presence-item";
143
+ item.innerHTML = `
146
144
  <span>${escapeHtml(entry.memberProfile.userName)}</span>
147
145
  <span class="presence-pill" aria-hidden="true"></span>
148
146
  `;
149
- presenceList.appendChild(item);
150
- }
147
+ presenceList.appendChild(item);
148
+ }
151
149
  }
152
150
 
153
151
  function formatTime(value: string): string {
154
- const date = new Date(value);
155
- return Number.isNaN(date.getTime())
156
- ? ""
157
- : new Intl.DateTimeFormat(undefined, {
158
- hour: "2-digit",
159
- minute: "2-digit",
160
- }).format(date);
152
+ const date = new Date(value);
153
+ return Number.isNaN(date.getTime())
154
+ ? ""
155
+ : new Intl.DateTimeFormat(undefined, {
156
+ hour: "2-digit",
157
+ minute: "2-digit",
158
+ }).format(date);
161
159
  }
162
160
 
163
161
  function escapeHtml(value: string): string {
164
- return String(value)
165
- .replaceAll("&", "&amp;")
166
- .replaceAll("<", "&lt;")
167
- .replaceAll(">", "&gt;")
168
- .replaceAll('"', "&quot;")
169
- .replaceAll("'", "&#39;");
162
+ return String(value)
163
+ .replaceAll("&", "&amp;")
164
+ .replaceAll("<", "&lt;")
165
+ .replaceAll(">", "&gt;")
166
+ .replaceAll('"', "&quot;")
167
+ .replaceAll("'", "&#39;");
170
168
  }
171
169
 
172
170
  async function connectRoom(): Promise<void> {
173
- const payload = {
174
- roomId: roomInput.value.trim().toLowerCase(),
175
- roomKey: keyInput.value,
176
- userName: nameInput.value,
177
- };
178
-
179
- setStatus("Joining room...");
180
-
181
- try {
182
- presenceOffset = 0;
183
- joinedRoom = await chatClient.join(payload);
184
- renderHistory(joinedRoom.roomProfile.history);
185
- await refreshPresence();
186
-
187
- stopMessageListener = joinedRoom.on.message((message) => {
188
- renderMessage(message);
189
- });
190
-
191
- stopSystemListener = joinedRoom.on.systemNotice((notice) => {
192
- renderSystemNotice(notice.text, notice.sentAt);
193
- });
194
-
195
- stopPresenceListener = joinedRoom.presence.onChange(() => {
196
- void refreshPresence();
197
- });
198
-
199
- roomTitle.textContent = joinedRoom.roomId;
200
- roomSubtitle.textContent = `Signed in as ${payload.userName}. The room stays private behind the shared key.`;
201
- setWorkspaceVisible(true);
202
- setStatus(`Connected to ${joinedRoom.roomId}.`);
203
- messageInput.focus();
204
- } catch (error) {
205
- joinedRoom = null;
206
- setStatus(error instanceof Error ? error.message : String(error));
207
- }
171
+ const payload = {
172
+ roomId: roomInput.value.trim().toLowerCase(),
173
+ roomKey: keyInput.value,
174
+ userName: nameInput.value,
175
+ };
176
+
177
+ setStatus("Joining room...");
178
+
179
+ try {
180
+ presenceOffset = 0;
181
+ joinedRoom = await chatClient.join(payload);
182
+ renderHistory(joinedRoom.roomProfile.history);
183
+ stopRoomListeners = joinedRoom.listen({
184
+ events: {
185
+ message: (message) => {
186
+ renderMessage(message);
187
+ },
188
+ systemNotice: (notice) => {
189
+ renderSystemNotice(notice.text, notice.sentAt);
190
+ },
191
+ },
192
+ presence: {
193
+ onChange: () => {
194
+ void refreshPresence();
195
+ },
196
+ },
197
+ });
198
+ await refreshPresence();
199
+
200
+ roomTitle.textContent = joinedRoom.roomId;
201
+ roomSubtitle.textContent = `Signed in as ${payload.userName}. The room stays private behind the shared key.`;
202
+ setWorkspaceVisible(true);
203
+ setStatus(`Connected to ${joinedRoom.roomId}.`);
204
+ messageInput.focus();
205
+ } catch (error) {
206
+ joinedRoom = null;
207
+ setStatus(error instanceof Error ? error.message : String(error));
208
+ }
208
209
  }
209
210
 
210
211
  async function leaveRoom(): Promise<void> {
211
- if (!joinedRoom) {
212
- return;
213
- }
214
-
215
- const roomId = joinedRoom.roomId;
216
-
217
- try {
218
- await joinedRoom.leave();
219
- } finally {
220
- stopMessageListener?.();
221
- stopSystemListener?.();
222
- stopPresenceListener?.();
223
- stopMessageListener = null;
224
- stopSystemListener = null;
225
- stopPresenceListener = null;
226
- joinedRoom = null;
227
-
228
- setWorkspaceVisible(false);
229
- joinForm.reset();
230
- clearMessages();
231
- presenceList.innerHTML = "";
232
- presenceCount.textContent = "0 online";
233
- presencePage.textContent = "Showing 0 members.";
234
- roomTitle.textContent = "-";
235
- roomSubtitle.textContent = "-";
236
- setStatus(`Left ${roomId}.`);
237
- }
212
+ if (!joinedRoom) {
213
+ return;
214
+ }
215
+
216
+ const roomId = joinedRoom.roomId;
217
+
218
+ try {
219
+ await joinedRoom.leave();
220
+ } finally {
221
+ stopRoomListeners?.();
222
+ stopRoomListeners = null;
223
+ joinedRoom = null;
224
+
225
+ setWorkspaceVisible(false);
226
+ joinForm.reset();
227
+ clearMessages();
228
+ presenceList.innerHTML = "";
229
+ presenceCount.textContent = "0 online";
230
+ presencePage.textContent = "Showing 0 members.";
231
+ roomTitle.textContent = "-";
232
+ roomSubtitle.textContent = "-";
233
+ setStatus(`Left ${roomId}.`);
234
+ }
238
235
  }
239
236
 
240
237
  joinForm.addEventListener("submit", async (event) => {
241
- event.preventDefault();
242
- await connectRoom();
238
+ event.preventDefault();
239
+ await connectRoom();
243
240
  });
244
241
 
245
242
  messageForm.addEventListener("submit", async (event) => {
246
- event.preventDefault();
247
-
248
- if (!joinedRoom) {
249
- setStatus("Join a room before sending messages.");
250
- return;
251
- }
252
-
253
- const text = messageInput.value.trim();
254
- if (!text) {
255
- return;
256
- }
257
-
258
- try {
259
- await joinedRoom.rpc.sendMessage({ text });
260
- messageInput.value = "";
261
- } catch (error) {
262
- setStatus(error instanceof Error ? error.message : String(error));
263
- }
243
+ event.preventDefault();
244
+
245
+ if (!joinedRoom) {
246
+ setStatus("Join a room before sending messages.");
247
+ return;
248
+ }
249
+
250
+ const text = messageInput.value.trim();
251
+ if (!text) {
252
+ return;
253
+ }
254
+
255
+ try {
256
+ await joinedRoom.rpc.sendMessage({ text });
257
+ messageInput.value = "";
258
+ } catch (error) {
259
+ setStatus(error instanceof Error ? error.message : String(error));
260
+ }
264
261
  });
265
262
 
266
263
  leaveButton.addEventListener("click", async () => {
267
- await leaveRoom();
264
+ await leaveRoom();
268
265
  });
269
266
 
270
267
  presencePrevButton.addEventListener("click", async () => {
271
- if (!joinedRoom || presenceOffset === 0) {
272
- return;
273
- }
268
+ if (!joinedRoom || presenceOffset === 0) {
269
+ return;
270
+ }
274
271
 
275
- presenceOffset = Math.max(0, presenceOffset - presencePageSize);
276
- await refreshPresence();
272
+ presenceOffset = Math.max(0, presenceOffset - presencePageSize);
273
+ await refreshPresence();
277
274
  });
278
275
 
279
276
  presenceNextButton.addEventListener("click", async () => {
280
- if (!joinedRoom) {
281
- return;
282
- }
277
+ if (!joinedRoom) {
278
+ return;
279
+ }
283
280
 
284
- presenceOffset += presencePageSize;
285
- await refreshPresence();
281
+ presenceOffset += presencePageSize;
282
+ await refreshPresence();
286
283
  });
287
284
 
288
285
  socket.on("connect", () => {
289
- setStatus("Connected. Join a room to start chatting.");
286
+ setStatus("Connected. Join a room to start chatting.");
290
287
  });
291
288
 
292
289
  socket.on("disconnect", () => {
293
- setStatus("Disconnected from the server.");
290
+ setStatus("Disconnected from the server.");
294
291
  });
295
292
 
296
293
  setWorkspaceVisible(false);