@alteran/astro 0.3.8 → 0.5.2

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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -30
  3. package/index.js +34 -28
  4. package/migrations/0007_bored_spitfire.sql +26 -0
  5. package/migrations/0008_furry_ozymandias.sql +2 -0
  6. package/migrations/meta/0007_snapshot.json +534 -0
  7. package/migrations/meta/0008_snapshot.json +548 -0
  8. package/migrations/meta/_journal.json +14 -0
  9. package/package.json +10 -9
  10. package/src/app.ts +8 -4
  11. package/src/db/account.ts +25 -6
  12. package/src/db/dal.ts +34 -23
  13. package/src/db/repo.ts +35 -35
  14. package/src/db/schema.ts +5 -1
  15. package/src/db/seed.ts +5 -13
  16. package/src/entrypoints/server.ts +2 -22
  17. package/src/handlers/root.ts +4 -4
  18. package/src/lib/account-state.ts +156 -0
  19. package/src/lib/actor.ts +28 -12
  20. package/src/lib/appview/auth-policy.ts +66 -0
  21. package/src/lib/appview/did-resolver.ts +233 -0
  22. package/src/lib/appview/proxy.ts +221 -0
  23. package/src/lib/appview/service-config.ts +61 -0
  24. package/src/lib/appview/service-jwt.ts +93 -0
  25. package/src/lib/appview/types.ts +25 -0
  26. package/src/lib/appview.ts +5 -532
  27. package/src/lib/auth-errors.ts +24 -0
  28. package/src/lib/auth.ts +63 -15
  29. package/src/lib/blockstore-gc.ts +2 -1
  30. package/src/lib/cache.ts +30 -4
  31. package/src/lib/chat.ts +14 -8
  32. package/src/lib/commit.ts +26 -36
  33. package/src/lib/config.ts +26 -15
  34. package/src/lib/did-document.ts +32 -0
  35. package/src/lib/errors.ts +54 -0
  36. package/src/lib/feed.ts +18 -19
  37. package/src/lib/firehose/frames.ts +87 -47
  38. package/src/lib/firehose/validation.ts +3 -3
  39. package/src/lib/jwt.ts +85 -177
  40. package/src/lib/labeler.ts +43 -30
  41. package/src/lib/logger.ts +4 -0
  42. package/src/lib/mst/block-map.ts +172 -0
  43. package/src/lib/mst/blockstore.ts +56 -93
  44. package/src/lib/mst/index.ts +1 -0
  45. package/src/lib/mst/leaf.ts +25 -0
  46. package/src/lib/mst/mst.ts +81 -237
  47. package/src/lib/mst/serialize.ts +97 -0
  48. package/src/lib/mst/types.ts +21 -0
  49. package/src/lib/oauth/clients.ts +67 -0
  50. package/src/lib/oauth/dpop-errors.ts +15 -0
  51. package/src/lib/oauth/dpop.ts +150 -0
  52. package/src/lib/oauth/resource.ts +199 -0
  53. package/src/lib/oauth/store.ts +77 -0
  54. package/src/lib/preferences.ts +9 -34
  55. package/src/lib/refresh-session.ts +161 -0
  56. package/src/lib/relay.ts +10 -8
  57. package/src/lib/secrets.ts +6 -7
  58. package/src/lib/sequencer.ts +12 -3
  59. package/src/lib/service-auth.ts +184 -0
  60. package/src/lib/session-tokens.ts +28 -76
  61. package/src/lib/streaming-car.ts +3 -0
  62. package/src/lib/tracing.ts +4 -3
  63. package/src/lib/util.ts +65 -15
  64. package/src/middleware.ts +1 -1
  65. package/src/pages/.well-known/did.json.ts +27 -30
  66. package/src/pages/.well-known/oauth-authorization-server.ts +31 -0
  67. package/src/pages/.well-known/oauth-protected-resource.ts +22 -0
  68. package/src/pages/debug/record.ts +1 -1
  69. package/src/pages/debug/sequencer.ts +28 -0
  70. package/src/pages/oauth/authorize.ts +78 -0
  71. package/src/pages/oauth/consent.ts +80 -0
  72. package/src/pages/oauth/par.ts +121 -0
  73. package/src/pages/oauth/token.ts +158 -0
  74. package/src/pages/xrpc/[...nsid].ts +61 -0
  75. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +12 -13
  76. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +23 -23
  77. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +9 -2
  78. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +9 -2
  79. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +9 -2
  80. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +43 -41
  81. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +10 -3
  82. package/src/pages/xrpc/com.atproto.identity.resolveHandle.ts +40 -9
  83. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +41 -29
  84. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +20 -6
  85. package/src/pages/xrpc/com.atproto.identity.updateHandle.ts +1 -1
  86. package/src/pages/xrpc/com.atproto.repo.applyWrites.ts +101 -11
  87. package/src/pages/xrpc/com.atproto.repo.createRecord.ts +44 -14
  88. package/src/pages/xrpc/com.atproto.repo.deleteRecord.ts +41 -13
  89. package/src/pages/xrpc/com.atproto.repo.describeRepo.ts +2 -2
  90. package/src/pages/xrpc/com.atproto.repo.getRecord.ts +14 -1
  91. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +14 -6
  92. package/src/pages/xrpc/com.atproto.repo.listRecords.ts +1 -1
  93. package/src/pages/xrpc/com.atproto.repo.putRecord.ts +42 -14
  94. package/src/pages/xrpc/com.atproto.repo.uploadBlob.ts +76 -15
  95. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +20 -8
  96. package/src/pages/xrpc/com.atproto.server.createSession.ts +31 -11
  97. package/src/pages/xrpc/com.atproto.server.describeServer.ts +1 -1
  98. package/src/pages/xrpc/com.atproto.server.getServiceAuth.ts +12 -5
  99. package/src/pages/xrpc/com.atproto.server.getSession.ts +22 -8
  100. package/src/pages/xrpc/com.atproto.server.refreshSession.ts +30 -72
  101. package/src/pages/xrpc/com.atproto.sync.getBlob.ts +71 -22
  102. package/src/pages/xrpc/com.atproto.sync.getCheckout.json.ts +1 -1
  103. package/src/pages/xrpc/com.atproto.sync.getCheckout.ts +1 -1
  104. package/src/pages/xrpc/com.atproto.sync.getHead.ts +7 -2
  105. package/src/pages/xrpc/com.atproto.sync.getLatestCommit.ts +1 -1
  106. package/src/pages/xrpc/com.atproto.sync.getRecord.ts +5 -27
  107. package/src/pages/xrpc/com.atproto.sync.getRepo.json.ts +1 -1
  108. package/src/pages/xrpc/com.atproto.sync.getRepo.ts +50 -5
  109. package/src/pages/xrpc/com.atproto.sync.getRepoStatus.ts +58 -0
  110. package/src/pages/xrpc/com.atproto.sync.listBlobs.ts +1 -1
  111. package/src/pages/xrpc/com.atproto.sync.listRepos.ts +5 -3
  112. package/src/services/car.ts +207 -55
  113. package/src/services/r2-blob-store.ts +1 -1
  114. package/src/services/repo/blockstore-ops.ts +29 -0
  115. package/src/services/repo/operations.ts +133 -0
  116. package/src/services/repo-manager.ts +202 -253
  117. package/src/worker/runtime.ts +53 -8
  118. package/src/worker/sequencer/broadcast.ts +91 -0
  119. package/src/worker/sequencer/cid-helpers.ts +39 -0
  120. package/src/worker/sequencer/payload.ts +84 -0
  121. package/src/worker/sequencer/types.ts +36 -0
  122. package/src/worker/sequencer/upgrade.ts +141 -0
  123. package/src/worker/sequencer.ts +288 -412
  124. package/types/env.d.ts +15 -3
  125. package/src/pages/xrpc/app.bsky.actor.getProfile.ts +0 -49
  126. package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +0 -51
  127. package/src/pages/xrpc/app.bsky.feed.getActorFeeds.ts +0 -25
  128. package/src/pages/xrpc/app.bsky.feed.getAuthorFeed.ts +0 -42
  129. package/src/pages/xrpc/app.bsky.feed.getFeedGenerators.ts +0 -25
  130. package/src/pages/xrpc/app.bsky.feed.getPostThread.ts +0 -37
  131. package/src/pages/xrpc/app.bsky.feed.getPosts.ts +0 -26
  132. package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +0 -47
  133. package/src/pages/xrpc/app.bsky.graph.getFollowers.ts +0 -29
  134. package/src/pages/xrpc/app.bsky.graph.getFollows.ts +0 -29
  135. package/src/pages/xrpc/app.bsky.notification.getUnreadCount.ts +0 -20
  136. package/src/pages/xrpc/app.bsky.notification.listNotifications.ts +0 -27
