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 +25 -10
- package/example/public/app.ts +184 -187
- package/example/server.ts +173 -146
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/client.ts +105 -16
- package/src/server.ts +189 -25
- package/src/types.ts +28 -4
- package/test/room.spec.ts +241 -78
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
|
-
}>({
|
|
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.
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
- `
|
|
216
|
-
- `
|
|
217
|
-
- `
|
|
218
|
-
- `
|
|
219
|
-
- `
|
|
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`.
|
package/example/public/app.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
+
status.textContent = message;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
function setWorkspaceVisible(visible: boolean): void {
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
workspace.classList.toggle("hidden", !visible);
|
|
39
|
+
joinPanel.classList.toggle("hidden", visible);
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
function clearMessages(): void {
|
|
45
|
-
|
|
43
|
+
messages.innerHTML = "";
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
function renderMessage(message: ChatMessage): void {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
56
|
+
messages.appendChild(item);
|
|
57
|
+
messages.scrollTop = messages.scrollHeight;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
function renderSystemNotice(text: string, sentAt: string): void {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
70
|
+
messages.appendChild(item);
|
|
71
|
+
messages.scrollTop = messages.scrollHeight;
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
function renderHistory(history: ChatMessage[]): void {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
clearMessages();
|
|
76
|
+
for (const message of history) {
|
|
77
|
+
renderMessage(message);
|
|
78
|
+
}
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
function renderPresence(): void {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
112
|
+
presenceList.appendChild(item);
|
|
113
|
+
}
|
|
116
114
|
}
|
|
117
115
|
|
|
118
116
|
async function refreshPresence(): Promise<void> {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
147
|
+
presenceList.appendChild(item);
|
|
148
|
+
}
|
|
151
149
|
}
|
|
152
150
|
|
|
153
151
|
function formatTime(value: string): string {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
162
|
+
return String(value)
|
|
163
|
+
.replaceAll("&", "&")
|
|
164
|
+
.replaceAll("<", "<")
|
|
165
|
+
.replaceAll(">", ">")
|
|
166
|
+
.replaceAll('"', """)
|
|
167
|
+
.replaceAll("'", "'");
|
|
170
168
|
}
|
|
171
169
|
|
|
172
170
|
async function connectRoom(): Promise<void> {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
await connectRoom();
|
|
243
240
|
});
|
|
244
241
|
|
|
245
242
|
messageForm.addEventListener("submit", async (event) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
264
|
+
await leaveRoom();
|
|
268
265
|
});
|
|
269
266
|
|
|
270
267
|
presencePrevButton.addEventListener("click", async () => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
268
|
+
if (!joinedRoom || presenceOffset === 0) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
274
271
|
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
presenceOffset = Math.max(0, presenceOffset - presencePageSize);
|
|
273
|
+
await refreshPresence();
|
|
277
274
|
});
|
|
278
275
|
|
|
279
276
|
presenceNextButton.addEventListener("click", async () => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
if (!joinedRoom) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
283
280
|
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
presenceOffset += presencePageSize;
|
|
282
|
+
await refreshPresence();
|
|
286
283
|
});
|
|
287
284
|
|
|
288
285
|
socket.on("connect", () => {
|
|
289
|
-
|
|
286
|
+
setStatus("Connected. Join a room to start chatting.");
|
|
290
287
|
});
|
|
291
288
|
|
|
292
289
|
socket.on("disconnect", () => {
|
|
293
|
-
|
|
290
|
+
setStatus("Disconnected from the server.");
|
|
294
291
|
});
|
|
295
292
|
|
|
296
293
|
setWorkspaceVisible(false);
|