@zooid/server 0.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/LICENSE +21 -0
- package/package.json +37 -0
- package/src/cloudflare-test.d.ts +4 -0
- package/src/db/queries.test.ts +501 -0
- package/src/db/queries.ts +450 -0
- package/src/db/schema.sql +56 -0
- package/src/do/channel.ts +69 -0
- package/src/index.ts +88 -0
- package/src/lib/jwt.test.ts +89 -0
- package/src/lib/jwt.ts +28 -0
- package/src/lib/schema-validator.test.ts +101 -0
- package/src/lib/schema-validator.ts +64 -0
- package/src/lib/signing.test.ts +73 -0
- package/src/lib/signing.ts +60 -0
- package/src/lib/ulid.test.ts +25 -0
- package/src/lib/ulid.ts +8 -0
- package/src/lib/validation.test.ts +35 -0
- package/src/lib/validation.ts +8 -0
- package/src/lib/xml.ts +13 -0
- package/src/middleware/auth.test.ts +125 -0
- package/src/middleware/auth.ts +103 -0
- package/src/routes/channels.test.ts +335 -0
- package/src/routes/channels.ts +220 -0
- package/src/routes/directory.test.ts +223 -0
- package/src/routes/directory.ts +109 -0
- package/src/routes/events.test.ts +477 -0
- package/src/routes/events.ts +315 -0
- package/src/routes/feed.test.ts +238 -0
- package/src/routes/feed.ts +101 -0
- package/src/routes/opml.test.ts +131 -0
- package/src/routes/opml.ts +41 -0
- package/src/routes/rss.test.ts +224 -0
- package/src/routes/rss.ts +91 -0
- package/src/routes/server-meta.test.ts +157 -0
- package/src/routes/server-meta.ts +100 -0
- package/src/routes/webhooks.test.ts +238 -0
- package/src/routes/webhooks.ts +111 -0
- package/src/routes/well-known.test.ts +34 -0
- package/src/routes/well-known.ts +58 -0
- package/src/routes/ws.test.ts +503 -0
- package/src/routes/ws.ts +25 -0
- package/src/test-utils.ts +79 -0
- package/src/types.ts +63 -0
- package/wrangler.toml +26 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
2
|
+
import { env, SELF } from 'cloudflare:test';
|
|
3
|
+
import app from '../index';
|
|
4
|
+
import { setupTestDb, cleanTestDb } from '../test-utils';
|
|
5
|
+
import { createToken } from '../lib/jwt';
|
|
6
|
+
|
|
7
|
+
const JWT_SECRET = 'test-jwt-secret';
|
|
8
|
+
|
|
9
|
+
async function adminRequest(path: string, options: RequestInit = {}) {
|
|
10
|
+
const token = await createToken({ scope: 'admin' }, JWT_SECRET);
|
|
11
|
+
const headers = new Headers(options.headers);
|
|
12
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
13
|
+
headers.set('Content-Type', 'application/json');
|
|
14
|
+
return app.request(
|
|
15
|
+
path,
|
|
16
|
+
{ ...options, headers },
|
|
17
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('WebSocket routes', () => {
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
await setupTestDb();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await cleanTestDb();
|
|
28
|
+
await adminRequest('/api/v1/channels', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
id: 'ws-channel',
|
|
32
|
+
name: 'WS Channel',
|
|
33
|
+
is_public: true,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
await adminRequest('/api/v1/channels', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
id: 'priv-ws',
|
|
40
|
+
name: 'Private WS',
|
|
41
|
+
is_public: false,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('GET /channels/:channelId/ws', () => {
|
|
47
|
+
it('returns 426 without Upgrade header', async () => {
|
|
48
|
+
const res = await app.request(
|
|
49
|
+
'/api/v1/channels/ws-channel/ws',
|
|
50
|
+
{},
|
|
51
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
52
|
+
);
|
|
53
|
+
expect(res.status).toBe(426);
|
|
54
|
+
const body = (await res.json()) as { error: string };
|
|
55
|
+
expect(body.error).toBe('Expected WebSocket upgrade');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns 404 for non-existent channel', async () => {
|
|
59
|
+
const res = await app.request(
|
|
60
|
+
'/api/v1/channels/nonexistent/ws',
|
|
61
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
62
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
63
|
+
);
|
|
64
|
+
expect(res.status).toBe(404);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('requires subscribe token for private channel', async () => {
|
|
68
|
+
const res = await app.request(
|
|
69
|
+
'/api/v1/channels/priv-ws/ws',
|
|
70
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
71
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
72
|
+
);
|
|
73
|
+
expect(res.status).toBe(401);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('allows subscribe token for private channel via Authorization header', async () => {
|
|
77
|
+
// SELF.fetch() uses the real env from .dev.vars
|
|
78
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
79
|
+
const token = await createToken(
|
|
80
|
+
{ scope: 'subscribe', channel: 'priv-ws', sub: 'sub-1' },
|
|
81
|
+
realSecret,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Set up channel in the real env
|
|
85
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
86
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${adminToken}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
id: 'priv-ws',
|
|
94
|
+
name: 'Private WS',
|
|
95
|
+
is_public: false,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const res = await SELF.fetch(
|
|
100
|
+
'http://localhost/api/v1/channels/priv-ws/ws',
|
|
101
|
+
{
|
|
102
|
+
headers: {
|
|
103
|
+
Upgrade: 'websocket',
|
|
104
|
+
Authorization: `Bearer ${token}`,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
expect(res.status).toBe(101);
|
|
109
|
+
expect(res.webSocket).toBeDefined();
|
|
110
|
+
res.webSocket!.accept();
|
|
111
|
+
res.webSocket!.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('allows subscribe token for private channel via ?token= query param', async () => {
|
|
115
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
116
|
+
const token = await createToken(
|
|
117
|
+
{ scope: 'subscribe', channel: 'priv-ws', sub: 'sub-1' },
|
|
118
|
+
realSecret,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
122
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: `Bearer ${adminToken}`,
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
id: 'priv-ws',
|
|
130
|
+
name: 'Private WS',
|
|
131
|
+
is_public: false,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const res = await SELF.fetch(
|
|
136
|
+
`http://localhost/api/v1/channels/priv-ws/ws?token=${token}`,
|
|
137
|
+
{
|
|
138
|
+
headers: { Upgrade: 'websocket' },
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
expect(res.status).toBe(101);
|
|
142
|
+
expect(res.webSocket).toBeDefined();
|
|
143
|
+
res.webSocket!.accept();
|
|
144
|
+
res.webSocket!.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects invalid ?token= for private channel', async () => {
|
|
148
|
+
const res = await app.request(
|
|
149
|
+
'/api/v1/channels/priv-ws/ws?token=invalid-token',
|
|
150
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
151
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
152
|
+
);
|
|
153
|
+
expect(res.status).toBe(401);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('rejects wrong-channel ?token= for private channel', async () => {
|
|
157
|
+
const token = await createToken(
|
|
158
|
+
{ scope: 'subscribe', channel: 'other-channel', sub: 'sub-1' },
|
|
159
|
+
JWT_SECRET,
|
|
160
|
+
);
|
|
161
|
+
const res = await app.request(
|
|
162
|
+
`/api/v1/channels/priv-ws/ws?token=${token}`,
|
|
163
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
164
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
165
|
+
);
|
|
166
|
+
expect(res.status).toBe(403);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('upgrades to WebSocket for public channel', async () => {
|
|
170
|
+
// SELF.fetch() uses the real env from .dev.vars
|
|
171
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
172
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
173
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${adminToken}`,
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
id: 'ws-channel',
|
|
181
|
+
name: 'WS Channel',
|
|
182
|
+
is_public: true,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const res = await SELF.fetch(
|
|
187
|
+
'http://localhost/api/v1/channels/ws-channel/ws',
|
|
188
|
+
{
|
|
189
|
+
headers: { Upgrade: 'websocket' },
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
expect(res.status).toBe(101);
|
|
193
|
+
expect(res.webSocket).toBeDefined();
|
|
194
|
+
res.webSocket!.accept();
|
|
195
|
+
res.webSocket!.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('broadcasts to multiple connected clients', async () => {
|
|
199
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
200
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
201
|
+
|
|
202
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: `Bearer ${adminToken}`,
|
|
206
|
+
'Content-Type': 'application/json',
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
id: 'ws-channel',
|
|
210
|
+
name: 'WS Channel',
|
|
211
|
+
is_public: true,
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Connect two WebSocket clients
|
|
216
|
+
const wsRes1 = await SELF.fetch(
|
|
217
|
+
'http://localhost/api/v1/channels/ws-channel/ws',
|
|
218
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
219
|
+
);
|
|
220
|
+
const wsRes2 = await SELF.fetch(
|
|
221
|
+
'http://localhost/api/v1/channels/ws-channel/ws',
|
|
222
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
223
|
+
);
|
|
224
|
+
expect(wsRes1.status).toBe(101);
|
|
225
|
+
expect(wsRes2.status).toBe(101);
|
|
226
|
+
|
|
227
|
+
const ws1 = wsRes1.webSocket!;
|
|
228
|
+
const ws2 = wsRes2.webSocket!;
|
|
229
|
+
ws1.accept();
|
|
230
|
+
ws2.accept();
|
|
231
|
+
|
|
232
|
+
const messages1: string[] = [];
|
|
233
|
+
const messages2: string[] = [];
|
|
234
|
+
ws1.addEventListener('message', (e: MessageEvent) => messages1.push(e.data as string));
|
|
235
|
+
ws2.addEventListener('message', (e: MessageEvent) => messages2.push(e.data as string));
|
|
236
|
+
|
|
237
|
+
// Publish an event
|
|
238
|
+
const publishToken = await createToken(
|
|
239
|
+
{ scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
|
|
240
|
+
realSecret,
|
|
241
|
+
);
|
|
242
|
+
const publishRes = await SELF.fetch(
|
|
243
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
244
|
+
{
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: {
|
|
247
|
+
Authorization: `Bearer ${publishToken}`,
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({ type: 'signal', data: { v: 1 } }),
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
expect(publishRes.status).toBe(201);
|
|
254
|
+
|
|
255
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
256
|
+
|
|
257
|
+
expect(messages1.length).toBeGreaterThanOrEqual(1);
|
|
258
|
+
expect(messages2.length).toBeGreaterThanOrEqual(1);
|
|
259
|
+
|
|
260
|
+
const received1 = JSON.parse(messages1[0]);
|
|
261
|
+
const received2 = JSON.parse(messages2[0]);
|
|
262
|
+
expect(received1.channel_id).toBe('ws-channel');
|
|
263
|
+
expect(received2.channel_id).toBe('ws-channel');
|
|
264
|
+
|
|
265
|
+
ws1.close();
|
|
266
|
+
ws2.close();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('filters events by type when ?types= is specified', async () => {
|
|
270
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
271
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
272
|
+
|
|
273
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: {
|
|
276
|
+
Authorization: `Bearer ${adminToken}`,
|
|
277
|
+
'Content-Type': 'application/json',
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
id: 'ws-channel',
|
|
281
|
+
name: 'WS Channel',
|
|
282
|
+
is_public: true,
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Client 1: only wants "alert" events
|
|
287
|
+
const wsRes1 = await SELF.fetch(
|
|
288
|
+
'http://localhost/api/v1/channels/ws-channel/ws?types=alert',
|
|
289
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
290
|
+
);
|
|
291
|
+
// Client 2: wants all events (no filter)
|
|
292
|
+
const wsRes2 = await SELF.fetch(
|
|
293
|
+
'http://localhost/api/v1/channels/ws-channel/ws',
|
|
294
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
295
|
+
);
|
|
296
|
+
expect(wsRes1.status).toBe(101);
|
|
297
|
+
expect(wsRes2.status).toBe(101);
|
|
298
|
+
|
|
299
|
+
const ws1 = wsRes1.webSocket!;
|
|
300
|
+
const ws2 = wsRes2.webSocket!;
|
|
301
|
+
ws1.accept();
|
|
302
|
+
ws2.accept();
|
|
303
|
+
|
|
304
|
+
const messages1: string[] = [];
|
|
305
|
+
const messages2: string[] = [];
|
|
306
|
+
ws1.addEventListener('message', (e: MessageEvent) => messages1.push(e.data as string));
|
|
307
|
+
ws2.addEventListener('message', (e: MessageEvent) => messages2.push(e.data as string));
|
|
308
|
+
|
|
309
|
+
const publishToken = await createToken(
|
|
310
|
+
{ scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
|
|
311
|
+
realSecret,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Publish a "signal" event — should only reach client 2
|
|
315
|
+
await SELF.fetch(
|
|
316
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
317
|
+
{
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: {
|
|
320
|
+
Authorization: `Bearer ${publishToken}`,
|
|
321
|
+
'Content-Type': 'application/json',
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({ type: 'signal', data: { v: 1 } }),
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
328
|
+
|
|
329
|
+
expect(messages1.length).toBe(0);
|
|
330
|
+
expect(messages2.length).toBe(1);
|
|
331
|
+
|
|
332
|
+
// Publish an "alert" event — should reach both clients
|
|
333
|
+
await SELF.fetch(
|
|
334
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
335
|
+
{
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: {
|
|
338
|
+
Authorization: `Bearer ${publishToken}`,
|
|
339
|
+
'Content-Type': 'application/json',
|
|
340
|
+
},
|
|
341
|
+
body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
346
|
+
|
|
347
|
+
expect(messages1.length).toBe(1);
|
|
348
|
+
expect(messages2.length).toBe(2);
|
|
349
|
+
|
|
350
|
+
const filtered = JSON.parse(messages1[0]);
|
|
351
|
+
expect(filtered.type).toBe('alert');
|
|
352
|
+
|
|
353
|
+
ws1.close();
|
|
354
|
+
ws2.close();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('supports multiple types in ?types= filter', async () => {
|
|
358
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
359
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
360
|
+
|
|
361
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
headers: {
|
|
364
|
+
Authorization: `Bearer ${adminToken}`,
|
|
365
|
+
'Content-Type': 'application/json',
|
|
366
|
+
},
|
|
367
|
+
body: JSON.stringify({
|
|
368
|
+
id: 'ws-channel',
|
|
369
|
+
name: 'WS Channel',
|
|
370
|
+
is_public: true,
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Client subscribes to both "alert" and "signal" but not "info"
|
|
375
|
+
const wsRes = await SELF.fetch(
|
|
376
|
+
'http://localhost/api/v1/channels/ws-channel/ws?types=alert,signal',
|
|
377
|
+
{ headers: { Upgrade: 'websocket' } },
|
|
378
|
+
);
|
|
379
|
+
expect(wsRes.status).toBe(101);
|
|
380
|
+
const ws = wsRes.webSocket!;
|
|
381
|
+
ws.accept();
|
|
382
|
+
|
|
383
|
+
const messages: string[] = [];
|
|
384
|
+
ws.addEventListener('message', (e: MessageEvent) => messages.push(e.data as string));
|
|
385
|
+
|
|
386
|
+
const publishToken = await createToken(
|
|
387
|
+
{ scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
|
|
388
|
+
realSecret,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// Publish "info" — should not be received
|
|
392
|
+
await SELF.fetch(
|
|
393
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
394
|
+
{
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: {
|
|
397
|
+
Authorization: `Bearer ${publishToken}`,
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({ type: 'info', data: { v: 1 } }),
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Publish "alert" — should be received
|
|
405
|
+
await SELF.fetch(
|
|
406
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
407
|
+
{
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: {
|
|
410
|
+
Authorization: `Bearer ${publishToken}`,
|
|
411
|
+
'Content-Type': 'application/json',
|
|
412
|
+
},
|
|
413
|
+
body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Publish "signal" — should be received
|
|
418
|
+
await SELF.fetch(
|
|
419
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
420
|
+
{
|
|
421
|
+
method: 'POST',
|
|
422
|
+
headers: {
|
|
423
|
+
Authorization: `Bearer ${publishToken}`,
|
|
424
|
+
'Content-Type': 'application/json',
|
|
425
|
+
},
|
|
426
|
+
body: JSON.stringify({ type: 'signal', data: { v: 3 } }),
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
431
|
+
|
|
432
|
+
expect(messages.length).toBe(2);
|
|
433
|
+
const types = messages.map((m) => JSON.parse(m).type);
|
|
434
|
+
expect(types).toContain('alert');
|
|
435
|
+
expect(types).toContain('signal');
|
|
436
|
+
expect(types).not.toContain('info');
|
|
437
|
+
|
|
438
|
+
ws.close();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('receives broadcast after publish', async () => {
|
|
442
|
+
// SELF.fetch() uses the real env from .dev.vars
|
|
443
|
+
const realSecret = env.ZOOID_JWT_SECRET;
|
|
444
|
+
const adminToken = await createToken({ scope: 'admin' }, realSecret);
|
|
445
|
+
|
|
446
|
+
// Create channel via SELF
|
|
447
|
+
await SELF.fetch('http://localhost/api/v1/channels', {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
headers: {
|
|
450
|
+
Authorization: `Bearer ${adminToken}`,
|
|
451
|
+
'Content-Type': 'application/json',
|
|
452
|
+
},
|
|
453
|
+
body: JSON.stringify({
|
|
454
|
+
id: 'ws-channel',
|
|
455
|
+
name: 'WS Channel',
|
|
456
|
+
is_public: true,
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Connect WebSocket via SELF
|
|
461
|
+
const wsRes = await SELF.fetch(
|
|
462
|
+
'http://localhost/api/v1/channels/ws-channel/ws',
|
|
463
|
+
{
|
|
464
|
+
headers: { Upgrade: 'websocket' },
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
expect(wsRes.status).toBe(101);
|
|
468
|
+
const ws = wsRes.webSocket!;
|
|
469
|
+
ws.accept();
|
|
470
|
+
|
|
471
|
+
const messages: string[] = [];
|
|
472
|
+
ws.addEventListener('message', (e: MessageEvent) => messages.push(e.data as string));
|
|
473
|
+
|
|
474
|
+
// Publish an event via SELF
|
|
475
|
+
const publishToken = await createToken(
|
|
476
|
+
{ scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
|
|
477
|
+
realSecret,
|
|
478
|
+
);
|
|
479
|
+
const publishRes = await SELF.fetch(
|
|
480
|
+
'http://localhost/api/v1/channels/ws-channel/events',
|
|
481
|
+
{
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: {
|
|
484
|
+
Authorization: `Bearer ${publishToken}`,
|
|
485
|
+
'Content-Type': 'application/json',
|
|
486
|
+
},
|
|
487
|
+
body: JSON.stringify({ type: 'signal', data: { v: 42 } }),
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
expect(publishRes.status).toBe(201);
|
|
491
|
+
|
|
492
|
+
// Give waitUntil a moment to complete
|
|
493
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
494
|
+
|
|
495
|
+
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
496
|
+
const received = JSON.parse(messages[0]);
|
|
497
|
+
expect(received.channel_id).toBe('ws-channel');
|
|
498
|
+
expect(received.type).toBe('signal');
|
|
499
|
+
|
|
500
|
+
ws.close();
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
package/src/routes/ws.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Bindings, Variables } from '../types';
|
|
3
|
+
import { requireSubscribeIfPrivate } from '../middleware/auth';
|
|
4
|
+
|
|
5
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
6
|
+
|
|
7
|
+
export const ws = new Hono<Env>();
|
|
8
|
+
|
|
9
|
+
ws.get(
|
|
10
|
+
'/channels/:channelId/ws',
|
|
11
|
+
requireSubscribeIfPrivate('channelId'),
|
|
12
|
+
async (c) => {
|
|
13
|
+
const upgradeHeader = c.req.header('Upgrade');
|
|
14
|
+
if (upgradeHeader !== 'websocket') {
|
|
15
|
+
return c.json({ error: 'Expected WebSocket upgrade' }, 426);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const channelId = c.req.param('channelId');
|
|
19
|
+
const id = c.env.CHANNEL_DO.idFromName(channelId);
|
|
20
|
+
const stub = c.env.CHANNEL_DO.get(id);
|
|
21
|
+
|
|
22
|
+
// Forward the request, preserving ?types= query param for type filtering
|
|
23
|
+
return stub.fetch(c.req.raw);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { env } from 'cloudflare:test';
|
|
2
|
+
|
|
3
|
+
const SCHEMA_STATEMENTS = [
|
|
4
|
+
`CREATE TABLE IF NOT EXISTS channels (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
name TEXT NOT NULL,
|
|
7
|
+
description TEXT,
|
|
8
|
+
tags TEXT,
|
|
9
|
+
is_public INTEGER NOT NULL DEFAULT 1,
|
|
10
|
+
schema TEXT,
|
|
11
|
+
strict INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
max_subscribers INTEGER DEFAULT 100,
|
|
13
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
14
|
+
)`,
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS events (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
channel_id TEXT NOT NULL,
|
|
18
|
+
publisher_id TEXT,
|
|
19
|
+
type TEXT,
|
|
20
|
+
data TEXT NOT NULL,
|
|
21
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
22
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
23
|
+
)`,
|
|
24
|
+
`CREATE TABLE IF NOT EXISTS webhooks (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
channel_id TEXT NOT NULL,
|
|
27
|
+
url TEXT NOT NULL,
|
|
28
|
+
event_types TEXT,
|
|
29
|
+
expires_at TEXT NOT NULL,
|
|
30
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
31
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
32
|
+
)`,
|
|
33
|
+
`CREATE TABLE IF NOT EXISTS publishers (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
channel_id TEXT NOT NULL,
|
|
36
|
+
name TEXT NOT NULL,
|
|
37
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
38
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
39
|
+
)`,
|
|
40
|
+
`CREATE INDEX IF NOT EXISTS idx_events_channel_created ON events(channel_id, created_at DESC)`,
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_events_channel_type ON events(channel_id, type)`,
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id)`,
|
|
43
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_webhooks_channel_url ON webhooks(channel_id, url)`,
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_publishers_channel ON publishers(channel_id)`,
|
|
45
|
+
`CREATE TABLE IF NOT EXISTS server_meta (
|
|
46
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
47
|
+
name TEXT NOT NULL DEFAULT 'Zooid',
|
|
48
|
+
description TEXT,
|
|
49
|
+
tags TEXT,
|
|
50
|
+
owner TEXT,
|
|
51
|
+
company TEXT,
|
|
52
|
+
email TEXT,
|
|
53
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
54
|
+
)`,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initialize the D1 database with the Zooid schema.
|
|
59
|
+
* Call this in beforeAll for integration tests.
|
|
60
|
+
*/
|
|
61
|
+
export async function setupTestDb() {
|
|
62
|
+
const db = env.DB;
|
|
63
|
+
for (const sql of SCHEMA_STATEMENTS) {
|
|
64
|
+
await db.prepare(sql).run();
|
|
65
|
+
}
|
|
66
|
+
return db;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clean all tables between tests.
|
|
71
|
+
*/
|
|
72
|
+
export async function cleanTestDb() {
|
|
73
|
+
const db = env.DB;
|
|
74
|
+
await db.prepare('DELETE FROM publishers').run();
|
|
75
|
+
await db.prepare('DELETE FROM webhooks').run();
|
|
76
|
+
await db.prepare('DELETE FROM events').run();
|
|
77
|
+
await db.prepare('DELETE FROM channels').run();
|
|
78
|
+
await db.prepare('DELETE FROM server_meta').run();
|
|
79
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Re-export shared API types
|
|
2
|
+
export type {
|
|
3
|
+
ZooidEvent,
|
|
4
|
+
PollResult,
|
|
5
|
+
ChannelListItem,
|
|
6
|
+
Webhook,
|
|
7
|
+
ServerDiscovery,
|
|
8
|
+
ServerIdentity,
|
|
9
|
+
} from '@zooid/types';
|
|
10
|
+
|
|
11
|
+
// Deprecated aliases for backward compatibility
|
|
12
|
+
export type { ServerMetadata, ServerMeta } from '@zooid/types';
|
|
13
|
+
|
|
14
|
+
// Server-internal types
|
|
15
|
+
|
|
16
|
+
import type { ChannelDO } from './do/channel';
|
|
17
|
+
|
|
18
|
+
export interface Bindings {
|
|
19
|
+
DB: D1Database;
|
|
20
|
+
ASSETS: Fetcher;
|
|
21
|
+
CHANNEL_DO: DurableObjectNamespace<ChannelDO>;
|
|
22
|
+
ZOOID_JWT_SECRET: string;
|
|
23
|
+
ZOOID_SIGNING_KEY?: string;
|
|
24
|
+
ZOOID_PUBLIC_KEY?: string;
|
|
25
|
+
ZOOID_SERVER_ID?: string;
|
|
26
|
+
ZOOID_SERVER_NAME?: string;
|
|
27
|
+
ZOOID_SERVER_DESC?: string;
|
|
28
|
+
ZOOID_TOKEN_EXPIRY?: string;
|
|
29
|
+
ZOOID_POLL_INTERVAL?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ZooidJWT {
|
|
33
|
+
scope: 'admin' | 'publish' | 'subscribe';
|
|
34
|
+
channel?: string;
|
|
35
|
+
sub?: string; // Publisher ID (standard JWT subject claim)
|
|
36
|
+
iat: number;
|
|
37
|
+
exp?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Raw DB row — is_public and strict are stored as INTEGER (0/1) */
|
|
41
|
+
export interface Channel {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
description: string | null;
|
|
45
|
+
tags: string | null;
|
|
46
|
+
is_public: number;
|
|
47
|
+
schema: string | null;
|
|
48
|
+
strict: number;
|
|
49
|
+
max_subscribers: number;
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Publisher {
|
|
54
|
+
id: string;
|
|
55
|
+
channel_id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
created_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Variables {
|
|
61
|
+
jwtPayload: ZooidJWT;
|
|
62
|
+
channelIsPublic?: boolean;
|
|
63
|
+
}
|
package/wrangler.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name = "zooid-zooid"
|
|
2
|
+
main = "src/index.ts"
|
|
3
|
+
compatibility_date = "2025-01-01"
|
|
4
|
+
|
|
5
|
+
[assets]
|
|
6
|
+
directory = "../web/dist/"
|
|
7
|
+
not_found_handling = "single-page-application"
|
|
8
|
+
binding = "ASSETS"
|
|
9
|
+
run_worker_first = ["/api/*", "/.well-known/*"]
|
|
10
|
+
|
|
11
|
+
[[d1_databases]]
|
|
12
|
+
binding = "DB"
|
|
13
|
+
database_name = "zooid-db-zooid"
|
|
14
|
+
database_id = "1dc5f0f2-3cc6-45ca-bd5e-1ad9d315a8dc"
|
|
15
|
+
|
|
16
|
+
[[durable_objects.bindings]]
|
|
17
|
+
name = "CHANNEL_DO"
|
|
18
|
+
class_name = "ChannelDO"
|
|
19
|
+
|
|
20
|
+
[[migrations]]
|
|
21
|
+
tag = "v1"
|
|
22
|
+
new_sqlite_classes = ["ChannelDO"]
|
|
23
|
+
|
|
24
|
+
[vars]
|
|
25
|
+
ZOOID_SERVER_ID = "zooid"
|
|
26
|
+
ZOOID_POLL_INTERVAL = "30"
|