@@ -1,65 +1,41 @@
1
- // Types via tsconfig
2
-
3
1
  import type { DurableObjectState, D1Database } from '@cloudflare/workers-types';
4
2
  import { drizzle } from 'drizzle-orm/d1';
5
3
  import { commit_log } from '../db/schema';
6
4
  import { gt, eq, desc } from 'drizzle-orm';
7
- import {
8
- createInfoFrame,
9
- createCommitFrame,
10
- createIdentityFrame,
11
- createAccountFrame,
12
- createErrorFrame,
13
- type CommitMessage,
14
- type RepoOp,
15
- } from '../lib/firehose/frames';
16
- import { checkCursor } from '../lib/firehose/validation';
17
- import { CID } from 'multiformats/cid';
18
- import { encodeBlocksForCommit } from '../services/car';
5
+ import { encodeInfoFrame, encodeCommitFrame } from '../lib/firehose/frames';
19
6
  import type { Env } from '../env';
7
+ import { fromWireStatus } from '../lib/account-state';
8
+ import type {
9
+ AccountEvent,
10
+ Client,
11
+ CommitEvent,
12
+ IdentityEvent,
13
+ } from './sequencer/types';
14
+ import { reviveOps, base64ToBytes } from './sequencer/cid-helpers';
15
+ import { createCommitPayload } from './sequencer/payload';
16
+ import {
17
+ handleUpgrade,
18
+ type HibernatableSocket,
19
+ type HibernatableState,
20
+ type WebSocketAttachment,
21
+ } from './sequencer/upgrade';
22
+ import {
23
+ broadcastAccount,
24
+ broadcastCommit,
25
+ broadcastIdentity,
26
+ } from './sequencer/broadcast';
27
+
28
+ export type {
29
+ AccountEvent,
30
+ Client,
31
+ CommitEvent,
32
+ IdentityEvent,
33
+ SequencerEvent,
34
+ } from './sequencer/types';
20
35
 
