Merge dev into main (v0.56.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
This commit is contained in:
commit
1916670ecd
24 files changed
+466
-71
No files matched your search
+11
-1
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.56.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Hinter Firmen-Proxys, die weder WebSocket noch Server-Sent Events durchlassen, kommen Änderungen anderer jetzt per Long-Polling live an: Aufgaben und Listen aktualisieren sich ohne Tabwechsel oder Neuladen. Notizen nutzen dieselbe Verbindung. Am Profil steht dann gelb „eingeschränkt“, die Live-Pille zeigt „Live (Long-Polling)“.
|
||||||
|
- Aufgaben zeigen wie Listen und Notizen eine Live-Pille.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kopfzeile von Aufgaben und Listen wie bei den Notizen: links Live-Pille und „Geteilt“, dann ein Trenner, rechts die Knöpfe. Am Handy genauso.
|
||||||
|
|
||||||
## [0.55.0] - 2026-11-02
|
## [0.55.0] - 2026-11-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -819,7 +828,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.56.0...dev
|
||||||
|
[0.56.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...v0.56.0
|
||||||
[0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0
|
[0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0
|
||||||
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
|
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
|
||||||
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||||
|
| POST | `/events/poll` | Long-Polling-Verbindung öffnen (`rooms` optional) → `{pollId}`; nur mit Sitzung |
|
||||||
|
| GET | `/events/poll/{pollId}?wait=25` | Gepufferte Ereignisse abholen, wartet bis zu `wait` s → `{events}`; `404` = abgelaufen |
|
||||||
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||||
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
||||||
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Echtzeit: WebSocket und SSE
|
# Echtzeit: WebSocket, SSE und Long-Polling
|
||||||
|
|
||||||
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
|
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die E
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
|
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
|
||||||
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
|
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
|
||||||
|
| Long-Polling | `POST /api/v1/events/poll` → `{pollId}`, dann `GET /api/v1/events/poll/{pollId}?wait=25` | Antwort `{events: [...]}`, sobald Ereignisse da sind, spätestens nach `wait` s leer; `404` = abgelaufen, neu öffnen |
|
||||||
|
|
||||||
Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE:
|
Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE:
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
|
|||||||
|
|
||||||
Über SSE liefert der Server zuerst `SSE_CONNECTED` mit einer `sseId`; erst dann gilt SSE als verbunden. Bleibt dieses erste Ereignis 5 s nach dem Öffnen aus, puffert ein Proxy den Stream: SSE wird für den Tab abgeschaltet. Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
Über SSE liefert der Server zuerst `SSE_CONNECTED` mit einer `sseId`; erst dann gilt SSE als verbunden. Bleibt dieses erste Ereignis 5 s nach dem Öffnen aus, puffert ein Proxy den Stream: SSE wird für den Tab abgeschaltet. Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
||||||
|
|
||||||
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
Ohne WebSocket und SSE holt der Client die Ereignisse per **Long-Polling**: Er öffnet eine Poll-Verbindung (Räume als `rooms` gleich mitgeben oder später wie bei SSE über `POST /events/rooms` mit der `pollId` als `sseId`), der Server puffert ihre Ereignisse (bis 256), und jede Abfrage wartet bis zu 25 s. Wird eine Poll-Verbindung 2 Minuten nicht abgefragt, schließt der Server sie. Nur mit Sitzung, nicht mit API-Schlüssel. So kommen auch Aufgaben- und Listenänderungen hinter Proxys live an. Die Notiz-Synchronisation nutzt dann dieselbe Verbindung als Weckruf; ohne sie fällt sie auf ihr eigenes Long-Polling (`GET /docs/{id}/changes`) und zuletzt auf eine Abfrage alle 3 s zurück (siehe [modules/collaboration.md](../modules/collaboration.md)).
|
||||||
|
|
||||||
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
|
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
```
|
```
|
||||||
Browser (React SPA, PWA-Manifest)
|
Browser (React SPA, PWA-Manifest)
|
||||||
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
|
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
|
||||||
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
|
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events, dann Long-Polling /api/v1/events/poll
|
||||||
▼
|
▼
|
||||||
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
|
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
|
||||||
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
|
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE, Long-Polling)
|
||||||
│ internal/auth JWT, bcrypt, API-Key-Hash
|
│ internal/auth JWT, bcrypt, API-Key-Hash
|
||||||
▼ internal/config
|
▼ internal/config
|
||||||
SQLite (modernc.org/sqlite, WAL) ── internal/storage
|
SQLite (modernc.org/sqlite, WAL) ── internal/storage
|
||||||
@@ -30,7 +30,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||||
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
|
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE/Long-Polling), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
|
||||||
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
|
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
|
||||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||||
| `internal/ws/` | `hub.go` |
|
| `internal/ws/` | `hub.go` |
|
||||||
@@ -38,7 +38,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `internal/auth/` | Tokens und Passwörter |
|
| `internal/auth/` | Tokens und Passwörter |
|
||||||
| `internal/config/` | Umgebungsvariablen |
|
| `internal/config/` | Umgebungsvariablen |
|
||||||
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
||||||
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE/Long-Polling), `types.ts`, `markdown.tsx`, `theme.ts` |
|
||||||
| `frontend/src/lib/docSync/` | Notiz-Synchronisation im Browser: Portierung von `internal/collab` (`model.ts`, `orderKey.ts`), Vergleich Editor-Liste → Operationen (`diff.ts`), `engine.ts`, IndexedDB (`persist.ts`), HTTP (`transport.ts`) |
|
| `frontend/src/lib/docSync/` | Notiz-Synchronisation im Browser: Portierung von `internal/collab` (`model.ts`, `orderKey.ts`), Vergleich Editor-Liste → Operationen (`diff.ts`), `engine.ts`, IndexedDB (`persist.ts`), HTTP (`transport.ts`) |
|
||||||
| `e2e/` | Browser-Tests mit Playwright gegen einen echten Server (`e2e/run.sh`) |
|
| `e2e/` | Browser-Tests mit Playwright gegen einen echten Server (`e2e/run.sh`) |
|
||||||
| `frontend/src/themes/` | CSS pro Theme |
|
| `frontend/src/themes/` | CSS pro Theme |
|
||||||
@@ -173,7 +173,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
|||||||
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
||||||
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
||||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Fehlt sie länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, ist die Verbindung **eingeschränkt** (z. B. Proxy, der SSE puffert; Notizen laufen dann über Long Polling, andere Änderungen erst beim Zurückkehren in den Tab oder Neuladen), sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
|
- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung, nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. WebSocket oder SSE: **online**. Nur Long-Polling (Proxy, der SSE puffert): **eingeschränkt**, Änderungen kommen leicht verzögert. Fehlt jede Verbindung länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, gilt ebenfalls **eingeschränkt**, sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
|
||||||
|
|
||||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ Daten fließen nur über `/sync` und `/snapshot`. Push-Kanäle melden nur „Not
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1. WebSocket | Verbindungsfehler oder 35 s ohne `PONG` auf das `PING` alle 15 s (WebSocket angenommen, aber nichts kommt durch) | SSE |
|
| 1. WebSocket | Verbindungsfehler oder 35 s ohne `PONG` auf das `PING` alle 15 s (WebSocket angenommen, aber nichts kommt durch) | SSE |
|
||||||
| 2. SSE | Erstes Ereignis kommt nicht innerhalb von 5 s (Proxy puffert); gilt dann für den ganzen Tab | Long-Polling |
|
| 2. SSE | Erstes Ereignis kommt nicht innerhalb von 5 s (Proxy puffert); gilt dann für den ganzen Tab | Long-Polling |
|
||||||
| 3. Long-Polling (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall |
|
| 3. Long-Polling: app-weit (`/events/poll`, `DOC_SEQ` wie bei SSE), sonst eigenes (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall |
|
||||||
| 4. Intervall | – | Abfrage alle 3 s; bei `online` wird Long-Polling erneut versucht |
|
| 4. Intervall | – | Abfrage alle 3 s; bei `online` wird Long-Polling erneut versucht |
|
||||||
|
|
||||||
Zusätzlich, solange Stufe 1–3 läuft: Abgleich alle 7 s, wenn die Engine Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). Gemessen im Browser-Test: 0,2–0,3 s über WebSocket, SSE und Long-Polling, rund 3 s im Intervall.
|
Zusätzlich, solange Stufe 1–3 läuft: Abgleich alle 7 s, wenn die Engine Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). Gemessen im Browser-Test: 0,2–0,3 s über WebSocket, SSE und Long-Polling, rund 3 s im Intervall.
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx)
|
|||||||
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
|
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
|
||||||
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
|
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
|
||||||
|
|
||||||
|
## Kopfzeile: Status | Aktionen
|
||||||
|
|
||||||
|
Komponente für den Verbindungsstatus: [LivePill.tsx](../frontend/src/components/LivePill.tsx) (Notizen zeigen ihren eigenen Sync-Weg im selben Aussehen).
|
||||||
|
|
||||||
|
- Rechts im Kopf von Notizen, Aufgaben und Listen gilt dieselbe Reihenfolge: links die Statusanzeigen (Live-Pille, „Geteilt“), dann ein senkrechter Trenner (`w-px h-5 bg-border`), rechts die Knöpfe (Teilen, Ansicht, Menü). Am Handy genauso, dort steht in der Pille nur „Live“.
|
||||||
|
|
||||||
## Mikrofon in Eingabezeilen
|
## Mikrofon in Eingabezeilen
|
||||||
|
|
||||||
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
|
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.55.0",
|
"version": "0.56.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.55.0",
|
"version": "0.56.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.55.0",
|
"version": "0.56.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const CONNECTION_STATUS = {
|
|||||||
limited: {
|
limited: {
|
||||||
label: 'eingeschränkt',
|
label: 'eingeschränkt',
|
||||||
dot: 'bg-status-warn',
|
dot: 'bg-status-warn',
|
||||||
title: 'Eingeschränkt – der Server ist erreichbar, aber keine Live-Verbindung (z. B. Firmen-Proxy); Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab oder nach dem Neuladen',
|
title: 'Eingeschränkt – keine direkte Live-Verbindung (z. B. Firmen-Proxy); Änderungen anderer kommen über Long Polling, etwas verzögert',
|
||||||
},
|
},
|
||||||
offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
|
offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { onRealtimeStatus, RealtimeTransport } from '../lib/api';
|
||||||
|
|
||||||
|
// Connection pill in the look of the notes' header (Doku/ui-patterns.md): whether changes of others
|
||||||
|
// arrive live, and how. Phones show the short label only.
|
||||||
|
export const LivePill: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<{ connected: boolean; transport: RealtimeTransport }>({
|
||||||
|
connected: false,
|
||||||
|
transport: 'none',
|
||||||
|
});
|
||||||
|
useEffect(() => onRealtimeStatus((connected, transport) => setStatus({ connected, transport })), []);
|
||||||
|
|
||||||
|
const { connected, transport } = status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted flex-shrink-0"
|
||||||
|
title={
|
||||||
|
!connected
|
||||||
|
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
||||||
|
: transport === 'sse'
|
||||||
|
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||||
|
: transport === 'poll'
|
||||||
|
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||||
|
: 'Live-Verbindung über WebSocket'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
||||||
|
{!connected ? (
|
||||||
|
'Offline'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Live
|
||||||
|
{transport === 'poll' && <span className="hidden sm:inline"> (Long-Polling)</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
CloudOff,
|
CloudOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api';
|
||||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
|
import { LivePill } from '../../components/LivePill';
|
||||||
import { OriginBadge } from '../../components/OriginBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
@@ -110,8 +111,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||||
const [editingQtyValue, setEditingQtyValue] = useState('');
|
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
|
||||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||||
const [newListTitle, setNewListTitle] = useState('');
|
const [newListTitle, setNewListTitle] = useState('');
|
||||||
const [newListIcon, setNewListIcon] = useState('🛒');
|
const [newListIcon, setNewListIcon] = useState('🛒');
|
||||||
@@ -222,10 +221,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
const conn = createRealtimeConnection({
|
const conn = createRealtimeConnection({
|
||||||
listId: activeList.id,
|
listId: activeList.id,
|
||||||
onStatusChange: (status) => {
|
|
||||||
setWsConnected(status.connected);
|
|
||||||
setRealtimeTransport(status.transport);
|
|
||||||
},
|
|
||||||
onMessage: (msg: WSMessage) => {
|
onMessage: (msg: WSMessage) => {
|
||||||
const msgListId = msg.listId || (msg as any).list_id;
|
const msgListId = msg.listId || (msg as any).list_id;
|
||||||
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
||||||
@@ -1032,22 +1027,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
// Connection pill in the same look as the notes' header
|
|
||||||
const livePill = (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
|
||||||
title={
|
|
||||||
!wsConnected
|
|
||||||
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
|
||||||
: realtimeTransport === 'sse'
|
|
||||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
|
||||||
: 'Live-Verbindung über WebSocket'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
|
||||||
{wsConnected ? 'Live' : 'Offline'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
@@ -1117,8 +1096,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
|
{/* Right Header Section like the notes: status (live, shared) | actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LivePill />
|
||||||
{activeList && (() => {
|
{activeList && (() => {
|
||||||
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
|
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
|
||||||
const sharesCount = activeList.shares?.length || 0;
|
const sharesCount = activeList.shares?.length || 0;
|
||||||
@@ -1151,6 +1131,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openShareModal}
|
onClick={openShareModal}
|
||||||
@@ -1162,8 +1143,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{livePill}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1193,17 +1172,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<LivePill />
|
||||||
{activeList && (
|
{activeList && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<span className="w-px h-5 bg-border" aria-hidden />
|
||||||
onClick={openShareModal}
|
<button
|
||||||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
type="button"
|
||||||
title="Liste teilen"
|
onClick={openShareModal}
|
||||||
>
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
<Users className="w-4 h-4 text-accent" />
|
title="Liste teilen"
|
||||||
</button>
|
>
|
||||||
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{livePill}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma
|
|||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
|
import { LivePill } from '../../components/LivePill';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
@@ -1872,8 +1873,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
|
{/* Right Header Section like the notes: status (live, shared) | actions (share, view switcher) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LivePill />
|
||||||
{currentSelectedList && (() => {
|
{currentSelectedList && (() => {
|
||||||
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
||||||
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
||||||
@@ -1910,6 +1912,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
@@ -1921,6 +1924,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{!currentSelectedList && <span className="w-px h-5 bg-border mx-0.5" aria-hidden />}
|
||||||
|
|
||||||
{/* View Switcher: List vs Kanban */}
|
{/* View Switcher: List vs Kanban */}
|
||||||
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
||||||
@@ -1973,16 +1977,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
: `Papierkorb (${taskTrash.length})`}
|
: `Papierkorb (${taskTrash.length})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentSelectedList && (
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
<button
|
<LivePill />
|
||||||
type="button"
|
{currentSelectedList && (
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
<>
|
||||||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
|
<span className="w-px h-5 bg-border" aria-hidden />
|
||||||
title="Aufgabenliste teilen"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Users className="w-4 h-4 text-accent" />
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
</button>
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
)}
|
title="Aufgabenliste teilen"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectedListReadOnly && (
|
{isSelectedListReadOnly && (
|
||||||
|
|||||||
@@ -137,4 +137,36 @@ describe('realtime fallback', () => {
|
|||||||
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
|
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
|
||||||
api.disconnectWebSocket();
|
api.disconnectWebSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('behind such a proxy, events come by long polling', async () => {
|
||||||
|
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||||
|
sessionStorage.setItem('mindsift_sse_buffered', 'true');
|
||||||
|
let polls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
if (url === '/api/v1/events/poll') return new Response('{"pollId":"poll_1"}', { status: 200 });
|
||||||
|
if (url.startsWith('/api/v1/events/poll/poll_1')) {
|
||||||
|
polls++;
|
||||||
|
if (polls === 1) return new Response('{"events":[{"event":"TASK_UPDATED","payload":{"id":"t1"}}]}', { status: 200 });
|
||||||
|
// Later polls wait like the server does, until the client gives up on them
|
||||||
|
return new Promise<Response>((_, reject) => init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))));
|
||||||
|
}
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const api = await loadApi();
|
||||||
|
const seen: string[] = [];
|
||||||
|
api.onWebSocketMessage((m) => seen.push(m.event));
|
||||||
|
await api.connectWebSocket();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(FakeEventSource.instances).toHaveLength(0);
|
||||||
|
expect(api.isRealtimeConnected()).toBe(true);
|
||||||
|
expect(api.getRealtimeTransport()).toBe('poll');
|
||||||
|
expect(seen).toEqual(['TASK_UPDATED']);
|
||||||
|
expect(polls).toBe(2); // the next poll is waiting
|
||||||
|
api.disconnectWebSocket();
|
||||||
|
expect(api.isRealtimeConnected()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
let statusListener: ((connected: boolean) => void) | null = null;
|
let statusListener: ((connected: boolean, transport?: string) => void) | null = null;
|
||||||
let currentConnected = false;
|
let currentConnected = false;
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
onRealtimeStatus: (cb: (connected: boolean, transport?: string) => void) => {
|
||||||
statusListener = cb;
|
statusListener = cb;
|
||||||
cb(currentConnected);
|
cb(currentConnected);
|
||||||
return () => (statusListener = null);
|
return () => (statusListener = null);
|
||||||
@@ -86,6 +86,15 @@ describe('useConnectionState', () => {
|
|||||||
expect(fetchMock.mock.calls.length).toBe(calls);
|
expect(fetchMock.mock.calls.length).toBe(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('is limited while changes only come by long polling', async () => {
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
act(() => statusListener?.(true, 'poll'));
|
||||||
|
expect(result.current).toBe('limited');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
act(() => statusListener?.(true, 'sse'));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
it('repeated failed reconnects do not postpone the check', async () => {
|
it('repeated failed reconnects do not postpone the check', async () => {
|
||||||
currentConnected = true;
|
currentConnected = true;
|
||||||
const { result } = renderHook(() => useConnectionState());
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
|||||||
+77
-2
@@ -1464,7 +1464,8 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
// poll: long polling, when a proxy lets neither WebSocket nor event streams through
|
||||||
|
export type RealtimeTransport = 'ws' | 'sse' | 'poll' | 'none';
|
||||||
|
|
||||||
let activeTransport: RealtimeTransport = 'none';
|
let activeTransport: RealtimeTransport = 'none';
|
||||||
let activeGlobalWS: WebSocket | null = null;
|
let activeGlobalWS: WebSocket | null = null;
|
||||||
@@ -1599,7 +1600,7 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
|
|||||||
clearTimeout(sseFirstEventWatchdog);
|
clearTimeout(sseFirstEventWatchdog);
|
||||||
|
|
||||||
if (isSseBufferedByProxy) {
|
if (isSseBufferedByProxy) {
|
||||||
notifyRealtimeStatusChange(false, 'none');
|
void connectPoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1640,6 +1641,7 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
if (activeGlobalSSE === es) activeGlobalSSE = null;
|
if (activeGlobalSSE === es) activeGlobalSSE = null;
|
||||||
notifyRealtimeStatusChange(false, 'none');
|
notifyRealtimeStatusChange(false, 'none');
|
||||||
|
void connectPoll();
|
||||||
}, SSE_FIRST_EVENT_TIMEOUT_MS);
|
}, SSE_FIRST_EVENT_TIMEOUT_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1694,6 +1696,78 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long polling: the server buffers the events of a poll connection; each request waits until there are
|
||||||
|
// some (at most POLL_WAIT_S). Room joins use the poll id like an SSE id (activeSSEId).
|
||||||
|
const POLL_WAIT_S = 25; // below typical proxy timeouts, like the note sync's long polling
|
||||||
|
let pollRunning = false;
|
||||||
|
let pollAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
function dispatchRealtimeMessage(msg: WSMessage) {
|
||||||
|
wsListeners.forEach((fn) => {
|
||||||
|
try {
|
||||||
|
fn(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Realtime listener error', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectPoll(): Promise<void> {
|
||||||
|
if (pollRunning || manualDisconnect || !getToken()) return;
|
||||||
|
pollRunning = true;
|
||||||
|
let pollId: string | null = null;
|
||||||
|
let failures = 0;
|
||||||
|
try {
|
||||||
|
while (!manualDisconnect && getToken()) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
pollAbort = ctrl;
|
||||||
|
try {
|
||||||
|
const fresh = !pollId;
|
||||||
|
if (!pollId) {
|
||||||
|
const opened = await request<{ pollId?: string }>('/api/v1/events/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rooms: Array.from(globalSubscribedRooms) }),
|
||||||
|
});
|
||||||
|
if (!opened?.pollId) throw new Error('no poll id');
|
||||||
|
pollId = opened.pollId;
|
||||||
|
activeSSEId = pollId;
|
||||||
|
notifyRealtimeStatusChange(true, 'poll');
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await fetch(`/api/v1/events/poll/${encodeURIComponent(pollId)}?wait=${POLL_WAIT_S}`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (res.status === 404 && !fresh) {
|
||||||
|
// Expired (e.g. device asleep, server restart): open a new one, rooms go along
|
||||||
|
pollId = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const events = ((await res.json()) as { events?: WSMessage[] }).events || [];
|
||||||
|
failures = 0;
|
||||||
|
events.forEach(dispatchRealtimeMessage);
|
||||||
|
// An empty answer long before the wait ran out (proxy answering on its own): no tight loop
|
||||||
|
if (events.length === 0 && Date.now() - started < 1000) await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
} catch {
|
||||||
|
if (ctrl.signal.aborted || manualDisconnect) break;
|
||||||
|
pollId = null;
|
||||||
|
if (activeTransport === 'poll') {
|
||||||
|
activeSSEId = null;
|
||||||
|
notifyRealtimeStatusChange(false, 'none');
|
||||||
|
}
|
||||||
|
failures++;
|
||||||
|
await new Promise((r) => setTimeout(r, Math.min(30000, 2000 * failures)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pollRunning = false;
|
||||||
|
pollAbort = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let wsTicketPending = false;
|
let wsTicketPending = false;
|
||||||
|
|
||||||
export async function connectWebSocket(): Promise<void> {
|
export async function connectWebSocket(): Promise<void> {
|
||||||
@@ -1845,6 +1919,7 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectWebSocket(): void {
|
export function disconnectWebSocket(): void {
|
||||||
|
pollAbort?.abort();
|
||||||
clearInterval(wsHeartbeatTimer);
|
clearInterval(wsHeartbeatTimer);
|
||||||
clearTimeout(sseFirstEventWatchdog);
|
clearTimeout(sseFirstEventWatchdog);
|
||||||
manualDisconnect = true;
|
manualDisconnect = true;
|
||||||
|
|||||||
@@ -641,6 +641,8 @@ export class DocSyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private channel(): SyncChannel {
|
private channel(): SyncChannel {
|
||||||
|
// The app-wide long polling brings the notifications: same channel as the note's own long polling
|
||||||
|
if (this.pushKind === 'poll') return 'longpoll';
|
||||||
if (this.pushKind !== 'none') return this.pushKind;
|
if (this.pushKind !== 'none') return this.pushKind;
|
||||||
if (this.longPollRunning && !this.longPollBroken) return 'longpoll';
|
if (this.longPollRunning && !this.longPollBroken) return 'longpoll';
|
||||||
return 'interval';
|
return 'interval';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Push notifications for note sync: the server sends DOC_SEQ into the note's room whenever the note, its
|
// Push notifications for note sync: the server sends DOC_SEQ into the note's room whenever the note, its
|
||||||
// leases or its presence change. Delivery uses the app-wide realtime connection (WebSocket, else SSE).
|
// leases or its presence change. Delivery uses the app-wide realtime connection (WebSocket, else SSE, else
|
||||||
|
// long polling).
|
||||||
import { createRealtimeConnection, getRealtimeTransport, isRealtimeConnected } from '../api';
|
import { createRealtimeConnection, getRealtimeTransport, isRealtimeConnected } from '../api';
|
||||||
|
|
||||||
export type PushKind = 'ws' | 'sse' | 'none';
|
export type PushKind = 'ws' | 'sse' | 'poll' | 'none';
|
||||||
|
|
||||||
export interface PushChannel {
|
export interface PushChannel {
|
||||||
/** Calls onPoke for changes of docId and onStatus whenever the channel goes up or down. */
|
/** Calls onPoke for changes of docId and onStatus whenever the channel goes up or down. */
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export const SERVER_OFFLINE_GRACE_MS = 4000;
|
|||||||
// How often the server is asked while the realtime connection is missing
|
// How often the server is asked while the realtime connection is missing
|
||||||
export const SERVER_PROBE_INTERVAL_MS = 15000;
|
export const SERVER_PROBE_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
// online: realtime connection (WebSocket/SSE) is up; limited: the server answers plain requests, but no
|
// online: realtime connection (WebSocket/SSE) is up; limited: changes only come by long polling (a proxy
|
||||||
// realtime connection gets through (e.g. a proxy that buffers event streams; notes fall back to long
|
// that buffers event streams), or not live at all while the server still answers plain requests;
|
||||||
// polling); offline: no server at all
|
// offline: no server at all
|
||||||
export type ConnectionState = 'online' | 'limited' | 'offline';
|
export type ConnectionState = 'online' | 'limited' | 'offline';
|
||||||
|
|
||||||
async function serverReachable(): Promise<boolean> {
|
async function serverReachable(): Promise<boolean> {
|
||||||
@@ -29,6 +29,7 @@ export function useConnectionState(): ConnectionState {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
let viaPoll = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
const stopProbing = () => {
|
const stopProbing = () => {
|
||||||
@@ -45,7 +46,7 @@ export function useConnectionState(): ConnectionState {
|
|||||||
const update = () => {
|
const update = () => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
stopProbing();
|
stopProbing();
|
||||||
setState('online');
|
setState(viaPoll ? 'limited' : 'online');
|
||||||
} else if (navigator.onLine === false) {
|
} else if (navigator.onLine === false) {
|
||||||
stopProbing();
|
stopProbing();
|
||||||
setState('offline');
|
setState('offline');
|
||||||
@@ -53,8 +54,9 @@ export function useConnectionState(): ConnectionState {
|
|||||||
timer = window.setTimeout(probe, SERVER_OFFLINE_GRACE_MS);
|
timer = window.setTimeout(probe, SERVER_OFFLINE_GRACE_MS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const unsubscribe = onRealtimeStatus((isConnected) => {
|
const unsubscribe = onRealtimeStatus((isConnected, transport) => {
|
||||||
connected = isConnected;
|
connected = isConnected;
|
||||||
|
viaPoll = transport === 'poll';
|
||||||
update();
|
update();
|
||||||
});
|
});
|
||||||
window.addEventListener('online', update);
|
window.addEventListener('online', update);
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
@@ -125,3 +127,29 @@ func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOpenPoll starts a long-poll connection (third transport after WebSocket and SSE, for proxies that
|
||||||
|
// let neither through). Rooms can be given right away or joined later via /events/rooms with the id.
|
||||||
|
func (s *Server) handleOpenPoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Rooms []string `json:"rooms"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)).Decode(&req)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"pollId": s.wsHub.OpenPoll(auth.GetUserID(r.Context()), req.Rooms)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePoll returns the events of a poll connection as soon as there are some, at the latest after
|
||||||
|
// `wait` seconds (empty list). 404: the connection expired, the client opens a new one.
|
||||||
|
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wait := longPollMax
|
||||||
|
if sec, err := strconv.Atoi(r.URL.Query().Get("wait")); err == nil && sec >= 0 && time.Duration(sec)*time.Second < wait {
|
||||||
|
wait = time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(wait + 10*time.Second))
|
||||||
|
events, ok := s.wsHub.Poll(r.Context(), chi.URLParam(r, "id"), auth.GetUserID(r.Context()), wait)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "Verbindung abgelaufen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"events": events})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -354,3 +355,62 @@ func TestLogoutEndsLiveStreams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long polling as the third transport: events of the user and of joined rooms arrive with the next
|
||||||
|
// poll, a poll waits until something happens, and nobody else can read the connection.
|
||||||
|
func TestLongPollDeliversEvents(t *testing.T) {
|
||||||
|
srv, store, ts, owner, token := startRealtimeServer(t)
|
||||||
|
_, strangerToken := newTestUser(t, store, "stranger@example.com")
|
||||||
|
|
||||||
|
resp := doJSON(t, ts, http.MethodPost, "/api/v1/events/poll", token, map[string]interface{}{})
|
||||||
|
var opened struct {
|
||||||
|
PollID string `json:"pollId"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&opened)
|
||||||
|
if resp.StatusCode != http.StatusOK || opened.PollID == "" {
|
||||||
|
t.Fatalf("open poll: %d %+v", resp.StatusCode, opened)
|
||||||
|
}
|
||||||
|
poll := func(tok string, wait int) (int, []sseMsg) {
|
||||||
|
resp := doJSON(t, ts, http.MethodGet, "/api/v1/events/poll/"+opened.PollID+"?wait="+strconv.Itoa(wait), tok, nil)
|
||||||
|
var out struct {
|
||||||
|
Events []sseMsg `json:"events"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return resp.StatusCode, out.Events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing happens: the poll answers empty after the wait
|
||||||
|
if code, events := poll(token, 0); code != http.StatusOK || len(events) != 0 {
|
||||||
|
t.Fatalf("empty poll: %d %v", code, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A poll that is waiting returns as soon as an event comes
|
||||||
|
go func() {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
srv.wsHub.BroadcastToUser(owner.ID, "TASK_UPDATED", map[string]string{"id": "t1"})
|
||||||
|
}()
|
||||||
|
start := time.Now()
|
||||||
|
code, events := poll(token, 5)
|
||||||
|
if code != http.StatusOK || len(events) != 1 || events[0].Event != "TASK_UPDATED" || time.Since(start) > 3*time.Second {
|
||||||
|
t.Fatalf("waiting poll: %d %v after %s", code, events, time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List rooms are joined like with SSE; events between polls wait in the buffer
|
||||||
|
list, _ := store.CreateList(owner.ID, "Einkauf", "🛒", "shopping")
|
||||||
|
if r := doJSON(t, ts, http.MethodPost, "/api/v1/events/rooms", token, map[string]string{"sseId": opened.PollID, "room": list.ID}); r.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("join list room: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
srv.wsHub.BroadcastListEvent(list.ID, "ITEM_ADDED", map[string]string{"id": "i1"})
|
||||||
|
srv.wsHub.BroadcastListEvent(list.ID, "ITEM_UPDATED", map[string]string{"id": "i1"})
|
||||||
|
if code, events = poll(token, 5); code != http.StatusOK || len(events) != 2 || events[1].Event != "ITEM_UPDATED" {
|
||||||
|
t.Fatalf("list events: %d %v", code, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, _ = poll(strangerToken, 0); code != http.StatusNotFound {
|
||||||
|
t.Errorf("someone else's poll connection: want 404, got %d", code)
|
||||||
|
}
|
||||||
|
resp = doJSON(t, ts, http.MethodGet, "/api/v1/events/poll/poll_unknown?wait=0", token, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("unknown poll connection: want 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Get("/api/v1/auth/me", s.handleMe)
|
r.Get("/api/v1/auth/me", s.handleMe)
|
||||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||||
|
r.Post("/api/v1/events/poll", s.handleOpenPoll)
|
||||||
|
r.Get("/api/v1/events/poll/{id}", s.handlePoll)
|
||||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||||
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe)
|
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe)
|
||||||
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
|
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -104,6 +105,7 @@ type Hub struct {
|
|||||||
sseClients map[*SSEClient]bool
|
sseClients map[*SSEClient]bool
|
||||||
sseUserRooms map[string]map[*SSEClient]bool
|
sseUserRooms map[string]map[*SSEClient]bool
|
||||||
sseRooms map[string]map[*SSEClient]bool
|
sseRooms map[string]map[*SSEClient]bool
|
||||||
|
polls map[string]*pollConn
|
||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
// authorizeRoom decides whether a user may receive events of a room; without it every join is denied
|
// authorizeRoom decides whether a user may receive events of a room; without it every join is denied
|
||||||
@@ -136,6 +138,112 @@ func NewHub() *Hub {
|
|||||||
sseClients: make(map[*SSEClient]bool),
|
sseClients: make(map[*SSEClient]bool),
|
||||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||||
|
polls: make(map[string]*pollConn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long polling, for networks that let neither WebSocket nor event streams through (proxies that buffer
|
||||||
|
// SSE): a poll connection is an SSEClient without a stream. Its events wait in the send buffer until the
|
||||||
|
// next poll picks them up; room joins work like for SSE (JoinSSERoomByID with its id).
|
||||||
|
|
||||||
|
// PollIdleTimeout: a poll connection nobody asked for this long is closed (tab closed, device asleep).
|
||||||
|
const PollIdleTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
type pollConn struct {
|
||||||
|
client *SSEClient
|
||||||
|
lastPoll time.Time
|
||||||
|
waiting int // polls running right now
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPoll registers a poll connection for the user and returns its id.
|
||||||
|
func (h *Hub) OpenPoll(userID string, rooms []string) string {
|
||||||
|
h.sweepPolls()
|
||||||
|
c := &SSEClient{
|
||||||
|
id: domain.NewID("poll"),
|
||||||
|
userID: userID,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
subscribedRooms: make(map[string]bool),
|
||||||
|
}
|
||||||
|
for _, room := range rooms {
|
||||||
|
if room = strings.TrimSpace(room); room != "" {
|
||||||
|
c.subscribedRooms[room] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.RegisterSSE(c)
|
||||||
|
h.mu.Lock()
|
||||||
|
h.polls[c.id] = &pollConn{client: c, lastPoll: time.Now()}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll waits up to wait for events of the user's poll connection id and returns everything buffered.
|
||||||
|
// ok is false when the connection is unknown or closed (expired, server restart): open a new one.
|
||||||
|
func (h *Hub) Poll(ctx context.Context, id, userID string, wait time.Duration) (events []json.RawMessage, ok bool) {
|
||||||
|
h.sweepPolls()
|
||||||
|
h.mu.Lock()
|
||||||
|
p := h.polls[id]
|
||||||
|
if p == nil || p.client.userID != userID {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
p.waiting++
|
||||||
|
h.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
p.waiting--
|
||||||
|
p.lastPoll = time.Now()
|
||||||
|
h.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// What is already waiting goes out right away; otherwise wait for the first event
|
||||||
|
events, open := drainBuffered(p.client.send)
|
||||||
|
if len(events) > 0 || !open {
|
||||||
|
return events, open || len(events) > 0
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case msg, open := <-p.client.send:
|
||||||
|
if !open {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rest, _ := drainBuffered(p.client.send) // whatever came along goes in the same answer
|
||||||
|
return append([]json.RawMessage{msg}, rest...), true
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
return events, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainBuffered takes everything waiting in ch without blocking; open is false once ch is closed.
|
||||||
|
func drainBuffered(ch chan []byte) (events []json.RawMessage, open bool) {
|
||||||
|
events = []json.RawMessage{}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return events, false
|
||||||
|
}
|
||||||
|
events = append(events, msg)
|
||||||
|
default:
|
||||||
|
return events, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepPolls closes poll connections that were not asked for within PollIdleTimeout.
|
||||||
|
func (h *Hub) sweepPolls() {
|
||||||
|
h.mu.Lock()
|
||||||
|
var stale []*SSEClient
|
||||||
|
for id, p := range h.polls {
|
||||||
|
if p.waiting == 0 && time.Since(p.lastPoll) > PollIdleTimeout {
|
||||||
|
stale = append(stale, p.client)
|
||||||
|
delete(h.polls, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, c := range stale {
|
||||||
|
h.UnregisterSSE(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -90,3 +91,29 @@ func TestRoomJoinsRequireAuthorization(t *testing.T) {
|
|||||||
t.Fatal("joining with another user's sseId must fail")
|
t.Fatal("joining with another user's sseId must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll connections nobody asks for any more (tab closed) are closed and stop receiving events.
|
||||||
|
func TestIdlePollConnectionsAreClosed(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
id := hub.OpenPoll("user-1", nil)
|
||||||
|
hub.BroadcastToUser("user-1", "TEST_EVENT", nil)
|
||||||
|
if events, ok := hub.Poll(context.Background(), id, "user-1", 0); !ok || len(events) != 1 {
|
||||||
|
t.Fatalf("fresh poll connection: ok=%v events=%d", ok, len(events))
|
||||||
|
}
|
||||||
|
if _, ok := hub.Poll(context.Background(), id, "user-2", 0); ok {
|
||||||
|
t.Fatal("another user must not read the connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.polls[id].lastPoll = time.Now().Add(-PollIdleTimeout - time.Second)
|
||||||
|
hub.mu.Unlock()
|
||||||
|
hub.OpenPoll("user-1", nil) // any poll activity sweeps
|
||||||
|
if _, ok := hub.Poll(context.Background(), id, "user-1", 0); ok {
|
||||||
|
t.Fatal("idle poll connection should be gone")
|
||||||
|
}
|
||||||
|
hub.mu.RLock()
|
||||||
|
defer hub.mu.RUnlock()
|
||||||
|
if len(hub.sseUserRooms["user-1"]) != 1 {
|
||||||
|
t.Fatalf("only the new connection should stay registered, got %d", len(hub.sseUserRooms["user-1"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user