@commonpub/layer 0.18.3 → 0.19.0

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.
@@ -53,12 +53,15 @@ watch(() => props.open, (v) => {
53
53
  search.value = '';
54
54
  }
55
55
  });
56
+
57
+ const dialogRef = ref<HTMLElement | null>(null);
58
+ useFocusTrap(dialogRef, () => props.open, close);
56
59
  </script>
57
60
 
58
61
  <template>
59
62
  <Teleport to="body">
60
63
  <div v-if="open" class="cpub-picker-overlay" @click.self="close">
61
- <div class="cpub-picker-dialog" role="dialog" aria-label="Select content" @keydown.escape="close">
64
+ <div ref="dialogRef" class="cpub-picker-dialog" role="dialog" aria-label="Select content">
62
65
  <div class="cpub-picker-header">
63
66
  <h2 class="cpub-picker-title"><i class="fa-solid fa-link"></i> Link Existing Content</h2>
64
67
  <button class="cpub-picker-close" aria-label="Close" @click="close"><i class="fa-solid fa-xmark"></i></button>
@@ -77,16 +77,14 @@ function resetState(): void {
77
77
  confirmed.value = false;
78
78
  }
79
79
 
80
- function onKeydown(e: KeyboardEvent): void {
81
- if (e.key === 'Escape') handleClose();
82
- if (e.key === 'Enter' && !result.value && canSubmit.value && !loading.value) handleFetch();
83
- }
80
+ const dialogRef = ref<HTMLElement | null>(null);
81
+ useFocusTrap(dialogRef, () => props.show, handleClose);
84
82
  </script>
85
83
 
86
84
  <template>
87
85
  <Teleport to="body">
88
- <div v-if="show" class="cpub-import-overlay" @click.self="handleClose" @keydown="onKeydown">
89
- <div class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
86
+ <div v-if="show" class="cpub-import-overlay" @click.self="handleClose">
87
+ <div ref="dialogRef" class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
90
88
  <div class="cpub-import-header">
91
89
  <h2 id="import-url-title"><i class="fa-solid fa-file-import"></i> Import from URL</h2>
92
90
  <button class="cpub-import-close" aria-label="Close" @click="handleClose">
@@ -3,7 +3,7 @@
3
3
  * Modal showing publish validation errors.
4
4
  * Displayed when the user tries to publish content that's missing required fields.
5
5
  */
