@alteran/astro 0.3.8 → 0.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,23 @@
1
+ import type { APIContext } from 'astro';
2
+ import { proxyAppView } from '../../lib/appview';
3
+ import { isAuthorized, unauthorized } from '../../lib/auth';
4
+
5
+ export const prerender = false;
6
+
7
+ // Implements: app.bsky.feed.getSuggestedFeeds (proxy-only)
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ if (!(await isAuthorized(request, env))) return unauthorized();
11
+
12
+ return proxyAppView({
13
+ request,
14
+ env,
15
+ lxm: 'app.bsky.feed.getSuggestedFeeds',
16
+ fallback: async () => {
17
+ return new Response(JSON.stringify({ feeds: [] }), {
18
+ headers: { 'Content-Type': 'application/json' },
19
+ });
20
+ },
21
+ });
22
+ }
23
+
@@ -0,0 +1,23 @@
1
+ import type { APIContext } from 'astro';
2
+ import { proxyAppView } from '../../lib/appview';
3
+ import { isAuthorized, unauthorized } from '../../lib/auth';
4
+
5
+ export const prerender = false;
6
+
7
+ // Implements: app.bsky.unspecced.getSuggestedFeeds (proxy-only)
8
+ export async function GET({ locals, request }: APIContext) {
9
+ const { env } = locals.runtime;
10
+ if (!(await isAuthorized(request, env))) return unauthorized();
11
+
12
+ return proxyAppView({
13
+ request,
14
+ env,
15
+ lxm: 'app.bsky.unspecced.getSuggestedFeeds',
16
+ fallback: async () => {
17
+ return new Response(JSON.stringify({ feeds: [] }), {
18
+ headers: { 'Content-Type': 'application/json' },
19
+ });
20
+ },
21
+ });
22
+ }
23
+
@@ -238,9 +238,6 @@ export class Sequencer {
238
238
  const pair = new WebSocketPair();
239
239
  const [client, server] = Object.values(pair);
240
240
  const id = crypto.randomUUID();
241
- const ws = server as unknown as WebSocket;
242
-
243
- ws.accept();
244
241
 
245
242
  // Parse cursor parameter
246
243
  const cursorParam = url.searchParams.get('cursor');
@@ -250,57 +247,30 @@ export class Sequencer {
250
247
  if (cursor > this.nextSeq - 1) {
251
248
  // Future cursor error
252
249
  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');
250
+ server.send(err);
251
+ server.close(1008, 'FutureCursor');
255
252
  return new Response(null, { status: 101, webSocket: client });
256
253
  }
257
254
 
258
- const clientObj: Client = { webSocket: ws, id, cursor };
255
+ // CRITICAL: Use Cloudflare's hibernatable WebSocket API
256
+ // This keeps the connection alive even after the response is returned
257
+ // Without this, the WebSocket closes immediately when the worker context ends
258
+ this.state.acceptWebSocket(server as any, [id, cursor.toString()]);
259
+
260
+ const clientObj: Client = { webSocket: server as unknown as WebSocket, id, cursor };
259
261
  this.clients.set(id, clientObj);
260
262
 
261
263
  // Send #info frame on connection
262
264
  const infoFrame = createInfoFrame('com.atproto.sync.subscribeRepos', 'Connected to PDS firehose');
263
265
  try {
264
- ws.send(infoFrame.toFramedBytes());
266
+ server.send(infoFrame.toFramedBytes());
265
267
  } catch (error) {
266
268
  console.error('Failed to send info frame:', error);
267
269
  }
268
270
 
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);
299
- });
300
-
301
271
  // Replay buffered events if cursor provided
302
272
  if (cursor > 0) {
303
- await this.replayFromCursor(ws, cursor);
273
+ await this.replayFromCursor(server as unknown as WebSocket, cursor);
304
274
  }
305
275
 
306
276
  return new Response(null, { status: 101, webSocket: client });
@@ -572,4 +542,52 @@ export class Sequencer {
572
542
  droppedFrames: this.droppedFrameCount,
573
543
  };
574
544
  }
545
+
546
+ /**
547
+ * WebSocket hibernation handler: called when a message is received
548
+ * This is required for Cloudflare's hibernatable WebSocket API
549
+ */
550
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
551
+ // Find client by WebSocket instance
552
+ const client = Array.from(this.clients.values()).find((c) => c.webSocket === ws);
553
+ if (!client) {
554
+ console.warn('Received message from unknown WebSocket');
555
+ return;
556
+ }
557
+
558
+ try {
559
+ const data = typeof message === 'string' ? message : new TextDecoder().decode(message);
560
+ if (data === 'ping') {
561
+ ws.send('pong');
562
+ }
563
+ } catch (error) {
564
+ console.error('WebSocket message error:', error);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * WebSocket hibernation handler: called when connection closes
570
+ * This is required for Cloudflare's hibernatable WebSocket API
571
+ */
572
+ async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
573
+ // Find and remove client
574
+ const entry = Array.from(this.clients.entries()).find(([_, c]) => c.webSocket === ws);
575
+ if (entry) {
576
+ this.clients.delete(entry[0]);
577
+ console.log(`Client ${entry[0]} disconnected: code=${code} reason="${reason}" clean=${wasClean}`);
578
+ }
579
+ }
580
+
581
+ /**
582
+ * WebSocket hibernation handler: called when an error occurs
583
+ * This is required for Cloudflare's hibernatable WebSocket API
584
+ */
585
+ async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
586
+ // Find and remove client
587
+ const entry = Array.from(this.clients.entries()).find(([_, c]) => c.webSocket === ws);
588
+ if (entry) {
589
+ this.clients.delete(entry[0]);
590
+ console.error(`Client ${entry[0]} error:`, error);
591
+ }
592
+ }
575
593
  }