@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,450 @@
|
|
|
1
|
+
|
|
2
|
+
import type {
|
|
3
|
+
Channel,
|
|
4
|
+
ChannelListItem,
|
|
5
|
+
Publisher,
|
|
6
|
+
ZooidEvent,
|
|
7
|
+
Webhook,
|
|
8
|
+
PollResult,
|
|
9
|
+
ServerIdentity,
|
|
10
|
+
} from '../types';
|
|
11
|
+
import { generateUlid } from '../lib/ulid';
|
|
12
|
+
|
|
13
|
+
export async function createChannel(
|
|
14
|
+
db: D1Database,
|
|
15
|
+
channel: {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
is_public?: boolean;
|
|
21
|
+
schema?: Record<string, unknown>;
|
|
22
|
+
strict?: boolean;
|
|
23
|
+
},
|
|
24
|
+
): Promise<Channel> {
|
|
25
|
+
const isPublic = channel.is_public === false ? 0 : 1;
|
|
26
|
+
const strict = channel.strict ? 1 : 0;
|
|
27
|
+
const schema = channel.schema ? JSON.stringify(channel.schema) : null;
|
|
28
|
+
const tags = channel.tags ? JSON.stringify(channel.tags) : null;
|
|
29
|
+
|
|
30
|
+
await db
|
|
31
|
+
.prepare(
|
|
32
|
+
`INSERT INTO channels (id, name, description, tags, is_public, schema, strict) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
33
|
+
)
|
|
34
|
+
.bind(channel.id, channel.name, channel.description ?? null, tags, isPublic, schema, strict)
|
|
35
|
+
.run();
|
|
36
|
+
|
|
37
|
+
const row = await db
|
|
38
|
+
.prepare(`SELECT * FROM channels WHERE id = ?`)
|
|
39
|
+
.bind(channel.id)
|
|
40
|
+
.first<Channel>();
|
|
41
|
+
|
|
42
|
+
return row!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getChannel(
|
|
46
|
+
db: D1Database,
|
|
47
|
+
id: string,
|
|
48
|
+
): Promise<Channel | null> {
|
|
49
|
+
return db.prepare(`SELECT * FROM channels WHERE id = ?`).bind(id).first<Channel>();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function listChannels(
|
|
53
|
+
db: D1Database,
|
|
54
|
+
): Promise<ChannelListItem[]> {
|
|
55
|
+
const rows = await db
|
|
56
|
+
.prepare(
|
|
57
|
+
`SELECT
|
|
58
|
+
c.id,
|
|
59
|
+
c.name,
|
|
60
|
+
c.description,
|
|
61
|
+
c.tags,
|
|
62
|
+
c.is_public,
|
|
63
|
+
c.schema,
|
|
64
|
+
c.strict,
|
|
65
|
+
COALESCE(e.event_count, 0) as event_count,
|
|
66
|
+
e.last_event_at
|
|
67
|
+
FROM channels c
|
|
68
|
+
LEFT JOIN (
|
|
69
|
+
SELECT
|
|
70
|
+
channel_id,
|
|
71
|
+
COUNT(*) as event_count,
|
|
72
|
+
MAX(created_at) as last_event_at
|
|
73
|
+
FROM events
|
|
74
|
+
GROUP BY channel_id
|
|
75
|
+
) e ON c.id = e.channel_id
|
|
76
|
+
ORDER BY c.created_at DESC`,
|
|
77
|
+
)
|
|
78
|
+
.all<{
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
description: string | null;
|
|
82
|
+
tags: string | null;
|
|
83
|
+
is_public: number;
|
|
84
|
+
schema: string | null;
|
|
85
|
+
strict: number;
|
|
86
|
+
event_count: number;
|
|
87
|
+
last_event_at: string | null;
|
|
88
|
+
}>();
|
|
89
|
+
|
|
90
|
+
const channels: ChannelListItem[] = [];
|
|
91
|
+
|
|
92
|
+
for (const row of rows.results) {
|
|
93
|
+
const publishers = await db
|
|
94
|
+
.prepare(`SELECT name FROM publishers WHERE channel_id = ?`)
|
|
95
|
+
.bind(row.id)
|
|
96
|
+
.all<{ name: string }>();
|
|
97
|
+
|
|
98
|
+
channels.push({
|
|
99
|
+
id: row.id,
|
|
100
|
+
name: row.name,
|
|
101
|
+
description: row.description,
|
|
102
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
103
|
+
is_public: row.is_public === 1,
|
|
104
|
+
schema: row.schema ? JSON.parse(row.schema) : null,
|
|
105
|
+
strict: row.strict === 1,
|
|
106
|
+
event_count: row.event_count,
|
|
107
|
+
last_event_at: row.last_event_at,
|
|
108
|
+
publishers: publishers.results.map((p) => p.name),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return channels;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function createPublisher(
|
|
116
|
+
db: D1Database,
|
|
117
|
+
channelId: string,
|
|
118
|
+
name: string,
|
|
119
|
+
): Promise<Publisher> {
|
|
120
|
+
const id = generateUlid();
|
|
121
|
+
|
|
122
|
+
await db
|
|
123
|
+
.prepare(
|
|
124
|
+
`INSERT INTO publishers (id, channel_id, name) VALUES (?, ?, ?)`,
|
|
125
|
+
)
|
|
126
|
+
.bind(id, channelId, name)
|
|
127
|
+
.run();
|
|
128
|
+
|
|
129
|
+
const row = await db
|
|
130
|
+
.prepare(`SELECT * FROM publishers WHERE id = ?`)
|
|
131
|
+
.bind(id)
|
|
132
|
+
.first<Publisher>();
|
|
133
|
+
|
|
134
|
+
return row!;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Event queries ---
|
|
138
|
+
|
|
139
|
+
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
140
|
+
const MAX_BATCH_SIZE = 100;
|
|
141
|
+
const DEFAULT_POLL_LIMIT = 50;
|
|
142
|
+
const DEFAULT_WEBHOOK_TTL_SECONDS = 3 * 24 * 60 * 60; // 3 days
|
|
143
|
+
const MAX_WEBHOOK_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
144
|
+
|
|
145
|
+
export async function createEvent(
|
|
146
|
+
db: D1Database,
|
|
147
|
+
event: {
|
|
148
|
+
channelId: string;
|
|
149
|
+
publisherId?: string | null;
|
|
150
|
+
type?: string | null;
|
|
151
|
+
data: unknown;
|
|
152
|
+
},
|
|
153
|
+
): Promise<ZooidEvent> {
|
|
154
|
+
const dataStr = JSON.stringify(event.data);
|
|
155
|
+
if (new TextEncoder().encode(dataStr).length > MAX_PAYLOAD_BYTES) {
|
|
156
|
+
throw new Error('Event payload exceeds 64KB limit');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const id = generateUlid();
|
|
160
|
+
|
|
161
|
+
await db
|
|
162
|
+
.prepare(
|
|
163
|
+
`INSERT INTO events (id, channel_id, publisher_id, type, data) VALUES (?, ?, ?, ?, ?)`,
|
|
164
|
+
)
|
|
165
|
+
.bind(
|
|
166
|
+
id,
|
|
167
|
+
event.channelId,
|
|
168
|
+
event.publisherId ?? null,
|
|
169
|
+
event.type ?? null,
|
|
170
|
+
dataStr,
|
|
171
|
+
)
|
|
172
|
+
.run();
|
|
173
|
+
|
|
174
|
+
const row = await db
|
|
175
|
+
.prepare(`SELECT * FROM events WHERE id = ?`)
|
|
176
|
+
.bind(id)
|
|
177
|
+
.first<ZooidEvent>();
|
|
178
|
+
|
|
179
|
+
return row!;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function createEvents(
|
|
183
|
+
db: D1Database,
|
|
184
|
+
channelId: string,
|
|
185
|
+
publisherId: string | null,
|
|
186
|
+
events: Array<{ type?: string | null; data: unknown }>,
|
|
187
|
+
): Promise<ZooidEvent[]> {
|
|
188
|
+
if (events.length > MAX_BATCH_SIZE) {
|
|
189
|
+
throw new Error(`Batch size exceeds maximum of ${MAX_BATCH_SIZE}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ids: string[] = [];
|
|
193
|
+
|
|
194
|
+
for (const event of events) {
|
|
195
|
+
const dataStr = JSON.stringify(event.data);
|
|
196
|
+
if (new TextEncoder().encode(dataStr).length > MAX_PAYLOAD_BYTES) {
|
|
197
|
+
throw new Error('Event payload exceeds 64KB limit');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const id = generateUlid();
|
|
201
|
+
ids.push(id);
|
|
202
|
+
|
|
203
|
+
await db
|
|
204
|
+
.prepare(
|
|
205
|
+
`INSERT INTO events (id, channel_id, publisher_id, type, data) VALUES (?, ?, ?, ?, ?)`,
|
|
206
|
+
)
|
|
207
|
+
.bind(id, channelId, publisherId, event.type ?? null, dataStr)
|
|
208
|
+
.run();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const results: ZooidEvent[] = [];
|
|
212
|
+
for (const id of ids) {
|
|
213
|
+
const row = await db
|
|
214
|
+
.prepare(`SELECT * FROM events WHERE id = ?`)
|
|
215
|
+
.bind(id)
|
|
216
|
+
.first<ZooidEvent>();
|
|
217
|
+
results.push(row!);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function pollEvents(
|
|
224
|
+
db: D1Database,
|
|
225
|
+
channelId: string,
|
|
226
|
+
options: {
|
|
227
|
+
since?: string;
|
|
228
|
+
cursor?: string;
|
|
229
|
+
limit?: number;
|
|
230
|
+
type?: string;
|
|
231
|
+
},
|
|
232
|
+
): Promise<PollResult> {
|
|
233
|
+
const limit = options.limit ?? DEFAULT_POLL_LIMIT;
|
|
234
|
+
const conditions: string[] = ['channel_id = ?'];
|
|
235
|
+
const bindings: unknown[] = [channelId];
|
|
236
|
+
|
|
237
|
+
if (options.since) {
|
|
238
|
+
conditions.push('created_at > ?');
|
|
239
|
+
bindings.push(options.since);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (options.cursor) {
|
|
243
|
+
conditions.push('id > ?');
|
|
244
|
+
bindings.push(options.cursor);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (options.type) {
|
|
248
|
+
conditions.push('type = ?');
|
|
249
|
+
bindings.push(options.type);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC LIMIT ?`;
|
|
253
|
+
bindings.push(limit + 1);
|
|
254
|
+
|
|
255
|
+
const stmt = db.prepare(sql);
|
|
256
|
+
const result = await stmt.bind(...bindings).all<ZooidEvent>();
|
|
257
|
+
const rows = result.results;
|
|
258
|
+
|
|
259
|
+
const hasMore = rows.length > limit;
|
|
260
|
+
const events = hasMore ? rows.slice(0, limit) : rows;
|
|
261
|
+
const cursor = events.length > 0 ? events[events.length - 1].id : null;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
events,
|
|
265
|
+
cursor: hasMore ? cursor : null,
|
|
266
|
+
has_more: hasMore,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function cleanupExpiredEvents(
|
|
271
|
+
db: D1Database,
|
|
272
|
+
channelId: string,
|
|
273
|
+
): Promise<number> {
|
|
274
|
+
const result = await db
|
|
275
|
+
.prepare(
|
|
276
|
+
`DELETE FROM events WHERE channel_id = ? AND created_at < datetime('now', '-7 days')`,
|
|
277
|
+
)
|
|
278
|
+
.bind(channelId)
|
|
279
|
+
.run();
|
|
280
|
+
|
|
281
|
+
return result.meta.changes ?? 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Webhook queries ---
|
|
285
|
+
|
|
286
|
+
export async function createWebhook(
|
|
287
|
+
db: D1Database,
|
|
288
|
+
webhook: {
|
|
289
|
+
channelId: string;
|
|
290
|
+
url: string;
|
|
291
|
+
eventTypes?: string[];
|
|
292
|
+
ttlSeconds?: number;
|
|
293
|
+
},
|
|
294
|
+
): Promise<Webhook> {
|
|
295
|
+
const ttl = Math.min(
|
|
296
|
+
webhook.ttlSeconds ?? DEFAULT_WEBHOOK_TTL_SECONDS,
|
|
297
|
+
MAX_WEBHOOK_TTL_SECONDS,
|
|
298
|
+
);
|
|
299
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
300
|
+
const eventTypes = webhook.eventTypes
|
|
301
|
+
? JSON.stringify(webhook.eventTypes)
|
|
302
|
+
: null;
|
|
303
|
+
const id = generateUlid();
|
|
304
|
+
|
|
305
|
+
await db
|
|
306
|
+
.prepare(
|
|
307
|
+
`INSERT INTO webhooks (id, channel_id, url, event_types, expires_at)
|
|
308
|
+
VALUES (?, ?, ?, ?, ?)
|
|
309
|
+
ON CONFLICT(channel_id, url) DO UPDATE SET
|
|
310
|
+
expires_at = excluded.expires_at,
|
|
311
|
+
event_types = excluded.event_types`,
|
|
312
|
+
)
|
|
313
|
+
.bind(id, webhook.channelId, webhook.url, eventTypes, expiresAt)
|
|
314
|
+
.run();
|
|
315
|
+
|
|
316
|
+
// Fetch back — could be the new row or the updated existing one
|
|
317
|
+
const row = await db
|
|
318
|
+
.prepare(
|
|
319
|
+
`SELECT * FROM webhooks WHERE channel_id = ? AND url = ?`,
|
|
320
|
+
)
|
|
321
|
+
.bind(webhook.channelId, webhook.url)
|
|
322
|
+
.first<Webhook>();
|
|
323
|
+
|
|
324
|
+
return row!;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function deleteWebhook(
|
|
328
|
+
db: D1Database,
|
|
329
|
+
webhookId: string,
|
|
330
|
+
channelId: string,
|
|
331
|
+
): Promise<boolean> {
|
|
332
|
+
const result = await db
|
|
333
|
+
.prepare(`DELETE FROM webhooks WHERE id = ? AND channel_id = ?`)
|
|
334
|
+
.bind(webhookId, channelId)
|
|
335
|
+
.run();
|
|
336
|
+
|
|
337
|
+
return (result.meta.changes ?? 0) > 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function getWebhooksForChannel(
|
|
341
|
+
db: D1Database,
|
|
342
|
+
channelId: string,
|
|
343
|
+
eventType?: string,
|
|
344
|
+
): Promise<Webhook[]> {
|
|
345
|
+
if (eventType) {
|
|
346
|
+
// Return webhooks that match the event type OR have no filter (null = all events)
|
|
347
|
+
const result = await db
|
|
348
|
+
.prepare(
|
|
349
|
+
`SELECT * FROM webhooks
|
|
350
|
+
WHERE channel_id = ? AND expires_at > datetime('now')
|
|
351
|
+
AND (event_types IS NULL OR event_types LIKE ?)`,
|
|
352
|
+
)
|
|
353
|
+
.bind(channelId, `%"${eventType}"%`)
|
|
354
|
+
.all<Webhook>();
|
|
355
|
+
return result.results;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const result = await db
|
|
359
|
+
.prepare(
|
|
360
|
+
`SELECT * FROM webhooks WHERE channel_id = ? AND expires_at > datetime('now')`,
|
|
361
|
+
)
|
|
362
|
+
.bind(channelId)
|
|
363
|
+
.all<Webhook>();
|
|
364
|
+
return result.results;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- Server meta queries ---
|
|
368
|
+
|
|
369
|
+
interface ServerMetaRow {
|
|
370
|
+
id: number;
|
|
371
|
+
name: string;
|
|
372
|
+
description: string | null;
|
|
373
|
+
tags: string | null;
|
|
374
|
+
owner: string | null;
|
|
375
|
+
company: string | null;
|
|
376
|
+
email: string | null;
|
|
377
|
+
updated_at: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function rowToServerMeta(row: ServerMetaRow): ServerIdentity {
|
|
381
|
+
return {
|
|
382
|
+
name: row.name,
|
|
383
|
+
description: row.description,
|
|
384
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
385
|
+
owner: row.owner,
|
|
386
|
+
company: row.company,
|
|
387
|
+
email: row.email,
|
|
388
|
+
updated_at: row.updated_at,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function getServerMeta(
|
|
393
|
+
db: D1Database,
|
|
394
|
+
): Promise<ServerIdentity | null> {
|
|
395
|
+
const row = await db
|
|
396
|
+
.prepare(`SELECT * FROM server_meta WHERE id = 1`)
|
|
397
|
+
.first<ServerMetaRow>();
|
|
398
|
+
|
|
399
|
+
return row ? rowToServerMeta(row) : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function upsertServerMeta(
|
|
403
|
+
db: D1Database,
|
|
404
|
+
meta: {
|
|
405
|
+
name?: string;
|
|
406
|
+
description?: string | null;
|
|
407
|
+
tags?: string[];
|
|
408
|
+
owner?: string | null;
|
|
409
|
+
company?: string | null;
|
|
410
|
+
email?: string | null;
|
|
411
|
+
},
|
|
412
|
+
): Promise<ServerIdentity> {
|
|
413
|
+
const tags = meta.tags ? JSON.stringify(meta.tags) : null;
|
|
414
|
+
|
|
415
|
+
await db
|
|
416
|
+
.prepare(
|
|
417
|
+
`INSERT INTO server_meta (id, name, description, tags, owner, company, email, updated_at)
|
|
418
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
419
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
420
|
+
name = COALESCE(?, server_meta.name),
|
|
421
|
+
description = ?,
|
|
422
|
+
tags = ?,
|
|
423
|
+
owner = ?,
|
|
424
|
+
company = ?,
|
|
425
|
+
email = ?,
|
|
426
|
+
updated_at = datetime('now')`,
|
|
427
|
+
)
|
|
428
|
+
.bind(
|
|
429
|
+
meta.name ?? 'Zooid',
|
|
430
|
+
meta.description ?? null,
|
|
431
|
+
tags,
|
|
432
|
+
meta.owner ?? null,
|
|
433
|
+
meta.company ?? null,
|
|
434
|
+
meta.email ?? null,
|
|
435
|
+
// ON CONFLICT values
|
|
436
|
+
meta.name ?? null,
|
|
437
|
+
meta.description ?? null,
|
|
438
|
+
tags,
|
|
439
|
+
meta.owner ?? null,
|
|
440
|
+
meta.company ?? null,
|
|
441
|
+
meta.email ?? null,
|
|
442
|
+
)
|
|
443
|
+
.run();
|
|
444
|
+
|
|
445
|
+
const row = await db
|
|
446
|
+
.prepare(`SELECT * FROM server_meta WHERE id = 1`)
|
|
447
|
+
.first<ServerMetaRow>();
|
|
448
|
+
|
|
449
|
+
return rowToServerMeta(row!);
|
|
450
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
description TEXT,
|
|
5
|
+
tags TEXT,
|
|
6
|
+
is_public INTEGER NOT NULL DEFAULT 1,
|
|
7
|
+
schema TEXT,
|
|
8
|
+
strict INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
max_subscribers INTEGER DEFAULT 100,
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
channel_id TEXT NOT NULL,
|
|
16
|
+
publisher_id TEXT,
|
|
17
|
+
type TEXT,
|
|
18
|
+
data TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
channel_id TEXT NOT NULL,
|
|
26
|
+
url TEXT NOT NULL,
|
|
27
|
+
event_types TEXT,
|
|
28
|
+
expires_at TEXT NOT NULL,
|
|
29
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
30
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
|
31
|
+
);
|
|
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
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS server_meta (
|
|
42
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
43
|
+
name TEXT NOT NULL DEFAULT 'Zooid',
|
|
44
|
+
description TEXT,
|
|
45
|
+
tags TEXT,
|
|
46
|
+
owner TEXT,
|
|
47
|
+
company TEXT,
|
|
48
|
+
email TEXT,
|
|
49
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_events_channel_created ON events(channel_id, created_at DESC);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_events_channel_type ON events(channel_id, type);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
|
|
55
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhooks_channel_url ON webhooks(channel_id, url);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_publishers_channel ON publishers(channel_id);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import type { Bindings } from '../types';
|
|
3
|
+
|
|
4
|
+
const TAG_ALL = 'type:*';
|
|
5
|
+
const TAG_PREFIX = 'type:';
|
|
6
|
+
|
|
7
|
+
export class ChannelDO extends DurableObject<Bindings> {
|
|
8
|
+
async fetch(request: Request): Promise<Response> {
|
|
9
|
+
const upgradeHeader = request.headers.get('Upgrade');
|
|
10
|
+
if (upgradeHeader !== 'websocket') {
|
|
11
|
+
return new Response('Expected WebSocket upgrade', { status: 426 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const url = new URL(request.url);
|
|
15
|
+
const typesParam = url.searchParams.get('types');
|
|
16
|
+
|
|
17
|
+
const tags: string[] = [];
|
|
18
|
+
if (typesParam) {
|
|
19
|
+
for (const t of typesParam.split(',')) {
|
|
20
|
+
const trimmed = t.trim();
|
|
21
|
+
if (trimmed) tags.push(TAG_PREFIX + trimmed);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// No types specified → receive all events
|
|
25
|
+
if (tags.length === 0) tags.push(TAG_ALL);
|
|
26
|
+
|
|
27
|
+
const pair = new WebSocketPair();
|
|
28
|
+
this.ctx.acceptWebSocket(pair[1], tags);
|
|
29
|
+
|
|
30
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async broadcast(event: Record<string, unknown>): Promise<void> {
|
|
34
|
+
const message = JSON.stringify(event);
|
|
35
|
+
const eventType = typeof event.type === 'string' ? event.type : null;
|
|
36
|
+
|
|
37
|
+
// Collect sockets that should receive this event
|
|
38
|
+
const targets = new Set<WebSocket>();
|
|
39
|
+
|
|
40
|
+
// Unfiltered sockets always receive
|
|
41
|
+
for (const ws of this.ctx.getWebSockets(TAG_ALL)) {
|
|
42
|
+
targets.add(ws);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// If the event has a type, also include sockets subscribed to that type
|
|
46
|
+
if (eventType) {
|
|
47
|
+
for (const ws of this.ctx.getWebSockets(TAG_PREFIX + eventType)) {
|
|
48
|
+
targets.add(ws);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const ws of targets) {
|
|
53
|
+
try {
|
|
54
|
+
ws.send(message);
|
|
55
|
+
} catch {
|
|
56
|
+
// Client disconnected — close silently
|
|
57
|
+
try {
|
|
58
|
+
ws.close(1011, 'Broadcast failed');
|
|
59
|
+
} catch {
|
|
60
|
+
// Already closed
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
webSocketMessage() {}
|
|
67
|
+
webSocketClose() {}
|
|
68
|
+
webSocketError() {}
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { fromHono } from 'chanfana';
|
|
3
|
+
import type { Bindings, Variables } from './types';
|
|
4
|
+
import { wellKnown } from './routes/well-known';
|
|
5
|
+
import { requireAuth, requireScope, requireSubscribeIfPrivate } from './middleware/auth';
|
|
6
|
+
import { ListChannels, CreateChannel, AddPublisher } from './routes/channels';
|
|
7
|
+
import { PublishEvents, PollEvents } from './routes/events';
|
|
8
|
+
import { RegisterWebhook, DeleteWebhook } from './routes/webhooks';
|
|
9
|
+
import { GetServerMeta, UpdateServerMeta } from './routes/server-meta';
|
|
10
|
+
import { DirectoryClaim } from './routes/directory';
|
|
11
|
+
import { ws } from './routes/ws';
|
|
12
|
+
import { rss } from './routes/rss';
|
|
13
|
+
import { feed } from './routes/feed';
|
|
14
|
+
import { opml } from './routes/opml';
|
|
15
|
+
|
|
16
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
17
|
+
|
|
18
|
+
const app = new Hono<Env>();
|
|
19
|
+
|
|
20
|
+
// Well-known stays at root
|
|
21
|
+
app.route('', wellKnown);
|
|
22
|
+
|
|
23
|
+
// All API routes under /api
|
|
24
|
+
const api = new Hono<Env>();
|
|
25
|
+
const openapi = fromHono(api, {
|
|
26
|
+
docs_url: '/docs',
|
|
27
|
+
redoc_url: null,
|
|
28
|
+
openapi_url: '/openapi.json',
|
|
29
|
+
schema: {
|
|
30
|
+
info: {
|
|
31
|
+
title: 'Zooid',
|
|
32
|
+
version: '0.1.0',
|
|
33
|
+
description: 'Pub/sub for AI agents',
|
|
34
|
+
},
|
|
35
|
+
security: [{ bearerAuth: [] }],
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
openapi.registry.registerComponent('securitySchemes', 'bearerAuth', {
|
|
39
|
+
type: 'http',
|
|
40
|
+
scheme: 'bearer',
|
|
41
|
+
bearerFormat: 'JWT',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Server meta routes
|
|
45
|
+
openapi.get('/server', GetServerMeta);
|
|
46
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
47
|
+
openapi.put('/server', requireAuth(), requireScope('admin'), UpdateServerMeta);
|
|
48
|
+
|
|
49
|
+
// Channel routes
|
|
50
|
+
openapi.get('/channels', ListChannels);
|
|
51
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
52
|
+
openapi.post('/channels', requireAuth(), requireScope('admin'), CreateChannel);
|
|
53
|
+
// prettier-ignore
|
|
54
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
55
|
+
openapi.post('/channels/:channelId/publishers', requireAuth(), requireScope('admin'), AddPublisher);
|
|
56
|
+
|
|
57
|
+
// Event routes
|
|
58
|
+
// prettier-ignore
|
|
59
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
60
|
+
openapi.post('/channels/:channelId/events', requireAuth(), requireScope('publish', { channelParam: 'channelId' }), PublishEvents);
|
|
61
|
+
// prettier-ignore
|
|
62
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
63
|
+
openapi.get('/channels/:channelId/events', requireSubscribeIfPrivate('channelId'), PollEvents);
|
|
64
|
+
|
|
65
|
+
// Directory claim route
|
|
66
|
+
// prettier-ignore
|
|
67
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
68
|
+
openapi.post('/directory/claim', requireAuth(), requireScope('admin'), DirectoryClaim);
|
|
69
|
+
|
|
70
|
+
// Webhook routes
|
|
71
|
+
// prettier-ignore
|
|
72
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
73
|
+
openapi.post('/channels/:channelId/webhooks', requireSubscribeIfPrivate('channelId'), RegisterWebhook);
|
|
74
|
+
// prettier-ignore
|
|
75
|
+
// @ts-expect-error chanfana types don't include middleware overloads
|
|
76
|
+
openapi.delete('/channels/:channelId/webhooks/:webhookId', requireAuth(), requireScope('admin'), DeleteWebhook);
|
|
77
|
+
|
|
78
|
+
// Plain Hono routes (streaming/XML — not suited for OpenAPI)
|
|
79
|
+
api.route('', ws);
|
|
80
|
+
api.route('', rss);
|
|
81
|
+
api.route('', feed);
|
|
82
|
+
api.route('', opml);
|
|
83
|
+
|
|
84
|
+
api.get('/', (c) => c.json({ ok: true }));
|
|
85
|
+
app.route('/api/v1', api);
|
|
86
|
+
|
|
87
|
+
export { ChannelDO } from './do/channel';
|
|
88
|
+
export default app;
|