6
- defineProps<{
6
+ const props = defineProps<{
7
7
  errors: string[];
8
8
  show: boolean;
9
9
  }>();
@@ -11,12 +11,15 @@ defineProps<{
11
11
  const emit = defineEmits<{
12
12
  dismiss: [];
13
13
  }>();
14
+
15
+ const dialogRef = ref<HTMLElement | null>(null);
16
+ useFocusTrap(dialogRef, () => props.show, () => emit('dismiss'));
14
17
  </script>
15
18
 
16
19
  <template>
17
20
  <Teleport to="body">
18
21
  <div v-if="show" class="cpub-publish-errors-overlay" @click.self="emit('dismiss')">
19
- <div class="cpub-publish-errors-card" role="alertdialog" aria-labelledby="publish-errors-title">
22
+ <div ref="dialogRef" class="cpub-publish-errors-card" role="alertdialog" aria-labelledby="publish-errors-title">
20
23
  <h3 id="publish-errors-title" class="cpub-publish-errors-title">
21
24
  <i class="fa-solid fa-circle-exclamation" /> Not ready to publish
22
25
  </h3>
@@ -36,12 +36,15 @@ function submit(): void {
36
36
  }
37
37
 
38
38
  defineExpose({ show });
39
+
40
+ const dialogRef = ref<HTMLElement | null>(null);
41
+ useFocusTrap(dialogRef, () => open.value, close);
39
42
  </script>
40
43
 
41
44
  <template>
42
45
  <Teleport to="body">
43
46
  <div v-if="open" class="cpub-rfd-overlay" @click.self="close">
44
- <div class="cpub-rfd-dialog" role="dialog" aria-modal="true">
47
+ <div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true">
45
48
  <div class="cpub-rfd-header">
46
49
  <h3>Follow from your instance</h3>
47
50
  <button class="cpub-rfd-close" aria-label="Close" @click="close">
@@ -0,0 +1,90 @@
1
+ import type { Ref } from 'vue';
2
+
3
+ /**
4
+ * Modal a11y helper: focus trap, initial focus, focus restoration, body
5
+ * scroll lock. Mirrors the behaviour of `<Dialog>` in @commonpub/ui without
6
+ * forcing a visual refactor of layer-side modals that have their own
7
+ * header/footer styling.
8
+ *
9
+ * Wire it from a modal's `<script setup>`:
10
+ *
11
+ * const dialogRef = ref<HTMLElement | null>(null);
12
+ * useFocusTrap(dialogRef, () => props.open, close);
13
+ *
14
+ * Where `() => props.open` is a getter that returns true while the modal
15
+ * is visible, and `close` is the function that dismisses it (called when
16
+ * the user presses Escape).
17
+ *
18
+ * The trap cycles Tab/Shift+Tab within `dialogRef`'s focusable
19
+ * descendants. On open, focus moves to the first focusable element. On
20
+ * close, focus restores to whatever element had focus when the modal
21
+ * opened. Body scroll is locked while open.
22
+ */
23
+ export function useFocusTrap(
24
+ dialogRef: Ref<HTMLElement | null>,
25
+ isOpen: () => boolean,
26
+ onEscape: () => void,
27
+ ): void {
28
+ let previousActive: HTMLElement | null = null;
29
+
30
+ function focusableElements(): HTMLElement[] {
31
+ if (!dialogRef.value) return [];
32
+ return Array.from(
33
+ dialogRef.value.querySelectorAll<HTMLElement>(
34
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
35
+ ),
36
+ );
37
+ }
38
+
39
+ function handleKeydown(event: KeyboardEvent): void {
40
+ if (!isOpen()) return;
41
+ if (event.key === 'Escape') {
42
+ event.preventDefault();
43
+ onEscape();
44
+ return;
45
+ }
46
+ if (event.key !== 'Tab') return;
47
+
48
+ const focusable = focusableElements();
49
+ if (focusable.length === 0) {
50
+ event.preventDefault();
51
+ return;
52
+ }
53
+ const first = focusable[0]!;
54
+ const last = focusable[focusable.length - 1]!;
55
+ const active = document.activeElement as HTMLElement | null;
56
+
57
+ if (event.shiftKey) {
58
+ if (active === first || !dialogRef.value?.contains(active)) {
59
+ event.preventDefault();
60
+ last.focus();
61
+ }
62
+ } else {
63
+ if (active === last || !dialogRef.value?.contains(active)) {
64
+ event.preventDefault();
65
+ first.focus();
66
+ }
67
+ }
68
+ }
69
+
70
+ watch(isOpen, async (open) => {
71
+ if (open) {
72
+ previousActive = document.activeElement as HTMLElement | null;
73
+ document.body.style.overflow = 'hidden';
74
+ document.addEventListener('keydown', handleKeydown);
75
+ await nextTick();
76
+ const first = focusableElements()[0];
77
+ first?.focus();
78
+ } else {
79
+ document.body.style.overflow = '';
80
+ document.removeEventListener('keydown', handleKeydown);
81
+ previousActive?.focus();
82
+ previousActive = null;
83
+ }
84
+ });
85
+
86
+ onUnmounted(() => {
87
+ document.body.style.overflow = '';
88
+ document.removeEventListener('keydown', handleKeydown);
89
+ });
90
+ }
@@ -83,6 +83,10 @@ const userUsername = computed(() => user.value?.username ?? '');
83
83
 
84
84
  <template>
85
85
  <div class="cpub-layout">
86
+ <!-- WCAG 2.4.1 — visually hidden until focused, lets keyboard users
87
+ skip past the global nav directly to page content. -->
88
+ <a href="#main-content" class="cpub-skip-link">Skip to content</a>
89
+
86
90
  <!-- ═══ TOP NAV ═══ -->
87
91
  <header class="cpub-topbar">
88
92
  <NuxtLink to="/" class="cpub-topbar-logo">
@@ -218,6 +222,28 @@ const userUsername = computed(() => user.value?.username ?? '');
218
222
  </template>
219
223
 
220
224
  <style scoped>
225
+ .cpub-skip-link {
226
+ position: absolute;
227
+ top: 0;
228
+ left: 0;
229
+ z-index: var(--z-modal, 9999);
230
+ padding: 8px 16px;
231
+ background: var(--accent);
232
+ color: var(--color-on-accent, #fff);
233
+ font-family: var(--font-mono);
234
+ font-size: var(--text-label);
235
+ text-transform: uppercase;
236
+ letter-spacing: var(--tracking-wider);
237
+ text-decoration: none;
238
+ transform: translateY(-100%);
239
+ transition: transform var(--transition-fast);
240
+ }
241
+ .cpub-skip-link:focus {
242
+ transform: translateY(0);
243
+ outline: 2px solid var(--text);
244
+ outline-offset: -2px;
245
+ }
246
+
221
247
  .cpub-layout { min-height: 100vh; display: flex; flex-direction: column; }
222
248
 
223
249
  /* ═══ TOPBAR ═══ */
package/nuxt.config.ts CHANGED
@@ -14,10 +14,16 @@ export default defineNuxtConfig({
14
14
  compatibilityDate: '2024-11-01',
15
15
  app: {
16
16
  head: {
17
+ htmlAttrs: { lang: 'en' },
17
18
  link: [
18
19
  {
19
20
  rel: 'stylesheet',
20
21
  href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
22
+ // SRI: protects against a compromised CDN serving altered CSS.
23
+ // If Font Awesome is upgraded, regenerate via:
24
+ // curl -sS https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css \
25
+ // | openssl dgst -sha384 -binary | openssl base64 -A | sed 's/^/sha384-/'
26
+ integrity: 'sha384-SZXxX4whJ79/gErwcOYf+zWLeJdY/qpuqC4cAa9rOGUstPomtqpuNWT9wdPEn2fk',
21
27
  crossorigin: 'anonymous',
22
28
  },
23
29
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.18.3",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -28,9 +28,6 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
- "@commonpub/explainer": "^0.7.12",
32
- "@commonpub/schema": "^0.14.4",
33
- "@commonpub/server": "^2.47.3",
34
31
  "@tiptap/core": "^2.11.0",
35
32
  "@tiptap/extension-bold": "^2.11.0",
36
33
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -56,10 +53,13 @@
56
53
  "@commonpub/auth": "0.5.1",
57
54
  "@commonpub/config": "0.11.0",
58
55
  "@commonpub/docs": "0.6.2",
59
- "@commonpub/editor": "0.7.9",
56
+ "@commonpub/protocol": "0.9.9",
57
+ "@commonpub/schema": "0.15.0",
58
+ "@commonpub/server": "2.48.0",
59
+ "@commonpub/explainer": "0.7.12",
60
60
  "@commonpub/learning": "0.5.2",
61
61
  "@commonpub/ui": "0.8.5",
62
- "@commonpub/protocol": "0.9.9"
62
+ "@commonpub/editor": "0.7.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -446,4 +446,19 @@ function fmtErrorRate(rate: number): string {
446
446
  .cpub-empty { padding: 40px; text-align: center; color: var(--text-dim); background: var(--surface); border: var(--border-width-default) solid var(--border); }
447
447
  .cpub-empty code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 6px; }
448
448
  .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
449
+
450
+ /* Mobile — admin api-keys table is the worst offender at 375px because
451
+ Postgres-style 6-column tables don't shrink. We let the table-wrapper
452
+ scroll horizontally and tighten everything else. The usage drawer's
453
+ stat grid drops to a single column. */
454
+ @media (max-width: 768px) {
455
+ .cpub-key-table { display: block; overflow-x: auto; white-space: nowrap; }
456
+ .cpub-key-table th,
457
+ .cpub-key-table td { padding: 8px 10px; font-size: 11px; }
458
+ .cpub-key-actions { flex-direction: column; gap: 4px; align-items: stretch; }
459
+ .cpub-key-actions .cpub-btn-link { padding: 4px 6px; }
460
+ .cpub-usage-grid { grid-template-columns: 1fr; gap: 12px; }
461
+ .cpub-usage-by-day ul { font-size: 10px; }
462
+ .cpub-key-usage-row td { padding: 12px; }
463
+ }
449
464
  </style>
@@ -526,4 +526,22 @@ async function refederate(): Promise<void> {
526
526
  margin-top: 10px; padding: 10px 14px; font-size: 0.8125rem; font-family: var(--font-mono);
527
527
  background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); color: var(--text);
528
528
  }
529
+
530
+ /* Mobile — admin federation dashboard. The stats grid (4 cells × 100px min)
531
+ plus 24px gap claims the entire 375px viewport with nothing left for
532
+ labels. The form wraps each input + button onto its own line. The
533
+ activity-list rows shrink to legible. */
534
+ @media (max-width: 768px) {
535
+ .cpub-fed-stats { grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 16px; }
536
+ .cpub-fed-stat { padding: 12px 10px; }
537
+ .cpub-fed-stat-val { font-size: 1.25rem; }
538
+ .cpub-fed-tabs { overflow-x: auto; }
539
+ .cpub-fed-tabs button { padding: 8px 12px; white-space: nowrap; }
540
+ .cpub-fed-form { flex-direction: column; align-items: stretch; gap: 8px; }
541
+ .cpub-fed-form .cpub-fed-btn,
542
+ .cpub-fed-form .cpub-fed-input { width: 100%; }
543
+ .cpub-fed-actor { font-size: 11px; }
544
+ .cpub-fed-time { font-size: 9px; }
545
+ .cpub-admin-title { font-size: 1rem; }
546
+ }
529
547
  </style>
@@ -325,4 +325,38 @@ function stripHtml(html: string): string {
325
325
  font-weight: 600;
326
326
  margin-bottom: var(--space-4);
327
327
  }
328
+
329
+ /* Mobile — federated profile view. The header's avatar+info+follow-btn
330
+ row at gap:16px overflows 375px. Stats wrap, actions stack, DM form
331
+ actions stack. Avatar shrinks. */
332
+ @media (max-width: 768px) {
333
+ .cpub-remote-profile { padding: var(--space-4) var(--space-3); }
334
+ .cpub-remote-profile__header {
335
+ flex-direction: column;
336
+ align-items: flex-start;
337
+ gap: var(--space-3);
338
+ }
339
+ .cpub-remote-profile__avatar {
340
+ width: 56px;
341
+ height: 56px;
342
+ }
343
+ .cpub-remote-profile__name { font-size: 1.125rem; }
344
+ .cpub-remote-profile__actions {
345
+ flex-direction: column;
346
+ align-items: stretch;
347
+ width: 100%;
348
+ gap: var(--space-2);
349
+ }
350
+ .cpub-remote-profile__follow-btn,
351
+ .cpub-remote-profile__dm-send,
352
+ .cpub-remote-profile__dm-cancel { width: 100%; }
353
+ .cpub-remote-profile__stats {
354
+ flex-wrap: wrap;
355
+ gap: var(--space-2);
356
+ font-size: var(--font-size-sm);
357
+ }
358
+ .cpub-remote-profile__dm-actions {
359
+ flex-direction: column;
360
+ }
361
+ }
328
362
  </style>
@@ -4,13 +4,15 @@ import { incrementViewCount } from '@commonpub/server';
4
4
  const recentViews = new Map<string, number>();
5
5
  const VIEW_COOLDOWN_MS = 5 * 60 * 1000;
6
6
 
7
- // Periodic cleanup every 2 minutes
7
+ // Periodic cleanup every 2 minutes. `.unref()` so this interval doesn't
8
+ // hold the event loop on shutdown — Nitro's graceful-exit path shouldn't
9
+ // have to wait on a view-dedup timer.
8
10
  setInterval(() => {
9
11
  const now = Date.now();
10
12
  for (const [key, ts] of recentViews) {
11
13
  if (now - ts > VIEW_COOLDOWN_MS) recentViews.delete(key);
12
14
  }
13
- }, 120_000);
15
+ }, 120_000).unref();
14
16
 
15
17
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
16
18
  const db = useDB();
@@ -3,13 +3,18 @@
3
3
  * Proxies and caches remote images for federated content.
4
4
  * Prevents slow cross-origin fetches on content cards.
5
5
  *
6
- * Security: only proxies images from known federation origins
7
- * (federated_content.origin_domain or remote_actors.instance_domain).
6
+ * Security: enforces HTTPS, blocks private/reserved hosts on the input
7
+ * URL AND on every redirect target (via safeFetchBinary), streams the
8
+ * response body with a hard size cap so a chunked-encoding upstream
9
+ * can't OOM us by withholding Content-Length.
8
10
  */
11
+ import { safeFetchBinary } from '@commonpub/server';
12
+
9
13
  export default defineEventHandler(async (event) => {
10
14
  const query = getQuery(event);
11
15
  const url = query.url as string | undefined;
12
- const width = Math.min(parseInt(String(query.w || '800'), 10), 1920);
16
+ // `w` query param is reserved for future image-resize work; not currently
17
+ // used for proxying — the upstream image is returned as-is.
13
18
 
14
19
  if (!url || typeof url !== 'string') {
15
20
  throw createError({ statusCode: 400, statusMessage: 'Missing url parameter' });
@@ -23,66 +28,24 @@ export default defineEventHandler(async (event) => {
23
28
  throw createError({ statusCode: 400, statusMessage: 'Invalid URL' });
24
29
  }
25
30
 
26
- // Only allow HTTPS image URLs
31
+ // Only allow HTTPS image URLs (defense-in-depth on top of safeFetchBinary's
32
+ // own private-URL check; safeFetchBinary allows http for content-import use,
33
+ // but image-proxy is HTTPS-only).
27
34
  if (parsed.protocol !== 'https:') {
28
35
  throw createError({ statusCode: 400, statusMessage: 'Only HTTPS URLs allowed' });
29
36
  }
30
37
 
31
- // Block localhost/private IPs (SSRF prevention)
32
- const hostname = parsed.hostname.toLowerCase();
33
- const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
34
- if (
35
- h === 'localhost' ||
36
- h === 'localhost.localdomain' ||
37
- h === 'metadata.google.internal' ||
38
- h.endsWith('.local') ||
39
- /^127\./.test(h) ||
40
- /^10\./.test(h) ||
41
- /^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
42
- /^192\.168\./.test(h) ||
43
- /^169\.254\./.test(h) ||
44
- /^0\./.test(h) ||
45
- h === '::1' ||
46
- /^f[cd]/i.test(h) ||
47
- /^fe80/i.test(h)
48
- ) {
49
- throw createError({ statusCode: 403, statusMessage: 'Private addresses not allowed' });
50
- }
51
-
52
- // Fetch the remote image
53
- const controller = new AbortController();
54
- const timeout = setTimeout(() => controller.abort(), 15_000);
55
-
56
38
  try {
57
- const response = await fetch(url, {
58
- signal: controller.signal,
59
- headers: {
60
- Accept: 'image/*',
61
- 'User-Agent': 'CommonPub/1.0 (image-proxy)',
62
- },
63
- redirect: 'follow',
39
+ const { buffer, contentType } = await safeFetchBinary(url, {
40
+ accept: 'image/*',
41
+ userAgent: 'CommonPub/1.0 (image-proxy)',
42
+ timeoutMs: 15_000,
64
43
  });
65
44
 
66
- clearTimeout(timeout);
67
-
68
- if (!response.ok) {
69
- throw createError({ statusCode: 502, statusMessage: `Upstream returned ${response.status}` });
70
- }
71
-
72
- const contentType = response.headers.get('content-type') || '';
73
45
  if (!contentType.startsWith('image/')) {
74
46
  throw createError({ statusCode: 502, statusMessage: 'Not an image' });
75
47
  }
76
48
 
77
- // Limit to 10MB
78
- const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
79
- if (contentLength > 10 * 1024 * 1024) {
80
- throw createError({ statusCode: 502, statusMessage: 'Image too large' });
81
- }
82
-
83
- const buffer = Buffer.from(await response.arrayBuffer());
84
-
85
- // Set aggressive cache headers — federated images rarely change
86
49
  setResponseHeaders(event, {
87
50
  'Content-Type': contentType,
88
51
  'Cache-Control': 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400',
@@ -91,8 +54,16 @@ export default defineEventHandler(async (event) => {
91
54
 
92
55
  return buffer;
93
56
  } catch (err: unknown) {
94
- clearTimeout(timeout);
95
57
  if ((err as { statusCode?: number })?.statusCode) throw err;
58
+ const msg = err instanceof Error ? err.message : 'Failed to fetch image';
59
+ // Map known private-URL/redirect rejections to 403 so callers can distinguish
60
+ // them from upstream failures.
61
+ if (msg.includes('private or reserved') || msg.includes('Too many redirects')) {
62
+ throw createError({ statusCode: 403, statusMessage: msg });
63
+ }
64
+ if (msg === 'Response too large') {
65
+ throw createError({ statusCode: 502, statusMessage: 'Image too large' });
66
+ }
96
67
  throw createError({ statusCode: 502, statusMessage: 'Failed to fetch image' });
97
68
  }
98
69
  });
@@ -16,11 +16,37 @@ import { getUnreadCount, getUnreadMessageCount, subscribeSseEvents } from '@comm
16
16
  * the DB. The pub/sub payload is a nudge; we never trust it as the source
17
17
  * of truth.
18
18
  */
19
+
20
+ /**
21
+ * Per-user concurrent SSE connection cap. A normal browser will hold
22
+ * 1–2 connections per origin (HTTP/2 multiplexes); a script could
23
+ * trivially open hundreds, each holding a Redis subscriber + interval
24
+ * timers. Cap at 10 — generous for legitimate clients, hostile for
25
+ * abuse. The map lives at module scope and persists for the Nitro
26
+ * process lifetime.
27
+ *
28
+ * Multi-instance scale-out: each Nitro process has its own map, so
29
+ * the effective cap across N instances is `10 × N`. Acceptable; an
30
+ * attacker can't pin to one instance via L7 routing without a
31
+ * sticky-session config.
32
+ */
33
+ const MAX_CONNECTIONS_PER_USER = 10;
34
+ const userConnections = new Map<string, number>();
35
+
19
36
  export default defineEventHandler(async (event) => {
20
37
  const user = requireAuth(event);
21
38
  const userId = user.id;
22
39
  const db = useDB();
23
40
 
41
+ const current = userConnections.get(userId) ?? 0;
42
+ if (current >= MAX_CONNECTIONS_PER_USER) {
43
+ throw createError({
44
+ statusCode: 429,
45
+ statusMessage: 'Too many concurrent realtime connections',
46
+ });
47
+ }
48
+ userConnections.set(userId, current + 1);
49
+
24
50
  const encoder = new TextEncoder();
25
51
  const stream = new ReadableStream({
26
52
  async start(controller) {
@@ -39,6 +65,9 @@ export default defineEventHandler(async (event) => {
39
65
  unsubscribe = null;
40
66
  }
41
67
  try { controller.close(); } catch { /* already closed */ }
68
+ const next = (userConnections.get(userId) ?? 1) - 1;
69
+ if (next <= 0) userConnections.delete(userId);
70
+ else userConnections.set(userId, next);
42
71
  }
43
72
 
44
73
  async function sendCounts(): Promise<void> {