@alteran/astro 0.3.7 → 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 +1 -1
- package/src/lib/appview.ts +25 -1
- package/src/pages/xrpc/app.bsky.actor.getProfile.ts +36 -21
- package/src/pages/xrpc/app.bsky.actor.getProfiles.ts +11 -2
- package/src/pages/xrpc/app.bsky.feed.getSuggestedFeeds.ts +23 -0
- package/src/pages/xrpc/app.bsky.feed.getTimeline.ts +32 -20
- package/src/pages/xrpc/app.bsky.unspecced.getSuggestedFeeds.ts +23 -0
- package/src/worker/sequencer.ts +58 -40
package/package.json
CHANGED
package/src/lib/appview.ts
CHANGED
|
@@ -350,22 +350,34 @@ export interface ProxyAppViewOptions {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppViewOptions): Promise<Response> {
|
|
353
|
+
console.log('proxyAppView called:', { lxm, url: request.url });
|
|
354
|
+
|
|
353
355
|
const config = getAppViewConfig(env);
|
|
354
356
|
if (!config) {
|
|
357
|
+
console.log('proxyAppView: No appview config, using fallback');
|
|
355
358
|
return fallback ? await fallback() : new Response('AppView not configured', { status: 501 });
|
|
356
359
|
}
|
|
357
360
|
|
|
361
|
+
console.log('proxyAppView: AppView config found:', { url: config.url, did: config.did });
|
|
362
|
+
|
|
358
363
|
const auth = await authenticateRequest(request, env);
|
|
359
|
-
if (!auth)
|
|
364
|
+
if (!auth) {
|
|
365
|
+
console.log('proxyAppView: Authentication failed');
|
|
366
|
+
return unauthorized();
|
|
367
|
+
}
|
|
360
368
|
|
|
361
369
|
if (!auth.claims.sub) {
|
|
370
|
+
console.log('proxyAppView: No subject in auth claims');
|
|
362
371
|
return new Response(JSON.stringify({ error: 'InvalidToken' }), {
|
|
363
372
|
status: 401,
|
|
364
373
|
headers: { 'Content-Type': 'application/json' },
|
|
365
374
|
});
|
|
366
375
|
}
|
|
367
376
|
|
|
377
|
+
console.log('proxyAppView: Authenticated as', auth.claims.sub);
|
|
378
|
+
|
|
368
379
|
if (PROTECTED_METHODS.has(lxm)) {
|
|
380
|
+
console.log('proxyAppView: Method is protected, cannot proxy');
|
|
369
381
|
return new Response(
|
|
370
382
|
JSON.stringify({ error: 'InvalidToken', message: 'method cannot be proxied' }),
|
|
371
383
|
{
|
|
@@ -377,6 +389,7 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
377
389
|
|
|
378
390
|
const scope = resolveAuthScope(auth.claims.scope);
|
|
379
391
|
if (scope === TAKENDOWN_SCOPE) {
|
|
392
|
+
console.log('proxyAppView: Account is takendown');
|
|
380
393
|
return new Response(JSON.stringify({ error: 'AccountTakendown' }), {
|
|
381
394
|
status: 403,
|
|
382
395
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -384,6 +397,7 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
384
397
|
}
|
|
385
398
|
|
|
386
399
|
if (!PRIVILEGED_SCOPES.has(scope) && PRIVILEGED_METHODS.has(lxm)) {
|
|
400
|
+
console.log('proxyAppView: Insufficient privileges for method');
|
|
387
401
|
return new Response(JSON.stringify({ error: 'InvalidToken' }), {
|
|
388
402
|
status: 401,
|
|
389
403
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -393,6 +407,7 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
393
407
|
let target: ProxyTarget = { did: config.did, url: config.url };
|
|
394
408
|
const proxyHeader = request.headers.get('atproto-proxy');
|
|
395
409
|
if (proxyHeader) {
|
|
410
|
+
console.log('proxyAppView: Resolving proxy header:', proxyHeader);
|
|
396
411
|
try {
|
|
397
412
|
target = await resolveProxyTarget(env, proxyHeader, config);
|
|
398
413
|
} catch (error) {
|
|
@@ -414,6 +429,8 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
414
429
|
upstreamUrl.search = originalUrl.search;
|
|
415
430
|
upstreamUrl.hash = '';
|
|
416
431
|
|
|
432
|
+
console.log('proxyAppView: Proxying to', upstreamUrl.toString());
|
|
433
|
+
|
|
417
434
|
const headers = new Headers();
|
|
418
435
|
for (const header of FORWARDED_HEADERS) {
|
|
419
436
|
const value = request.headers.get(header);
|
|
@@ -422,10 +439,13 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
422
439
|
|
|
423
440
|
let serviceJwt: string;
|
|
424
441
|
try {
|
|
442
|
+
console.log('proxyAppView: Creating service JWT for', { iss: auth.claims.sub, aud: target.did, lxm });
|
|
425
443
|
serviceJwt = await createServiceJwt(env, auth.claims.sub, target.did, lxm);
|
|
444
|
+
console.log('proxyAppView: Service JWT created successfully');
|
|
426
445
|
} catch (error) {
|
|
427
446
|
console.error('AppView service token error:', error);
|
|
428
447
|
if (fallback) {
|
|
448
|
+
console.log('proxyAppView: Using fallback due to JWT error');
|
|
429
449
|
return fallback();
|
|
430
450
|
}
|
|
431
451
|
return new Response(JSON.stringify({ error: 'ServiceAuthUnavailable' }), {
|
|
@@ -438,6 +458,7 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
438
458
|
|
|
439
459
|
const method = request.method.toUpperCase();
|
|
440
460
|
if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
|
|
461
|
+
console.log('proxyAppView: Method not allowed:', method);
|
|
441
462
|
return new Response(JSON.stringify({ error: 'MethodNotAllowed' }), {
|
|
442
463
|
status: 405,
|
|
443
464
|
headers: {
|
|
@@ -469,7 +490,9 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
469
490
|
(init as any).duplex = 'half';
|
|
470
491
|
}
|
|
471
492
|
|
|
493
|
+
console.log('proxyAppView: Fetching upstream');
|
|
472
494
|
const upstream = await fetch(upstreamUrl.toString(), init);
|
|
495
|
+
console.log('proxyAppView: Upstream response:', { status: upstream.status, statusText: upstream.statusText });
|
|
473
496
|
|
|
474
497
|
const responseHeaders = new Headers(upstream.headers);
|
|
475
498
|
return new Response(upstream.body, {
|
|
@@ -480,6 +503,7 @@ export async function proxyAppView({ request, env, lxm, fallback }: ProxyAppView
|
|
|
480
503
|
} catch (error) {
|
|
481
504
|
console.error('AppView proxy error:', error);
|
|
482
505
|
if (fallback) {
|
|
506
|
+
console.log('proxyAppView: Using fallback due to upstream error');
|
|
483
507
|
return fallback();
|
|
484
508
|
}
|
|
485
509
|
return new Response(JSON.stringify({ error: 'UpstreamUnavailable' }), {
|
|
@@ -10,25 +10,40 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
10
10
|
const { env } = locals.runtime;
|
|
11
11
|
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
try {
|
|
14
|
+
return await proxyAppView({
|
|
15
|
+
request,
|
|
16
|
+
env,
|
|
17
|
+
lxm: 'app.bsky.actor.getProfile',
|
|
18
|
+
fallback: async () => {
|
|
19
|
+
console.log('app.bsky.actor.getProfile: Using fallback');
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
const identifier = url.searchParams.get('actor');
|
|
22
|
+
const actor = await getPrimaryActor(env);
|
|
23
|
+
console.log('app.bsky.actor.getProfile: actor', { did: actor.did, handle: actor.handle, identifier });
|
|
24
|
+
if (!matchesPrimaryActor(identifier, actor)) {
|
|
25
|
+
console.log('app.bsky.actor.getProfile: identifier does not match actor');
|
|
26
|
+
return new Response(JSON.stringify({ error: 'ProfileNotFound' }), {
|
|
27
|
+
status: 404,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const profile = buildProfileViewDetailed(actor, {
|
|
32
|
+
followers: 0,
|
|
33
|
+
follows: 0,
|
|
34
|
+
posts: await countPosts(env),
|
|
35
|
+
});
|
|
36
|
+
console.log('app.bsky.actor.getProfile: returning profile', profile);
|
|
37
|
+
return new Response(JSON.stringify(profile), {
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('app.bsky.actor.getProfile error:', error);
|
|
44
|
+
return new Response(JSON.stringify({ error: 'InternalServerError', message: String(error) }), {
|
|
45
|
+
status: 500,
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
34
49
|
}
|
|
@@ -14,13 +14,22 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
14
14
|
const { env } = locals.runtime;
|
|
15
15
|
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
16
16
|
|
|
17
|
+
// Some clients call with an empty actors list; upstream returns 400.
|
|
18
|
+
// For UX parity, treat missing/empty as an empty result set.
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const requestedActors = url.searchParams.getAll('actors');
|
|
21
|
+
if (requestedActors.length === 0) {
|
|
22
|
+
return new Response(JSON.stringify({ profiles: [] }), {
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
return proxyAppView({
|
|
18
28
|
request,
|
|
19
29
|
env,
|
|
20
30
|
lxm: 'app.bsky.actor.getProfiles',
|
|
21
31
|
fallback: async () => {
|
|
22
|
-
const
|
|
23
|
-
const actors = url.searchParams.getAll('actors');
|
|
32
|
+
const actors = requestedActors;
|
|
24
33
|
const actor = await getPrimaryActor(env);
|
|
25
34
|
const posts = await countPosts(env);
|
|
26
35
|
|
|
@@ -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
|
+
|
|
@@ -9,27 +9,39 @@ export async function GET({ locals, request }: APIContext) {
|
|
|
9
9
|
const { env } = locals.runtime;
|
|
10
10
|
if (!(await isAuthorized(request, env))) return unauthorized();
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
try {
|
|
13
|
+
return await proxyAppView({
|
|
14
|
+
request,
|
|
15
|
+
env,
|
|
16
|
+
lxm: 'app.bsky.feed.getTimeline',
|
|
17
|
+
fallback: async () => {
|
|
18
|
+
console.log('app.bsky.feed.getTimeline: Using fallback');
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
21
|
+
const limitParam = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
|
|
22
|
+
const limitInput = Number.isFinite(limitParam) ? limitParam : 50;
|
|
23
|
+
const limit = Math.max(1, Math.min(limitInput, 100));
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
console.log('app.bsky.feed.getTimeline: fetching posts', { limit, cursor });
|
|
26
|
+
const posts = await listPosts(env, limit, cursor);
|
|
27
|
+
console.log('app.bsky.feed.getTimeline: found posts', posts.length);
|
|
28
|
+
const feed = await buildFeedViewPosts(env, posts);
|
|
29
|
+
const nextCursor = posts.length === limit ? String(posts[posts.length - 1].rowid) : undefined;
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const payload: Record<string, unknown> = { feed };
|
|
32
|
+
if (nextCursor) payload.cursor = nextCursor;
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
console.log('app.bsky.feed.getTimeline: returning feed', { feedLength: feed.length, nextCursor });
|
|
35
|
+
return new Response(JSON.stringify(payload), {
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('app.bsky.feed.getTimeline error:', error);
|
|
42
|
+
return new Response(JSON.stringify({ error: 'InternalServerError', message: String(error) }), {
|
|
43
|
+
status: 500,
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
35
47
|
}
|
|
@@ -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
|
+
|
package/src/worker/sequencer.ts
CHANGED
|
@@ -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
|
-
|
|
254
|
-
|
|
250
|
+
server.send(err);
|
|
251
|
+
server.close(1008, 'FutureCursor');
|
|
255
252
|
return new Response(null, { status: 101, webSocket: client });
|
|
256
253
|
}
|
|
257
254
|
|
|
258
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|