21
- interface Client {
22
- webSocket: WebSocket;
23
- id: string;
24
- cursor: number;
25
- }
26
-
27
- interface CommitEvent {
28
- seq: number;
29
- did: string;
30
- commitCid: string;
31
- rev: string;
32
- data: string; // JSON-encoded commit data
33
- sig: string; // base64 signature
34
- ts: number;
35
- ops?: RepoOp[];
36
- blocks?: Uint8Array;
37
- }
38
-
39
- interface IdentityEvent {
40
- seq: number;
41
- did: string;
42
- handle?: string;
43
- ts: number;
44
- }
45
-
46
- interface AccountEvent {
47
- seq: number;
48
- did: string;
49
- active: boolean;
50
- status?: string;
51
- ts: number;
52
- }
53
-
54
- type SequencerEvent = CommitEvent | IdentityEvent | AccountEvent;
55
-
56
- /**
57
- * Sequencer Durable Object
58
- * Manages the firehose event stream for repository updates
59
- */
60
36
  export class Sequencer {
61
- private readonly state: DurableObjectState;
62
- private readonly env: Env & { PDS_SEQ_WINDOW?: string };
37
+ private readonly state: HibernatableState;
38
+ private readonly env: Env;
63
39
  private readonly clients = new Map<string, Client>();
64
40
  private buffer: CommitEvent[] = [];
65
41
  private readonly db: D1Database;
@@ -67,17 +43,39 @@ export class Sequencer {
67
43
  private nextSeq = 1;
68
44
  private droppedFrameCount = 0;
69
45
 
70
- constructor(state: DurableObjectState, env: Env & { PDS_SEQ_WINDOW?: string }) {
71
- this.state = state;
46
+ constructor(state: DurableObjectState, env: Env) {
47
+ this.state = state as HibernatableState;
72
48
  this.env = env;
73
49
  this.db = env.DB;
74
50
  this.maxWindow = parseInt(env.PDS_SEQ_WINDOW || '512', 10);
75
51
 
76
- // Initialize from storage
52
+ // Reconcile nextSeq with DB on construction so replay and append agree
53
+ // after worker restarts or DO migrations.
77
54
  this.state.blockConcurrencyWhile(async () => {
78
- const stored = await this.state.storage.get<number>('nextSeq');
79
- if (stored) {
80
- this.nextSeq = stored;
55
+ let base = 0;
56
+ try {
57
+ base = (await this.state.storage.get<number>('nextSeq')) || 0;
58
+ } catch (storageError) {
59
+ console.warn('Sequencer: storage.get(nextSeq) failed:', storageError);
60
+ }
61
+ try {
62
+ const db = drizzle(this.db);
63
+ const last = await db
64
+ .select({ seq: commit_log.seq })
65
+ .from(commit_log)
66
+ .orderBy(desc(commit_log.seq))
67
+ .limit(1)
68
+ .get();
69
+ const dbNext = last?.seq ? Number(last.seq) + 1 : 1;
70
+ if (!base || dbNext > base) base = dbNext;
71
+ } catch (dbError) {
72
+ console.warn('Sequencer: commit_log max(seq) failed:', dbError);
73
+ }
74
+ this.nextSeq = base > 0 ? base : 1;
75
+ try {
76
+ await this.state.storage.put('nextSeq', this.nextSeq);
77
+ } catch (storageError) {
78
+ console.warn('Sequencer: storage.put(nextSeq) failed:', storageError);
81
79
  }
82
80
  });
83
81
  }
@@ -85,18 +83,16 @@ export class Sequencer {
85
83
  async fetch(request: Request): Promise<Response> {
86
84
  const url = new URL(request.url);
87
85
 
88
- // Handle event notifications from PDS
89
86
  if (request.method === 'POST') {
90
- if (url.pathname === '/commit') {
91
- return this.handleCommitNotification(request);
92
- } else if (url.pathname === '/identity') {
93
- return this.handleIdentityNotification(request);
94
- } else if (url.pathname === '/account') {
95
- return this.handleAccountNotification(request);
96
- }
87
+ if (url.pathname === '/commit') return this.handleCommitNotification(request);
88
+ if (url.pathname === '/identity') return this.handleIdentityNotification(request);
89
+ if (url.pathname === '/account') return this.handleAccountNotification(request);
90
+ }
91
+
92
+ if (request.method === 'GET' && url.pathname === '/metrics') {
93
+ return this.handleMetrics();
97
94
  }
98
95
 
99
- // Handle WebSocket upgrade for firehose subscription
100
96
  const upgradeHeader = request.headers.get('Upgrade');
101
97
  if (upgradeHeader !== 'websocket') {
102
98
  return new Response('Expected websocket', { status: 426 });
@@ -105,9 +101,6 @@ export class Sequencer {
105
101
  return this.handleWebSocketUpgrade(request, url);
106
102
  }
107
103
 
108
- /**
109
- * Handle commit notification from PDS
110
- */
111
104
  private async handleCommitNotification(request: Request): Promise<Response> {
112
105
  try {
113
106
  const body = (await request.json()) as {
@@ -116,50 +109,79 @@ export class Sequencer {
116
109
  rev: string;
117
110
  data: string;
118
111
  sig: string;
119
- ops?: RepoOp[];
120
- blocks?: string; // base64-encoded CAR
112
+ ops?: unknown;
113
+ blocks?: string;
121
114
  };
122
115
 
123
- // Helper: base64 to Uint8Array (workers-safe)
124
- const b64ToBytes = (b64: string): Uint8Array => {
125
- const bin = atob(b64);
126
- const out = new Uint8Array(bin.length);
127
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
128
- return out;
129
- };
116
+ const ops = reviveOps(body.ops);
117
+
118
+ const db = drizzle(this.db);
119
+ let seqForEvent: number | null = null;
120
+ let tsForEvent = Date.now();
121
+ try {
122
+ const row = await db
123
+ .select({
124
+ seq: commit_log.seq,
125
+ rev: commit_log.rev,
126
+ data: commit_log.data,
127
+ sig: commit_log.sig,
128
+ ts: commit_log.ts,
129
+ })
130
+ .from(commit_log)
131
+ .where(eq(commit_log.cid, body.commitCid))
132
+ .limit(1)
133
+ .get();
134
+ if (row && typeof row.seq === 'number') {
135
+ seqForEvent = row.seq;
136
+ body.rev = row.rev;
137
+ body.data = row.data;
138
+ body.sig = row.sig;
139
+ tsForEvent = row.ts ?? tsForEvent;
140
+ }
141
+ } catch (lookupError) {
142
+ console.warn('commit_log lookup failed:', lookupError);
143
+ }
144
+
145
+ if (seqForEvent == null) {
146
+ seqForEvent = this.nextSeq++;
147
+ await this.state.storage.put('nextSeq', this.nextSeq);
148
+ try {
149
+ await db
150
+ .insert(commit_log)
151
+ .values({
152
+ seq: seqForEvent,
153
+ cid: body.commitCid,
154
+ rev: body.rev,
155
+ data: body.data,
156
+ sig: body.sig,
157
+ ts: tsForEvent,
158
+ })
159
+ .run();
160
+ } catch (insertError) {
161
+ console.warn('commit_log insert failed:', insertError);
162
+ }
163
+ } else if (seqForEvent >= this.nextSeq) {
164
+ this.nextSeq = seqForEvent + 1;
165
+ try {
166
+ await this.state.storage.put('nextSeq', this.nextSeq);
167
+ } catch (putError) {
168
+ console.warn('Sequencer: storage.put(nextSeq) failed:', putError);
169
+ }
170
+ }
130
171
 
131
172
  const event: CommitEvent = {
132
- seq: this.nextSeq++,
173
+ seq: seqForEvent,
133
174
  did: body.did,
134
175
  commitCid: body.commitCid,
135
176
  rev: body.rev,
136
177
  data: body.data,
137
178
  sig: body.sig,
138
- ts: Date.now(),
139
- ops: body.ops,
140
- blocks: body.blocks ? b64ToBytes(body.blocks) : undefined,
179
+ ts: tsForEvent,
180
+ ops,
181
+ blocks: body.blocks ? base64ToBytes(body.blocks) : undefined,
141
182
  };
142
183
 
143
- // Persist sequence number
144
- await this.state.storage.put('nextSeq', this.nextSeq);
145
-
146
- // Update commit_log with assigned sequence for this commit (if row exists)
147
- try {
148
- const db = drizzle(this.db);
149
- const res = await db.update(commit_log).set({ seq: event.seq }).where(eq(commit_log.cid, event.commitCid)).run();
150
- // If the row didn't exist (unexpected), insert a minimal row so replay works
151
- // Note: drizzle's run() returns a driver-specific result; we just best-effort insert
152
- if ((res as any)?.success === false) {
153
- await db.insert(commit_log).values({ seq: event.seq, cid: event.commitCid, rev: event.rev, data: event.data, sig: event.sig, ts: event.ts }).run();
154
- }
155
- } catch (e) {
156
- console.warn('commit_log seq update failed:', e);
157
- }
158
-
159
- // Add to buffer
160
184
  this.appendCommit(event);
161
-
162
- // Broadcast to all connected clients
163
185
  await this.broadcastCommit(event);
164
186
 
165
187
  return new Response('ok');
@@ -169,29 +191,21 @@ export class Sequencer {
169
191
  }
170
192
  }
171
193
 
172
- /**
173
- * Handle identity notification from PDS (handle changes)
174
- */
175
194
  private async handleIdentityNotification(request: Request): Promise<Response> {
176
195
  try {
177
- const body = (await request.json()) as {
178
- did: string;
179
- handle?: string;
180
- };
181
-
196
+ const body = (await request.json()) as { did: string; handle?: string };
182
197
  const event: IdentityEvent = {
183
198
  seq: this.nextSeq++,
184
199
  did: body.did,
185
200
  handle: body.handle,
186
201
  ts: Date.now(),
187
202
  };
188
-
189
- // Persist sequence number
190
- await this.state.storage.put('nextSeq', this.nextSeq);
191
-
192
- // Broadcast to all connected clients
203
+ try {
204
+ await this.state.storage.put('nextSeq', this.nextSeq);
205
+ } catch (putError) {
206
+ console.warn('Sequencer: storage.put(nextSeq) failed:', putError);
207
+ }
193
208
  await this.broadcastIdentity(event);
194
-
195
209
  return new Response('ok');
196
210
  } catch (error) {
197
211
  console.error('Failed to handle identity notification:', error);
@@ -199,31 +213,21 @@ export class Sequencer {
199
213
  }
200
214
  }
201
215
 
202
- /**
203
- * Handle account notification from PDS (account status changes)
204
- */
205
216
  private async handleAccountNotification(request: Request): Promise<Response> {
206
217
  try {
207
- const body = (await request.json()) as {
208
- did: string;
209
- active: boolean;
210
- status?: string;
211
- };
212
-
218
+ const body = (await request.json()) as { did: string; active: boolean; status?: string };
213
219
  const event: AccountEvent = {
214
220
  seq: this.nextSeq++,
215
221
  did: body.did,
216
- active: body.active,
217
- status: body.status,
222
+ state: fromWireStatus({ active: body.active, status: body.status }),
218
223
  ts: Date.now(),
219
224
  };
220
-
221
- // Persist sequence number
222
- await this.state.storage.put('nextSeq', this.nextSeq);
223
-
224
- // Broadcast to all connected clients
225
+ try {
226
+ await this.state.storage.put('nextSeq', this.nextSeq);
227
+ } catch (putError) {
228
+ console.warn('Sequencer: storage.put(nextSeq) failed:', putError);
229
+ }
225
230
  await this.broadcastAccount(event);
226
-
227
231
  return new Response('ok');
228
232
  } catch (error) {
229
233
  console.error('Failed to handle account notification:', error);
@@ -231,321 +235,110 @@ export class Sequencer {
231
235
  }
232
236
  }
233
237
 
234
- /**
235
- * Handle WebSocket upgrade for firehose subscription
236
- */
237
- private async handleWebSocketUpgrade(request: Request, url: URL): Promise<Response> {
238
- const pair = new WebSocketPair();
239
- const [client, server] = Object.values(pair);
240
- const id = crypto.randomUUID();
241
- const ws = server as unknown as WebSocket;
242
-
243
- ws.accept();
244
-
245
- // Parse cursor parameter
246
- const cursorParam = url.searchParams.get('cursor');
247
- const cursor = cursorParam ? parseInt(cursorParam, 10) : 0;
248
-
249
- // Validate cursor
250
- if (cursor > this.nextSeq - 1) {
251
- // Future cursor error
252
- const err = checkCursor(cursor, this.nextSeq - 1) ?? createErrorFrame('FutureCursor', 'Cursor is ahead of current sequence').toFramedBytes();
253
- ws.send(err);
254
- ws.close(1008, 'FutureCursor');
255
- return new Response(null, { status: 101, webSocket: client });
256
- }
257
-
258
- const clientObj: Client = { webSocket: ws, id, cursor };
259
- this.clients.set(id, clientObj);
260
-
261
- // Send #info frame on connection
262
- const infoFrame = createInfoFrame('com.atproto.sync.subscribeRepos', 'Connected to PDS firehose');
263
- try {
264
- ws.send(infoFrame.toFramedBytes());
265
- } catch (error) {
266
- console.error('Failed to send info frame:', error);
267
- }
268
-
269
- // Keep the connection alive to avoid intermediary idle timeouts (e.g., CF edge)
270
- // Send a lightweight #info heartbeat every ~25s. Most clients ignore unknown #info
271
- // messages; this is safe and keeps the socket active.
272
- const keepalive = setInterval(() => {
273
- try {
274
- const ka = createInfoFrame('keepalive', 'ping');
275
- ws.send(ka.toFramedBytes());
276
- } catch {}
277
- }, 25_000);
278
-
279
- // Set up event handlers
280
- ws.addEventListener('message', (evt) => {
281
- try {
282
- const data = typeof evt.data === 'string' ? evt.data : '';
283
- if (data === 'ping') {
284
- ws.send('pong');
285
- }
286
- } catch (error) {
287
- console.error('WebSocket message error:', error);
288
- }
289
- });
290
-
291
- ws.addEventListener('close', () => {
292
- this.clients.delete(id);
293
- clearInterval(keepalive);
294
- });
295
-
296
- ws.addEventListener('error', () => {
297
- this.clients.delete(id);
298
- clearInterval(keepalive);
238
+ private handleWebSocketUpgrade(request: Request, url: URL): Response {
239
+ const hibernate = String(this.env.PDS_WS_HIBERNATE ?? 'true').toLowerCase() !== 'false';
240
+ return handleUpgrade(request, url, {
241
+ state: this.state,
242
+ nextSeq: this.nextSeq,
243
+ hibernate,
244
+ onClient: (id, cursor, server) => {
245
+ this.clients.set(id, { webSocket: server, id, cursor });
246
+ },
299
247
  });
300
-
301
- // Replay buffered events if cursor provided
302
- if (cursor > 0) {
303
- await this.replayFromCursor(ws, cursor);
304
- }
305
-
306
- return new Response(null, { status: 101, webSocket: client });
307
248
  }
308
249
 
309
- /**
310
- * Replay events from cursor
311
- */
312
250
  private async replayFromCursor(ws: WebSocket, cursor: number): Promise<void> {
313
- // First try from buffer
314
251
  const bufferedEvents = this.buffer.filter((e) => e.seq > cursor);
315
252
 
316
253
  if (bufferedEvents.length > 0) {
317
254
  for (const event of bufferedEvents) {
318
255
  try {
319
- const frame = await this.createCommitFrame(event);
320
- ws.send(frame.toFramedBytes());
256
+ const message = await createCommitPayload(this.env, this.db, event);
257
+ ws.send(encodeCommitFrame(message));
321
258
  } catch (error) {
322
259
  console.error('Failed to send buffered event:', error);
323
260
  }
324
261
  }
325
- } else {
326
- // Fetch from database if not in buffer
327
- try {
328
- const db = drizzle(this.db);
329
- const events = await db
330
- .select()
331
- .from(commit_log)
332
- .where(gt(commit_log.seq, cursor))
333
- .orderBy(commit_log.seq)
334
- .limit(100)
335
- .all();
336
-
337
- for (const event of events) {
338
- try {
339
- const commitEvent: CommitEvent = {
340
- seq: event.seq!,
341
- did: JSON.parse(event.data).did,
342
- commitCid: event.cid,
343
- rev: event.rev,
344
- data: event.data,
345
- sig: event.sig,
346
- ts: event.ts,
347
- };
348
- const frame = await this.createCommitFrame(commitEvent);
349
- ws.send(frame.toFramedBytes());
350
- } catch (error) {
351
- console.error('Failed to send database event:', error);
352
- }
353
- }
354
- } catch (error) {
355
- console.error('Failed to fetch events from database:', error);
356
- }
262
+ return;
357
263
  }
358
- }
359
-
360
- /**
361
- * Broadcast commit event to all connected clients
362
- */
363
- private async broadcastCommit(event: CommitEvent): Promise<void> {
364
- const frame = await this.createCommitFrame(event);
365
- const bytes = frame.toFramedBytes();
366
264
 
367
- const disconnected: string[] = [];
368
-
369
- for (const [id, client] of Array.from(this.clients.entries())) {
370
- try {
371
- // Check if client's cursor is caught up
372
- if (event.seq > client.cursor) {
373
- client.webSocket.send(bytes);
374
- client.cursor = event.seq;
265
+ try {
266
+ const db = drizzle(this.db);
267
+ const events = await db
268
+ .select()
269
+ .from(commit_log)
270
+ .where(gt(commit_log.seq, cursor))
271
+ .orderBy(commit_log.seq)
272
+ .limit(100)
273
+ .all();
274
+
275
+ for (const event of events) {
276
+ try {
277
+ if (event.seq == null) continue;
278
+ const commitEvent: CommitEvent = {
279
+ seq: event.seq,
280
+ did: JSON.parse(event.data).did,
281
+ commitCid: event.cid,
282
+ rev: event.rev,
283
+ data: event.data,
284
+ sig: event.sig,
285
+ ts: event.ts,
286
+ };
287
+ const message = await createCommitPayload(this.env, this.db, commitEvent);
288
+ ws.send(encodeCommitFrame(message));
289
+ } catch (error) {
290
+ console.error('Failed to send database event:', error);
375
291
  }
376
- } catch (error) {
377
- console.error(`Failed to send to client ${id}:`, error);
378
- disconnected.push(id);
379
292
  }
380
- }
381
-
382
- // Clean up disconnected clients
383
- for (const id of disconnected) {
384
- this.clients.delete(id);
293
+ } catch (error) {
294
+ console.error('Failed to fetch events from database:', error);
385
295
  }
386
296
  }
387
297
 
388
- /**
389
- * Broadcast identity event to all connected clients
390
- */
391
- private async broadcastIdentity(event: IdentityEvent): Promise<void> {
392
- const frame = createIdentityFrame({
393
- seq: event.seq,
394
- did: event.did,
395
- time: new Date(event.ts).toISOString(),
396
- handle: event.handle,
397
- });
398
- const bytes = frame.toFramedBytes();
399
-
400
- const disconnected: string[] = [];
401
-
402
- for (const [id, client] of Array.from(this.clients.entries())) {
403
- try {
404
- if (event.seq > client.cursor) {
405
- client.webSocket.send(bytes);
406
- client.cursor = event.seq;
407
- }
408
- } catch (error) {
409
- console.error(`Failed to send to client ${id}:`, error);
410
- disconnected.push(id);
411
- }
412
- }
413
-
414
- for (const id of disconnected) {
415
- this.clients.delete(id);
298
+ private getSocketTargets(): WebSocket[] {
299
+ let sockets: WebSocket[] = [];
300
+ try {
301
+ // workers-types WebSocket misses a few DOM-types members; the values
302
+ // are wire-compatible at runtime, so widen through unknown.
303
+ sockets = (this.state.getWebSockets?.() || []) as unknown as WebSocket[];
304
+ } catch (error) {
305
+ console.warn('Sequencer: getWebSockets failed:', error);
416
306
  }
307
+ return sockets.length > 0
308
+ ? sockets
309
+ : Array.from(this.clients.values()).map((c) => c.webSocket);
417
310
  }
418
311
 
419
- /**
420
- * Broadcast account event to all connected clients
421
- */
422
- private async broadcastAccount(event: AccountEvent): Promise<void> {
423
- const accountFrame = createAccountFrame({
424
- seq: event.seq,
425
- did: event.did,
426
- time: new Date(event.ts).toISOString(),
427
- active: event.active,
428
- status: event.status,
429
- });
430
- // Emit compatibility #sync frame as well
431
- const { createSyncFrame } = await import('../lib/firehose/frames');
432
- const syncLike = createSyncFrame({
433
- seq: event.seq,
434
- did: event.did,
435
- time: new Date(event.ts).toISOString(),
436
- active: event.active,
437
- status: event.status,
438
- });
439
- const bytesAccount = accountFrame.toFramedBytes();
440
- const bytesSync = syncLike.toFramedBytes();
441
-
442
- const disconnected: string[] = [];
443
-
444
- for (const [id, client] of Array.from(this.clients.entries())) {
445
- try {
446
- if (event.seq > client.cursor) {
447
- client.webSocket.send(bytesAccount);
448
- client.webSocket.send(bytesSync);
449
- client.cursor = event.seq;
450
- }
451
- } catch (error) {
452
- console.error(`Failed to send to client ${id}:`, error);
453
- disconnected.push(id);
454
- }
455
- }
456
-
457
- for (const id of disconnected) {
458
- this.clients.delete(id);
459
- }
312
+ private broadcastCommit(event: CommitEvent): Promise<void> {
313
+ return broadcastCommit(this.env, this.db, this.getSocketTargets(), event);
460
314
  }
461
315
 
462
- /**
463
- * Create a #commit frame from event
464
- */
465
- private async createCommitFrame(event: CommitEvent): Promise<ReturnType<typeof createCommitFrame>> {
466
- const commitData = JSON.parse(event.data);
467
-
468
- // If blocks weren't provided, encode them now
469
- let blocks = event.blocks;
470
- if (!blocks && event.ops && event.ops.length > 0) {
471
- try {
472
- const commitCid = CID.parse(event.commitCid);
473
- // Extract MST root from commit data
474
- const mstRoot = commitData.data ? CID.parse(commitData.data) : commitCid;
475
- blocks = await encodeBlocksForCommit(
476
- this.env as Env,
477
- commitCid,
478
- mstRoot,
479
- event.ops,
480
- );
481
- } catch (error) {
482
- console.error('Failed to encode blocks for commit:', error);
483
- blocks = new Uint8Array();
484
- }
485
- }
486
-
487
- // Resolve prev commit and since (previous rev) when available
488
- let prevCid: CID | null = null;
489
- try {
490
- if (commitData.prev) prevCid = CID.parse(String(commitData.prev));
491
- } catch {}
492
-
493
- let since: string | null = null;
494
- try {
495
- const db = drizzle(this.db);
496
- if (prevCid) {
497
- const prev = await db.select().from(commit_log).where(eq(commit_log.cid, prevCid.toString())).get();
498
- since = prev?.rev ?? null;
499
- } else {
500
- const row = await db.select().from(commit_log).where(gt(commit_log.seq, 0 as any)).orderBy(desc(commit_log.seq)).limit(1).get();
501
- since = row?.rev ?? null;
502
- }
503
- } catch {}
504
-
505
- const message: CommitMessage = {
506
- seq: event.seq,
507
- rebase: false,
508
- tooBig: false,
509
- repo: event.did,
510
- commit: CID.parse(event.commitCid),
511
- prev: prevCid,
512
- rev: event.rev,
513
- since,
514
- blocks: blocks || new Uint8Array(),
515
- ops: event.ops || [],
516
- blobs: [],
517
- time: new Date(event.ts).toISOString(),
518
- };
316
+ private broadcastIdentity(event: IdentityEvent): void {
317
+ broadcastIdentity(this.getSocketTargets(), event);
318
+ }
519
319
 
520
- return createCommitFrame(message);
320
+ private broadcastAccount(event: AccountEvent): void {
321
+ broadcastAccount(this.getSocketTargets(), event);
521
322
  }
522
323
 
523
- /**
524
- * Append commit event to buffer with backpressure
525
- */
526
324
  private appendCommit(event: CommitEvent): void {
527
325
  this.buffer.push(event);
528
326
 
529
- // Implement backpressure: drop oldest events if buffer is full
530
327
  if (this.buffer.length > this.maxWindow) {
531
328
  const dropped = this.buffer.shift();
532
329
  this.droppedFrameCount++;
533
- console.warn(`Dropped event seq=${dropped?.seq} due to backpressure (total dropped: ${this.droppedFrameCount})`);
534
-
535
- // Send #info frame to all clients about dropped frames
330
+ console.warn(
331
+ `Dropped event seq=${dropped?.seq} due to backpressure (total dropped: ${this.droppedFrameCount})`,
332
+ );
536
333
  this.notifyFramesDropped();
537
334
  }
538
335
  }
539
336
 
540
- /**
541
- * Notify all clients that frames were dropped
542
- */
543
337
  private notifyFramesDropped(): void {
544
- const infoFrame = createInfoFrame(
338
+ const bytes = encodeInfoFrame(
545
339
  'FramesDropped',
546
340
  `${this.droppedFrameCount} frame(s) dropped due to backpressure`,
547
341
  );
548
- const bytes = infoFrame.toFramedBytes();
549
342
 
550
343
  for (const [id, client] of Array.from(this.clients.entries())) {
551
344
  try {
@@ -556,9 +349,6 @@ export class Sequencer {
556
349
  }
557
350
  }
558
351
 
559
- /**
560
- * Get metrics
561
- */
562
352
  getMetrics(): {
563
353
  connectedClients: number;
564
354
  bufferSize: number;
@@ -572,4 +362,90 @@ export class Sequencer {
572
362
  droppedFrames: this.droppedFrameCount,
573
363
  };
574
364
  }
365
+
366
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
367
+ const client = Array.from(this.clients.values()).find((c) => c.webSocket === ws);
368
+ if (!client) {
369
+ console.warn('Received message from unknown WebSocket');
370
+ return;
371
+ }
372
+
373
+ try {
374
+ const data = typeof message === 'string' ? message : new TextDecoder().decode(message);
375
+ if (data === 'ping') ws.send('pong');
376
+ } catch (error) {
377
+ console.error('WebSocket message error:', error);
378
+ }
379
+ }
380
+
381
+ private async handleMetrics(): Promise<Response> {
382
+ const base = this.getMetrics();
383
+ let sockets: WebSocket[] = [];
384
+ try {
385
+ // workers-types WebSocket misses a few DOM-types members; the values
386
+ // are wire-compatible at runtime, so widen through unknown.
387
+ sockets = (this.state.getWebSockets?.() || []) as unknown as WebSocket[];
388
+ } catch (error) {
389
+ console.warn('Sequencer: getWebSockets failed in metrics:', error);
390
+ }
391
+ const clients = sockets.map((ws) => {
392
+ let attachment: WebSocketAttachment | undefined;
393
+ try {
394
+ attachment = (ws as HibernatableSocket).deserializeAttachment?.();
395
+ } catch (attachError) {
396
+ console.warn('Sequencer: deserializeAttachment failed in metrics:', attachError);
397
+ }
398
+ return { attachment: attachment ?? null };
399
+ });
400
+ const body = {
401
+ ...base,
402
+ hibernatedSockets: sockets.length,
403
+ clients,
404
+ };
405
+ return new Response(JSON.stringify(body), {
406
+ headers: { 'Content-Type': 'application/json' },
407
+ });
408
+ }
409
+
410
+ async webSocketOpen(ws: WebSocket): Promise<void> {
411
+ console.log(JSON.stringify({ level: 'info', type: 'ws_open', ts: new Date().toISOString() }));
412
+
413
+ let cursor: number | undefined;
414
+ try {
415
+ const attachment = (ws as HibernatableSocket).deserializeAttachment?.();
416
+ if (attachment && typeof attachment.cursor === 'number') cursor = attachment.cursor;
417
+ } catch (attachError) {
418
+ console.warn('Sequencer: deserializeAttachment failed on open:', attachError);
419
+ }
420
+ if (cursor == null) {
421
+ const client = Array.from(this.clients.values()).find((c) => c.webSocket === ws);
422
+ if (client) cursor = client.cursor;
423
+ }
424
+ if (cursor && cursor > 0) {
425
+ await this.replayFromCursor(ws, cursor);
426
+ }
427
+ }
428
+
429
+ async webSocketClose(
430
+ ws: WebSocket,
431
+ code: number,
432
+ reason: string,
433
+ wasClean: boolean,
434
+ ): Promise<void> {
435
+ const entry = Array.from(this.clients.entries()).find(([, c]) => c.webSocket === ws);
436
+ if (entry) {
437
+ this.clients.delete(entry[0]);
438
+ console.log(
439
+ `Client ${entry[0]} disconnected: code=${code} reason="${reason}" clean=${wasClean}`,
440
+ );
441
+ }
442
+ }
443
+
444
+ async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
445
+ const entry = Array.from(this.clients.entries()).find(([, c]) => c.webSocket === ws);
446
+ if (entry) {
447
+ this.clients.delete(entry[0]);
448
+ console.error(`Client ${entry[0]} error:`, error);
449
+ }
450
+ }
575
451
  }