diff --git a/AGENTS.md b/AGENTS.md index cdcc0bc14..49eeb1c1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ This is a Typescript project with a monorepo structure. ### Packages Overview -- `@compass/backend` - Express.js REST API with MongoDB, Google Calendar sync, WebSocket support +- `@compass/backend` - Express.js REST API with MongoDB, Google Calendar sync, Server-Sent Events (SSE) - `@compass/web` - React/TypeScript frontend with Redux, styled-components, webpack bundling - `@compass/core` - Shared utilities, types, and business logic - `@compass/scripts` - CLI tools for building, database operations, user management diff --git a/docs/README.md b/docs/README.md index 6cacf655b..9b1dc2d18 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ Start with [AGENTS.md](../AGENTS.md) for repo rules, commands, and conventions. - Auth or session behavior: [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md), [Password Auth Flow](./features/password-auth-flow.md), - [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) + [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - Event shape or recurrence behavior: [Event And Task Domain Model](./architecture/event-and-task-domain-model.md), [Recurrence Handling](./features/recurring-events-handling.md) @@ -30,7 +30,7 @@ Start with [AGENTS.md](../AGENTS.md) for repo rules, commands, and conventions. ## Runtime Flows - [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md) -- [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) +- [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - [Password Auth Flow](./features/password-auth-flow.md) ## Architecture And Domain @@ -52,6 +52,6 @@ Start with [AGENTS.md](../AGENTS.md) for repo rules, commands, and conventions. ## Feature Deep Dives - [Password Auth Flow](./features/password-auth-flow.md) -- [Google Sync And Websocket Flow](./features/google-sync-and-websocket-flow.md) +- [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - [Recurrence Handling](./features/recurring-events-handling.md) - [Offline Storage And Migrations](./features/offline-storage-and-migrations.md) diff --git a/docs/architecture/event-and-task-domain-model.md b/docs/architecture/event-and-task-domain-model.md index 48927959c..75757bcde 100644 --- a/docs/architecture/event-and-task-domain-model.md +++ b/docs/architecture/event-and-task-domain-model.md @@ -85,7 +85,7 @@ It affects: - query behavior - sync transitions -- websocket notification type +- SSE notification type - provider selection when mapping events For someday events, Compass often behaves as the provider of record instead of Google. diff --git a/docs/architecture/glossary.md b/docs/architecture/glossary.md index 179facb00..db2d9d127 100644 --- a/docs/architecture/glossary.md +++ b/docs/architecture/glossary.md @@ -51,7 +51,7 @@ Definition of terms used in the source code and documentation. **Duck Pattern**: A Redux pattern that co-locates actions, reducers, and selectors in a single file (or directory) for a feature domain. -**WebSocket**: A communication protocol used for real-time bidirectional communication between the frontend and backend. Used to push updates when events change. +**Server-Sent Events (SSE)**: An HTTP-based mechanism where the server pushes named events over a long-lived response. Compass uses one `EventSource` per tab to `GET /api/events/stream` for calendar sync and metadata updates. **Supertokens**: The authentication library used by Compass to manage user sessions, access tokens, and refresh tokens. diff --git a/docs/architecture/repo-architecture.md b/docs/architecture/repo-architecture.md index dc92a0f9d..b72df4999 100644 --- a/docs/architecture/repo-architecture.md +++ b/docs/architecture/repo-architecture.md @@ -12,7 +12,7 @@ The React frontend. It owns: - auth/session-aware UI - event and task interactions - local offline storage -- websocket listeners +- SSE listeners (`EventSource`) Key entrypoints: @@ -29,13 +29,13 @@ The Express + MongoDB backend. It owns: - Supertokens session enforcement - event CRUD and recurrence processing - Google Calendar sync -- websocket fanout +- SSE fanout Key entrypoints: - `packages/backend/src/app.ts` - `packages/backend/src/servers/express/express.server.ts` -- `packages/backend/src/servers/websocket/websocket.server.ts` +- `packages/backend/src/servers/sse/sse.server.ts` ### `packages/core` @@ -51,7 +51,7 @@ High-value files: - `packages/core/src/types/event.types.ts` - `packages/core/src/types/type.utils.ts` - `packages/core/src/constants/core.constants.ts` -- `packages/core/src/constants/websocket.constants.ts` +- `packages/core/src/constants/sse.constants.ts` ### `packages/scripts` @@ -73,14 +73,14 @@ The web package imports shared event/task/date concepts from `core` and should n ### Backend -> Core -The backend uses `core` for shared validation, event categories, recurrence scopes, constants, and websocket event names. +The backend uses `core` for shared validation, event categories, recurrence scopes, constants, and SSE event names. ### Web <-> Backend The web talks to the backend through: - HTTP APIs -- websocket events +- SSE events - shared domain types from `core` ## Startup Paths @@ -102,7 +102,7 @@ The web talks to the backend through: 1. create Express app 2. create HTTP server -3. initialize websocket server on the HTTP server +3. register HTTP routes (SSE is opened per authenticated `GET /api/events/stream`) 4. start Mongo 5. listen on the configured port 6. optionally connect ngrok @@ -138,5 +138,5 @@ The repo prefers: - New event field: `core` schema, backend parsing/persistence, web editors/selectors/tests - New backend endpoint: backend route/controller/service plus maybe shared type in `core` -- New websocket event: `core` constants/types, backend server emitter, web socket hook consumer +- New SSE event: `core` constants/types, backend `sse.server` / `publish`, web SSE hook consumer - New local persistence behavior: web storage adapter, migration runner, tests diff --git a/docs/backend/README.md b/docs/backend/README.md index 9a3221ca5..fa29f6028 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -1,6 +1,6 @@ # Compass Backend -Backend service for auth/session management, event persistence, Google sync, and websocket notifications. +Backend service for auth/session management, event persistence, Google sync, and SSE notifications. ## Intent @@ -22,7 +22,7 @@ When changing sync or auth logic: - Shared error handling: - `packages/backend/src/common/errors/handlers/error.express.handler.ts` - Realtime notifications: - - `packages/backend/src/servers/websocket/websocket.server.ts` + - `packages/backend/src/servers/sse/sse.server.ts` ## Primary Backend Workflows @@ -78,4 +78,4 @@ Observed outcomes include: - [Backend Request Flow](./backend-request-flow.md) - [Compass API Documentation](./api-documentation.md) - [Backend Error Handling](./backend-error-handling.md) -- [Google Sync And Websocket Flow](../features/google-sync-and-websocket-flow.md) +- [Google Sync And SSE Flow](../features/google-sync-and-sse-flow.md) diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md index 911978fe8..277e983f8 100644 --- a/docs/backend/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -315,7 +315,7 @@ Authenticated user trigger for full import restart: - middleware: `verifySession()` + `requireGoogleConnectionSession` - response: `204 No Content` -- import runs asynchronously; progress is surfaced via websocket `IMPORT_GCAL_START` / `IMPORT_GCAL_END` +- import runs asynchronously; progress is surfaced via SSE `IMPORT_GCAL_START` / `IMPORT_GCAL_END` - body schema (`ImportGCalRequestSchema` in `packages/backend/src/sync/sync.types.ts`): - optional `force: boolean` - behavior: @@ -325,7 +325,7 @@ Authenticated user trigger for full import restart: ### /api/event-change-demo - `POST /api/event-change-demo` -- debug helper route used to dispatch event-change notifications to a configured demo socket user +- debug helper route used to dispatch event-change notifications to the user id in env `SSE_DEBUG_USER` ### ${SYNC_DEBUG}/import-incremental/:userId @@ -483,7 +483,7 @@ Standard error response format: } ``` -Google revocation is a first-class error contract used by API and websocket flows: +Google revocation is a first-class error contract used by API and SSE flows: ```json { diff --git a/docs/backend/backend-error-handling.md b/docs/backend/backend-error-handling.md index e1b9bb378..5e04db4c6 100644 --- a/docs/backend/backend-error-handling.md +++ b/docs/backend/backend-error-handling.md @@ -54,7 +54,7 @@ Internal details such as stack traces and operational flags stay server-side. - Keep `code` stable and machine-oriented. Prefer values like `GOOGLE_ACCOUNT_ALREADY_CONNECTED`. - Put technical detail in logs, not in the client payload. - Prefer reusing existing feature error metadata before inventing new names. -- If the error should trigger special auth/sync behavior, verify both API handling and websocket side effects. +- If the error should trigger special auth/sync behavior, verify both API handling and SSE side effects. ## Shared Frontend-Backend Error Pattern diff --git a/docs/backend/backend-request-flow.md b/docs/backend/backend-request-flow.md index 236810b62..2d30b99e2 100644 --- a/docs/backend/backend-request-flow.md +++ b/docs/backend/backend-request-flow.md @@ -142,7 +142,7 @@ When adding a public contract, prefer creating or extending a shared schema in ` 3. Keep the controller thin: extract params, user id, and response orchestration only. 4. Put business logic in a service. 5. Add or update tests at the controller/service level. -6. If the endpoint affects realtime UI, check whether a websocket notification is also needed. +6. If the endpoint affects realtime UI, check whether an SSE notification is also needed. ## Where Bugs Usually Hide diff --git a/docs/development/agent-onboarding.md b/docs/development/agent-onboarding.md index 48bd7bb01..9ab2a3735 100644 --- a/docs/development/agent-onboarding.md +++ b/docs/development/agent-onboarding.md @@ -43,9 +43,9 @@ sed -n '1,260p' packages/core/src/types/event.types.ts - Event controller: `packages/backend/src/event/controllers/event.controller.ts` - Event service: `packages/backend/src/event/services/event.service.ts` - Sync service: `packages/backend/src/sync/services/sync.service.ts` -- Websocket server: `packages/backend/src/servers/websocket/websocket.server.ts` +- SSE server: `packages/backend/src/servers/sse/sse.server.ts` - Shared event types: `packages/core/src/types/event.types.ts` -- Shared websocket constants: `packages/core/src/constants/websocket.constants.ts` +- Shared SSE event names: `packages/core/src/constants/sse.constants.ts` ## If You Are Touching... @@ -56,8 +56,8 @@ sed -n '1,260p' packages/core/src/types/event.types.ts - Backend endpoints: [Backend Request Flow](../backend/backend-request-flow.md), [API Documentation](../backend/api-documentation.md) -- Google sync or websocket behavior: - [Google Sync And Websocket Flow](../features/google-sync-and-websocket-flow.md) +- Google sync or SSE behavior: + [Google Sync And SSE Flow](../features/google-sync-and-sse-flow.md) - Local persistence: [Offline Storage And Migrations](../features/offline-storage-and-migrations.md) - Event or task shape: diff --git a/docs/development/common-change-recipes.md b/docs/development/common-change-recipes.md index 1ef77189e..5c113bb9e 100644 --- a/docs/development/common-change-recipes.md +++ b/docs/development/common-change-recipes.md @@ -9,7 +9,7 @@ These are the safest implementation paths for common Compass changes. 3. Keep the controller thin in `controllers/*.controller.ts`. 4. Put business logic in `services/*.service.ts`. 5. Add controller or service tests. -6. If the endpoint affects realtime UI, decide whether a websocket event is required. +6. If the endpoint affects realtime UI, decide whether an SSE event is required. ## Add A New Event Field @@ -45,12 +45,12 @@ Do not edit recurring behavior from one layer only. 6. Run focused tests: - `yarn test:backend --runTestsByPath packages/backend/src/event/classes/compass.event.parser.test.ts packages/backend/src/event/classes/compass.event.executor.test.ts packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts --runInBand` -## Add A Websocket Event +## Add An SSE Event -1. Add the event name to `packages/core/src/constants/websocket.constants.ts`. -2. Update shared websocket types in `packages/core/src/types/websocket.types.ts` if needed. -3. Emit from `packages/backend/src/servers/websocket/websocket.server.ts`. -4. Consume it in a web socket hook under `packages/web/src/socket/hooks`. +1. Add the event name to `packages/core/src/constants/sse.constants.ts`. +2. Update shared payload types in `packages/core/src/types/sse.types.ts` if needed. +3. Emit from `packages/backend/src/servers/sse/sse.server.ts` (or call site that uses `publish`). +4. Consume it in a web hook under `packages/web/src/sse/hooks` (listeners on `EventSource`). 5. Add tests on both emitter and listener sides. ## Add Or Change Local Storage Data diff --git a/docs/development/deploy.md b/docs/development/deploy.md index f7f08e8b1..7ac79d5a9 100644 --- a/docs/development/deploy.md +++ b/docs/development/deploy.md @@ -48,5 +48,5 @@ node build/node/packages/backend/src/app.js Deployment notes: - backend requires MongoDB, SuperTokens, and Google credentials -- if you run behind a reverse proxy, it must support websocket upgrades +- if you run behind a reverse proxy, configure buffering/timeouts for long-lived `text/event-stream` responses (SSE) - ngrok is only relevant for local watch/debug flows, not normal hosted deploys diff --git a/docs/development/env-and-dev-modes.md b/docs/development/env-and-dev-modes.md index 80f0f3460..1a7d294f2 100644 --- a/docs/development/env-and-dev-modes.md +++ b/docs/development/env-and-dev-modes.md @@ -32,7 +32,7 @@ Use this for: - authenticated API work - Google OAuth/session behavior - Mongo-backed event behavior -- sync and websocket work +- sync and SSE work This requires valid env config. @@ -136,7 +136,7 @@ Webpack behavior (`packages/web/webpack.config.mjs`): - Google connection flows - backend validation behavior - Mongo persistence -- websocket server behavior +- SSE stream behavior ## Backend Health Probe diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index 0b440b3da..354bece66 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -73,13 +73,14 @@ Use this document to find the first files to inspect for common Compass changes. - Legacy schema migration: `packages/web/src/common/storage/adapter/legacy-primary-key.migration.ts` - Data/external migrations: `packages/web/src/common/storage/migrations` -## Sync And Websockets - -- Web socket client: `packages/web/src/socket/client/socket.client.ts` -- Web socket hooks: `packages/web/src/socket/hooks` -- Web socket provider: `packages/web/src/socket/provider/SocketProvider.tsx` -- Shared websocket event names: `packages/core/src/constants/websocket.constants.ts` -- Backend websocket server: `packages/backend/src/servers/websocket/websocket.server.ts` +## Sync And SSE + +- SSE client: `packages/web/src/sse/client/sse.client.ts` +- SSE hooks: `packages/web/src/sse/hooks` +- SSE provider: `packages/web/src/sse/provider/SSEProvider.tsx` +- Shared SSE event names: `packages/core/src/constants/sse.constants.ts` +- Backend SSE server: `packages/backend/src/servers/sse/sse.server.ts` +- Events stream route: `packages/backend/src/events/events.routes.config.ts` - Backend sync routes/services: `packages/backend/src/sync/sync.routes.config.ts`, `packages/backend/src/sync/services` ## Users / Metadata / Priority diff --git a/docs/development/testing-playbook.md b/docs/development/testing-playbook.md index b9e4385a7..f5f52875d 100644 --- a/docs/development/testing-playbook.md +++ b/docs/development/testing-playbook.md @@ -222,7 +222,7 @@ Useful anchors: - `packages/web/src/__tests__` - `packages/web/src/views/**/*.test.tsx` -- `packages/web/src/socket/**/*.test.ts` +- `packages/web/src/sse/**/*.test.tsx` ## Backend Test Style @@ -267,10 +267,10 @@ Use them for: ## Testing Realtime And Sync Changes -For websocket or sync work: +For SSE or sync work: - test backend emitters/handlers where possible -- test web socket hooks for listener registration and dispatch behavior +- test web SSE hooks for listener registration and dispatch behavior - test event sagas if refetch or optimistic behavior changed ## Common Gaps To Watch diff --git a/docs/development/troubleshoot.md b/docs/development/troubleshoot.md index 8e929da31..325bd2a28 100644 --- a/docs/development/troubleshoot.md +++ b/docs/development/troubleshoot.md @@ -56,7 +56,7 @@ To fix this: When a user triggers repair from the UI (`Repair Google Calendar`) and the flow does not complete as expected, first classify the failure mode from the message -and websocket behavior. +and SSE behavior. ### Quota / rate-limit during repair diff --git a/docs/features/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md new file mode 100644 index 000000000..e2b2b558d --- /dev/null +++ b/docs/features/google-sync-and-sse-flow.md @@ -0,0 +1,226 @@ +# Google Sync And Server-Sent Events (SSE) + +Compass sync is bidirectional: + +- Compass-originated event changes can propagate to Google and then notify web clients. +- Google-originated changes can flow back into Compass and then notify web clients. + +Realtime updates use **Server-Sent Events** (one HTTP connection per tab, server pushes named events). The browser `EventSource` connects to `GET /api/events/stream` with the session cookie. + +## High-Level Architecture + +```mermaid +flowchart LR + subgraph Web["packages/web"] + ES[EventSource] + Prov[SSEProvider] + Ev[useEventSSE] + Gc[useGcalSSE] + Slice[Redux sync slice] + end + subgraph Backend["packages/backend"] + Stream[events.controller stream] + Srv[sse.server] + Sync[sync.service] + Err[error handler] + end + ES -->|GET /api/events/stream| Stream + Stream -->|subscribe + replay| Srv + Sync -->|publish| Srv + Err -->|GOOGLE_REVOKED| Srv + Srv -->|SSE over HTTP| ES + Prov --> ES + Ev --> ES + Gc --> ES + Ev --> Slice + Gc --> Slice +``` + +## Connection And First Events + +```mermaid +sequenceDiagram + participant B as Browser + participant X as Express + participant S as sseServer + participant M as userMetadataService + B->>X: GET /api/events/stream + X->>S: subscribe(userId, res) + X->>M: fetchUserMetadata(userId) + M-->>X: metadata + X->>S: publish USER_METADATA + S-->>B: "event: USER_METADATA" + Note over B,S: Connection stays open. Heartbeats limit proxy buffering. +``` + +## Shared Event Names + +Source: + +- `packages/core/src/constants/sse.constants.ts` + +Wire format uses the `event:` field (uppercase identifiers). Backend and web both import these constants. + +| Constant | Role | +| ----------------------- | ------------------------------------------------- | +| `EVENT_CHANGED` | Calendar grid data should be refetched | +| `SOMEDAY_EVENT_CHANGED` | Someday sidebar data should be refetched | +| `USER_METADATA` | Replay / push SuperTokens + sync metadata | +| `IMPORT_GCAL_START` | Full or repair import started | +| `IMPORT_GCAL_END` | Import finished (see payload contract below) | +| `GOOGLE_REVOKED` | Google refresh token invalid; client prunes state | + +### `IMPORT_GCAL_END` Payload Contract + +Source: + +- `packages/core/src/types/sse.types.ts` +- `packages/backend/src/user/services/user.service.ts` +- `packages/backend/src/sync/services/sync.service.ts` + +`IMPORT_GCAL_END` carries an explicit `operation` so the client can distinguish repair completion from incremental completion. + +```ts +type ImportGCalOperation = "INCREMENTAL" | "REPAIR"; + +type ImportGCalEndPayload = + | { + operation: ImportGCalOperation; + status: "COMPLETED"; + eventsCount?: number; + calendarsCount?: number; + } + | { + operation: ImportGCalOperation; + status: "ERRORED" | "IGNORED"; + message: string; + }; +``` + +Operational constraints: + +- repair path (`restartGoogleCalendarSync`) emits `operation: "REPAIR"` +- incremental path (`importIncremental`) emits `operation: "INCREMENTAL"` +- web listeners should keep a defensive `payload?` handler for compatibility with older emitters/tests + +## Outbound Flow: User Changes An Event In Compass + +High-level path: + +1. UI dispatches an event action. +2. A saga performs optimistic updates. +3. The selected repository writes locally or remotely. +4. Remote event writes hit backend event routes. +5. `EventController` packages the change as a `CompassEvent`. +6. `CompassSyncProcessor.processEvents()` loads the DB event, plans work, applies persistence, and runs Google side effects. +7. After commit, the backend calls `sseServer` to publish notifications based on whether the change affected normal or someday events (`EVENT_CHANGED` vs `SOMEDAY_EVENT_CHANGED`). + +Primary files: + +- `packages/web/src/ducks/events/sagas/event.sagas.ts` +- `packages/web/src/common/repositories/event` +- `packages/backend/src/event/controllers/event.controller.ts` +- `packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts` + +## Inbound Flow: Google Notifies Compass About Changes + +High-level path: + +1. Google posts to the notification endpoint in sync routes. +2. Backend verifies the request origin. +3. `SyncService.handleGcalNotification()` locates the watch and sync record. +4. The service builds a Google Calendar client for the user. +5. `GCalNotificationHandler` fetches incremental changes using the stored sync token. +6. `GcalSyncProcessor` applies those changes to Compass data. +7. The backend publishes `EVENT_CHANGED` (or someday equivalent) so clients refetch. + +Primary files: + +- `packages/backend/src/sync/sync.routes.config.ts` +- `packages/backend/src/sync/services/sync.service.ts` +- `packages/backend/src/sync/services/notify/handler/gcal.notification.handler.ts` +- `packages/backend/src/sync/services/sync/google/gcal.sync.processor.ts` + +### Notification Outcomes And Error Semantics + +Same as before: recoverable `INITIALIZED` / `IGNORED` / `PROCESSED` paths, `GOOGLE_REVOKED` on invalid refresh token, etc. See inline comments in `SyncService` and `SyncController`. + +## SSE Server Responsibilities + +Source: + +- `packages/backend/src/servers/sse/sse.server.ts` +- `packages/backend/src/events/controllers/events.controller.ts` + +The SSE layer: + +- accepts authenticated `GET /api/events/stream` requests (SuperTokens session) +- registers each open `Response` per user for fan-out +- sends periodic comment heartbeats (`: keepalive`) so buffering proxies do not delay events +- on connect, replays `USER_METADATA` after subscribe so reconnects get current state +- exposes helpers (`handleBackgroundCalendarChange`, `handleImportGCalEnd`, …) used by sync and error handling + +## Web Client Responsibilities + +Files: + +- `packages/web/src/sse/client/sse.client.ts` +- `packages/web/src/sse/hooks/useSSEConnection.ts` +- `packages/web/src/sse/hooks/useEventSSE.ts` +- `packages/web/src/sse/hooks/useGcalSSE.ts` +- `packages/web/src/sse/provider/SSEProvider.tsx` + +The client: + +- opens `EventSource` when a session exists (`SessionProvider` + `SSEProvider`) +- refetches events when `EVENT_CHANGED` / `SOMEDAY_EVENT_CHANGED` arrive (via `Sync_AsyncStateContextReason` aligned with those names) +- tracks Google import status from `IMPORT_GCAL_*` and `USER_METADATA` +- handles `GOOGLE_REVOKED` consistently with REST error payloads + +Redux reasons for refetch (`Sync_AsyncStateContextReason`) reuse the same string values as SSE event names where they correspond (`EVENT_CHANGED`, `SOMEDAY_EVENT_CHANGED`, `GOOGLE_REVOKED`), plus app-local reasons such as `IMPORT_COMPLETE`. + +## Revoked Token And Reconnect Lifecycle + +1. Backend detects missing/invalid Google refresh token (middleware, sync, or Google API error handling). +2. Backend prunes Google-origin data and publishes `GOOGLE_REVOKED` over SSE. +3. Web app marks Google as revoked in session memory and temporarily switches to local repository behavior. +4. User initiates re-consent via OAuth flow. +5. Backend auth handler determines mode server-side; reconnect updates credentials and metadata. + +## User Metadata Shape Used By SSE And UI + +`UserMetadata` includes Google connection state alongside sync state. It is pushed on stream connect (`USER_METADATA`) and available from `GET /api/user/metadata`. + +### Google Metadata Status Semantics + +Source files: + +- `packages/backend/src/user/services/user-metadata.service.ts` +- `packages/core/src/types/user.types.ts` +- `packages/web/src/sse/hooks/useGcalSSE.ts` + +Auto-import guardrail: + +- client auto-starts import only when `sync.importGCal === "RESTART"` **and** `google.connectionState` is not `NOT_CONNECTED` or `RECONNECT_REQUIRED` + +## Import Flow + +1. Backend starts import. +2. SSE publishes `IMPORT_GCAL_START`. +3. Client reacts to metadata / `USER_METADATA` / `IMPORT_GCAL_END`. +4. Backend completes import and publishes `IMPORT_GCAL_END`. +5. Client stores import results and triggers a refetch when appropriate. + +### Manual Import Trigger Contract + +`POST /api/sync/import-gcal` returns `204` immediately; progress is asynchronous via SSE events (not polling). + +## Debug + +- Local debug dispatch of calendar-change notifications may use env `SSE_DEBUG_USER` (see `sync.debug.controller`). + +## Rules Of Thumb For Changes + +- New realtime behavior usually needs changes in `core` (`sse.constants` / `sse.types`), `backend` (`sse.server` + callers), and `web` (hooks listening via `EventSource`). +- If you add a new SSE event, update shared constants and both emit/listen sides. +- If the UI is stale after edits, confirm an SSE event is published and the sync slice handles it on the client. diff --git a/docs/features/password-auth-flow.md b/docs/features/password-auth-flow.md index 21a0ed0f5..d6889ebaa 100644 --- a/docs/features/password-auth-flow.md +++ b/docs/features/password-auth-flow.md @@ -136,7 +136,7 @@ At runtime it: - checks whether a session already exists - marks the user as authenticated in local auth state -- reconnects the websocket when a session exists +- opens the SSE stream when a session exists - refreshes user metadata after session creation/refresh `UserProvider.tsx` also backfills `lastKnownEmail` from `/api/user/profile` once a previously-authenticated user is loaded. diff --git a/docs/features/recurring-events-handling.md b/docs/features/recurring-events-handling.md index 7adfc5335..374e270b0 100644 --- a/docs/features/recurring-events-handling.md +++ b/docs/features/recurring-events-handling.md @@ -177,7 +177,7 @@ Use this sequence when recurring edits behave unexpectedly: - `UNTIL`-only truncation - full series recreation - Google side effects for someday/non-someday transitions -- websocket notifications for calendar vs someday changes +- SSE notifications for calendar vs someday changes Good test anchors: diff --git a/docs/frontend/frontend-runtime-flow.md b/docs/frontend/frontend-runtime-flow.md index 7066c0015..b8c5c7643 100644 --- a/docs/frontend/frontend-runtime-flow.md +++ b/docs/frontend/frontend-runtime-flow.md @@ -49,7 +49,7 @@ Important behavior: - blocks mobile with `MobileGate` - wraps authenticated layout with `UserProvider` -- wires socket listeners through `SocketProvider` +- wires SSE listeners through `SSEProvider` This is the shell for the main desktop app experience. @@ -64,7 +64,7 @@ Responsibilities: - initialize SuperTokens recipes - track auth state in a `BehaviorSubject` - mark users as having authenticated -- connect or disconnect sockets on session changes +- open or close the SSE stream on session changes - expose a React context for auth status Important detail: @@ -244,7 +244,7 @@ Typical event flow: 2. redux-saga handles the async side effect 3. the selected repository writes locally or remotely 4. reducers and/or Elf stores update client state -5. websocket events can trigger refetch or metadata refresh later +5. SSE events can trigger refetch or metadata refresh later Important consequence: @@ -285,7 +285,7 @@ This is deliberate and prevents events from "disappearing" after login when loca Revoked state details: - stored in memory only (not persisted) -- set when `GOOGLE_REVOKED` is detected from socket or API error responses +- set when `GOOGLE_REVOKED` is detected from SSE or API error responses - cleared when Google auth succeeds again ## Storage Initialization @@ -304,27 +304,27 @@ Startup storage flow: Database init failure is non-fatal; the app falls back to remote-only behavior when possible. -## Websocket Runtime +## SSE Runtime Files: -- `packages/web/src/socket/provider/SocketProvider.tsx` -- `packages/web/src/socket/hooks/useSocketConnection.ts` -- `packages/web/src/socket/hooks/useEventSync.ts` -- `packages/web/src/socket/hooks/useGcalSync.ts` +- `packages/web/src/sse/provider/SSEProvider.tsx` +- `packages/web/src/sse/hooks/useSSEConnection.ts` +- `packages/web/src/sse/hooks/useEventSSE.ts` +- `packages/web/src/sse/hooks/useGcalSSE.ts` Responsibilities: -- connect/disconnect the socket based on auth state -- refetch events when background event changes arrive +- open/close `EventSource` to `GET /api/events/stream` based on auth state +- refetch events when background event changes arrive (`EVENT_CHANGED`, `SOMEDAY_EVENT_CHANGED`) - react to Google import progress and Google revocation events -- request user metadata via socket when appropriate +- apply `USER_METADATA` pushed on stream connect and when the backend refreshes metadata Runtime nuances: -- `useGcalSync` uses `USER_METADATA` as the source of truth for sync metadata and Google connection status. +- `useGcalSSE` uses `USER_METADATA` as the source of truth for sync metadata and Google connection status. - auto-import is triggered only when `sync.importGCal === "RESTART"` and `google.connectionState` is neither `NOT_CONNECTED` nor `RECONNECT_REQUIRED`. -- On connect, backend may proactively emit `GOOGLE_REVOKED`; the client clears Google-origin events and falls back to local event storage until reconnect. +- On connect, backend may proactively send `GOOGLE_REVOKED`; the client clears Google-origin events and falls back to local event storage until reconnect. ## Google Connection UI Contract @@ -359,6 +359,6 @@ Connect-later guardrail: ## What To Read Before Editing - Auth/session issue: read session provider, user provider, router loaders. -- Event refresh issue: read socket hooks, sync slice, event sagas. +- Event refresh issue: read SSE hooks, sync slice, event sagas. - Offline issue: read storage adapter and migration runner. - Rendering issue in day/week/now: start at the route view, then its hooks. diff --git a/jest.config.js b/jest.config.js index 6b3250177..927c3500b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ const backendProject = { "^@backend/common(/(.*)$)?": "/packages/backend/src/common/$1", "^@backend/dev(/(.*)$)?": "/packages/backend/src/dev/$1", "^@backend/email(/(.*)$)?": "/packages/backend/src/email/$1", + "^@backend/events(/(.*)$)?": "/packages/backend/src/events/$1", "^@backend/event(/(.*)$)?": "/packages/backend/src/event/$1", "^@backend/health(/(.*)$)?": "/packages/backend/src/health/$1", "^@backend/priority(/(.*)$)?": "/packages/backend/src/priority/$1", @@ -64,7 +65,7 @@ const webProject = { "^@web/ducks(/(.*)$)?": "/packages/web/src/ducks/$1", "^@web/public(/(.*)$)?": "/packages/web/src/public/$1", "^@web/routers(/(.*)$)?": "/packages/web/src/routers/$1", - "^@web/socket(/(.*)$)?": "/packages/web/src/socket/$1", + "^@web/sse(/(.*)$)?": "/packages/web/src/sse/$1", "^@web/store((/(.*)$)?)?": "/packages/web/src/store/$1", "^@web/views(/(.*)$)?": "/packages/web/src/views/$1", "^.+\\.(css|less)$": diff --git a/packages/backend/package.json b/packages/backend/package.json index fc6ab39bf..59ede34de 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,7 +18,6 @@ "p-limit": "^7.2.0", "rrule": "^2.7.2", "saslprep": "^1.0.3", - "socket.io": "^4.7.5", "supertokens-node": "^23.0.1", "tslib": "^2.4.0" }, @@ -35,7 +34,6 @@ "@types/node": "^22.13.10", "@types/supertest": "^6.0.3", "jest-environment-node": "^29.7.0", - "socket.io-client": "^4.7.5", "supertest": "^7.1.0", "tsconfig-paths": "^4.1.2" } diff --git a/packages/backend/src/__tests__/drivers/base.driver.ts b/packages/backend/src/__tests__/drivers/base.driver.ts index 19a75ed6b..a3d970bd0 100644 --- a/packages/backend/src/__tests__/drivers/base.driver.ts +++ b/packages/backend/src/__tests__/drivers/base.driver.ts @@ -1,22 +1,12 @@ import http from "node:http"; -import { type ManagerOptions, type Socket, io } from "socket.io-client"; import { type Request, agent } from "supertest"; -import type { - CompassSocket, - CompassSocketServer, -} from "@core/types/websocket.types"; -import { waitUntilEvent } from "@core/util/wait-until-event.util"; import { initExpressServer } from "@backend/servers/express/express.server"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; -import { getServerUri } from "@backend/servers/websocket/websocket.util"; export class BaseDriver { private readonly app = initExpressServer(); private readonly http = http.createServer(this.app); private readonly server = agent(this.http); - private readonly websocketClients: Socket[] = []; - private websocketServer?: CompassSocketServer; private serverUri?: string; private getSessionCookie(session?: { userId: string }): string { @@ -36,7 +26,14 @@ export class BaseDriver { async listen(): Promise { this.serverUri = await new Promise((resolve, reject) => { this.http.listen(0); - this.http.on("listening", () => resolve(getServerUri(this.http))); + this.http.on("listening", () => { + const address = this.http.address(); + if (address && typeof address === "object") { + resolve(`http://localhost:${address.port}`); + } else { + reject(new Error("Could not determine server address")); + } + }); this.http.on("error", reject); }); @@ -49,12 +46,6 @@ export class BaseDriver { }; } - initWebsocketServer(): CompassSocketServer { - this.websocketServer = webSocketServer.init(this.http); - - return this.websocketServer; - } - getServer() { return this.server; } @@ -65,49 +56,96 @@ export class BaseDriver { return this.serverUri; } - getWebsocketServer() { - return this.websocketServer; - } - /** - * createWebsocketClient + * openSSEStream * - * make sure to call listen() before using this method - * otherwise the getServerUri function will fail - * because the server address is not yet available + * Opens an SSE stream for a user, collects events, and returns + * a handle to close the stream and retrieve collected events. */ - createWebsocketClient( - user?: { userId: string; sessionId?: string }, - options: Pick = { autoConnect: true }, - ): Socket { + openSSEStream(user?: { userId: string; sessionId?: string }): { + close: () => void; + waitForEvent: (eventName: string, timeoutMs?: number) => Promise; + } { if (!this.serverUri) throw new Error("did you forget to call `listen`?"); - const client = io(this.serverUri, { - ...options, - withCredentials: true, - extraHeaders: user ? { cookie: this.getSessionCookie(user) } : undefined, - }); + const eventListeners = new Map void>>(); - this.websocketClients.push(client); + const cookie = user ? this.getSessionCookie(user) : undefined; + + let controller: AbortController | undefined; + + const startStream = async () => { + controller = new AbortController(); + const headers: Record = { + Accept: "text/event-stream", + }; + if (cookie) headers["Cookie"] = cookie; + + try { + const response = await fetch(`${this.serverUri}/api/events/stream`, { + headers, + signal: controller.signal, + }); + + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + let eventName = "message"; + let dataLine = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventName = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + dataLine = line.slice(6).trim(); + } else if (line === "") { + if (dataLine) { + const parsed = JSON.parse(dataLine) as unknown; + const listeners = eventListeners.get(eventName) ?? []; + for (const cb of listeners) cb(parsed); + eventName = "message"; + dataLine = ""; + } + } + } + } + } catch { + // Stream closed or aborted + } + }; - return client; + void startStream(); + + return { + close: () => controller?.abort(), + waitForEvent: (eventName: string, timeoutMs = 5000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for SSE event: ${eventName}`)); + }, timeoutMs); + + const listeners = eventListeners.get(eventName) ?? []; + listeners.push((data) => { + clearTimeout(timer); + resolve(data); + }); + eventListeners.set(eventName, listeners); + }), + }; } async teardown() { try { - await Promise.allSettled( - this.websocketClients.map( - async (client) => - new Promise((resolve) => { - client.once("disconnect", resolve); - if (client.connected) client.close(); - resolve("client already closed"); - }), - ), - ); - - await this.websocketServer?.close(); - if (!this.http.listening) return; await new Promise((resolve, reject) => { @@ -120,45 +158,4 @@ export class BaseDriver { console.error(error); } } - - async waitUntilWebsocketEvent( - websocket: Parameters["0"], - event: string, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: Payload) => Promise = (...args) => - Promise.resolve(args as unknown as Result), - ): Promise { - return waitUntilEvent(websocket, event, 2000, beforeEvent, afterEvent); - } - - getUserSockets(userId: string): CompassSocket[] { - const sockets: CompassSocket[] = []; - - this.websocketServer?.sockets.sockets.forEach((socket) => { - if (socket.data.session.getUserId() === userId) sockets.push(socket); - }); - - return sockets; - } - - async getConnectedUserClientSockets( - userId: string, - client: Socket, - ): Promise { - if (!this.websocketServer) { - throw new Error("did you forget to call `listen`?"); - } - - const sockets = await this.waitUntilWebsocketEvent( - this.websocketServer, - "connection", - async () => Promise.resolve(client.connect()), - async () => - new Promise((resolve) => - process.nextTick(() => resolve(this.getUserSockets(userId))), - ), - ); - - return sockets; - } } diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 0fa462d7d..0c7f19c39 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -5,7 +5,6 @@ import { ENV } from "@backend/common/constants/env.constants"; import mongoService from "@backend/common/services/mongo.service"; import { initExpressServer } from "@backend/servers/express/express.server"; import { initNgrokServer } from "@backend/servers/ngrok/ngrok.server"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { type Listener } from "@ngrok/ngrok"; import { createServer, type Server } from "node:http"; @@ -34,8 +33,6 @@ function onNgrokError(error: Error): void { async function start() { try { - webSocketServer.init(httpServer); - await mongoService.start(); await new Promise((resolve) => diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 278fc0694..815fa57ac 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -1,7 +1,7 @@ import { type Request } from "express"; import { GaxiosError } from "gaxios"; import { type SessionRequest } from "supertokens-node/framework/express"; -import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; +import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; @@ -23,7 +23,7 @@ import { type Info_Error, } from "@backend/common/types/error.types"; import { type SessionResponse } from "@backend/common/types/express.types"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import syncService from "@backend/sync/services/sync.service"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; @@ -117,7 +117,7 @@ const handleGoogleError = async ( ) => { if (isInvalidGoogleToken(e)) { await userService.pruneGoogleData(userId); - webSocketServer.handleGoogleRevoked(userId); + sseServer.handleGoogleRevoked(userId); logger.warn( `Invalid Google token for user: ${userId}. Google data pruned and client notified.`, diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts index 521cc2cd1..57146735f 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.test.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -1,4 +1,4 @@ -import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; +import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; @@ -9,7 +9,7 @@ import { toClientErrorPayload, } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import userService from "@backend/user/services/user.service"; describe("error.handler", () => { @@ -66,7 +66,7 @@ describe("error.handler", () => { const userId = "507f1f77bcf86cd799439011"; jest.spyOn(userService, "pruneGoogleData").mockResolvedValue(); const handleGoogleRevokedSpy = jest.spyOn( - webSocketServer, + sseServer, "handleGoogleRevoked", ); handleGoogleRevokedSpy.mockImplementation(() => undefined); diff --git a/packages/backend/src/events/controllers/events.controller.ts b/packages/backend/src/events/controllers/events.controller.ts new file mode 100644 index 000000000..79b1da9c3 --- /dev/null +++ b/packages/backend/src/events/controllers/events.controller.ts @@ -0,0 +1,30 @@ +import type { Request, Response } from "express"; +import { USER_METADATA } from "@core/constants/sse.constants"; +import { Logger } from "@core/logger/winston.logger"; +import { sseServer } from "@backend/servers/sse/sse.server"; +import userMetadataService from "@backend/user/services/user-metadata.service"; + +const logger = Logger("app:events.controller"); + +class EventsController { + streamEvents = async (req: Request, res: Response): Promise => { + const userId = req.session!.getUserId(); + + try { + // Subscribe immediately so no events are missed during the metadata fetch. + const unsubscribe = sseServer.subscribe(userId, res); + req.on("close", unsubscribe); + + // Replay current state after subscribing — client is never stuck on reconnect. + const metadata = await userMetadataService.fetchUserMetadata(userId); + sseServer.publishTo(res, USER_METADATA, metadata); + } catch (err) { + logger.error(`Failed to open SSE stream for user ${userId}:`, err); + if (!res.headersSent) { + res.status(500).end(); + } + } + }; +} + +export default new EventsController(); diff --git a/packages/backend/src/events/events.routes.config.ts b/packages/backend/src/events/events.routes.config.ts new file mode 100644 index 000000000..bf697c172 --- /dev/null +++ b/packages/backend/src/events/events.routes.config.ts @@ -0,0 +1,19 @@ +import type express from "express"; +import { verifySession } from "supertokens-node/recipe/session/framework/express"; +import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import eventsController from "./controllers/events.controller"; + +export class EventsRoutes extends CommonRoutesConfig { + constructor(app: express.Application) { + super(app, "EventsRoutes"); + } + + configureRoutes(): express.Application { + this.app + .route("/api/events/stream") + .all(verifySession()) + .get(eventsController.streamEvents); + + return this.app; + } +} diff --git a/packages/backend/src/servers/express/express.server.ts b/packages/backend/src/servers/express/express.server.ts index f58b7a371..ed9abfbf4 100644 --- a/packages/backend/src/servers/express/express.server.ts +++ b/packages/backend/src/servers/express/express.server.ts @@ -15,6 +15,7 @@ import { supertokensCors, } from "@backend/common/middleware/supertokens.middleware"; import { EventRoutes } from "@backend/event/event.routes.config"; +import { EventsRoutes } from "@backend/events/events.routes.config"; import { HealthRoutes } from "@backend/health/health.routes.config"; import { PriorityRoutes } from "@backend/priority/priority.routes.config"; import { SyncRoutes } from "@backend/sync/sync.routes.config"; @@ -42,6 +43,7 @@ export const initExpressServer = () => { routes.push(new UserRoutes(app)); routes.push(new PriorityRoutes(app)); routes.push(new EventRoutes(app)); + routes.push(new EventsRoutes(app)); routes.push(new SyncRoutes(app)); routes.push(new CalendarRoutes(app)); diff --git a/packages/backend/src/servers/ngrok/ngrok.server.ts b/packages/backend/src/servers/ngrok/ngrok.server.ts index fe7c148ce..00d8aeaf4 100644 --- a/packages/backend/src/servers/ngrok/ngrok.server.ts +++ b/packages/backend/src/servers/ngrok/ngrok.server.ts @@ -1,10 +1,15 @@ import EventEmitter from "node:events"; import type { Server } from "node:http"; import type { Server as HttpsServer } from "node:https"; +import type { AddressInfo } from "node:net"; import type { Listener } from "@ngrok/ngrok"; import { isDev } from "@core/util/env.util"; import { ENV } from "@backend/common/constants/env.constants"; -import { getServerUri } from "@backend/servers/websocket/websocket.util"; + +const getServerUri = (httpServer: Server | HttpsServer) => { + const port = (httpServer.address() as AddressInfo).port; + return `http://localhost:${port}`; +}; interface NGrokEventsMap { connect: []; diff --git a/packages/backend/src/servers/sse/sse.server.test.ts b/packages/backend/src/servers/sse/sse.server.test.ts new file mode 100644 index 000000000..c7f659b41 --- /dev/null +++ b/packages/backend/src/servers/sse/sse.server.test.ts @@ -0,0 +1,147 @@ +/** + * @jest-environment node + * + * we do not need the database for this test + */ +import { ObjectId } from "mongodb"; +import { + EVENT_CHANGED, + SOMEDAY_EVENT_CHANGED, + USER_METADATA, +} from "@core/constants/sse.constants"; +import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; + +jest.mock("supertokens-node/recipe/session/framework/express", () => ({ + verifySession: + () => + ( + req: { headers?: { cookie?: string }; session?: unknown }, + _res: unknown, + next: () => void, + ) => { + const cookieHeader = req.headers?.cookie ?? ""; + const sessionMatch = cookieHeader.match(/session=([^;]+)/); + if (sessionMatch) { + try { + const session = JSON.parse(decodeURIComponent(sessionMatch[1])) as { + userId: string; + }; + req.session = { + getUserId: () => session.userId, + getHandle: () => "test-session-handle", + }; + } catch { + // ignore invalid cookie + } + } + next(); + }, +})); + +jest.mock("@backend/user/services/user-metadata.service", () => ({ + __esModule: true, + default: { + fetchUserMetadata: jest.fn().mockResolvedValue({ + sync: { importGCal: null }, + }), + }, +})); + +describe("SSE Server", () => { + const baseDriver = new BaseDriver(); + + beforeAll(async () => { + await baseDriver.listen(); + }); + + afterAll(async () => baseDriver.teardown()); + + describe("Subscription and events:", () => { + it("delivers EVENT_CHANGED to a subscribed user", async () => { + const userId = new ObjectId().toString(); + + const stream = baseDriver.openSSEStream({ userId }); + const eventPromise = stream.waitForEvent(EVENT_CHANGED, 2000); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const { sseServer } = await import("./sse.server"); + sseServer.handleBackgroundCalendarChange(userId); + + await expect(eventPromise).resolves.toBeDefined(); + + stream.close(); + }); + + it("delivers SOMEDAY_EVENT_CHANGED to a subscribed user", async () => { + const userId = new ObjectId().toString(); + + const stream = baseDriver.openSSEStream({ userId }); + const eventPromise = stream.waitForEvent(SOMEDAY_EVENT_CHANGED, 2000); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const { sseServer } = await import("./sse.server"); + sseServer.handleBackgroundSomedayChange(userId); + + await expect(eventPromise).resolves.toBeDefined(); + + stream.close(); + }); + + it("replays USER_METADATA on connection (cold start)", async () => { + const userId = new ObjectId().toString(); + + const stream = baseDriver.openSSEStream({ userId }); + + await expect( + stream.waitForEvent(USER_METADATA, 2000), + ).resolves.toBeDefined(); + + stream.close(); + }); + + it("does not replay USER_METADATA to existing tabs when a new tab opens", async () => { + const userId = new ObjectId().toString(); + + // Tab A opens and receives its initial replay. + const streamA = baseDriver.openSSEStream({ userId }); + await expect( + streamA.waitForEvent(USER_METADATA, 2000), + ).resolves.toBeDefined(); + + // Register a second listener on tab A BEFORE tab B connects. + const spuriousReplay = streamA.waitForEvent(USER_METADATA, 300); + + // Tab B opens for the same user — should only replay to tab B. + const streamB = baseDriver.openSSEStream({ userId }); + await expect( + streamB.waitForEvent(USER_METADATA, 2000), + ).resolves.toBeDefined(); + + // Tab A must NOT receive a second USER_METADATA. + await expect(spuriousReplay).rejects.toThrow("Timeout"); + + streamA.close(); + streamB.close(); + }); + + it("does not deliver events to unsubscribed users", async () => { + const userId = new ObjectId().toString(); + const otherUserId = new ObjectId().toString(); + + const stream = baseDriver.openSSEStream({ userId }); + + const eventPromise = stream.waitForEvent(EVENT_CHANGED, 300); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const { sseServer } = await import("./sse.server"); + sseServer.handleBackgroundCalendarChange(otherUserId); + + await expect(eventPromise).rejects.toThrow("Timeout"); + + stream.close(); + }); + }); +}); diff --git a/packages/backend/src/servers/sse/sse.server.ts b/packages/backend/src/servers/sse/sse.server.ts new file mode 100644 index 000000000..61ebd68b7 --- /dev/null +++ b/packages/backend/src/servers/sse/sse.server.ts @@ -0,0 +1,105 @@ +import type { Response } from "express"; +import { + EVENT_CHANGED, + GOOGLE_REVOKED, + IMPORT_GCAL_END, + IMPORT_GCAL_START, + SOMEDAY_EVENT_CHANGED, +} from "@core/constants/sse.constants"; +import { Logger } from "@core/logger/winston.logger"; +import type { ImportGCalEndPayload } from "@core/types/sse.types"; + +const logger = Logger("app:sse.server"); +const HEARTBEAT_INTERVAL_MS = 25_000; + +class SSEServer { + private connections = new Map>(); + + constructor() { + // .unref() prevents the interval from keeping the Node.js process alive in + // tests and graceful shutdown scenarios. + setInterval(() => { + for (const [userId, conns] of this.connections) { + for (const res of conns) { + try { + res.write(": keepalive\n\n"); + } catch { + this.removeConnection(userId, res); + } + } + } + }, HEARTBEAT_INTERVAL_MS).unref(); + } + + private removeConnection(userId: string, res: Response): void { + const conns = this.connections.get(userId); + if (!conns) return; + conns.delete(res); + if (conns.size === 0) this.connections.delete(userId); + logger.debug(`SSE dead connection removed for user: ${userId}`); + } + + subscribe(userId: string, res: Response): () => void { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const conns = this.connections.get(userId) ?? new Set(); + conns.add(res); + this.connections.set(userId, conns); + logger.debug( + `SSE connection opened for user: ${userId} (total: ${conns.size})`, + ); + + return () => { + this.removeConnection(userId, res); + logger.debug(`SSE connection closed for user: ${userId}`); + }; + } + + publish(userId: string, event: string, data: unknown = {}): void { + const conns = this.connections.get(userId); + if (!conns?.size) return; + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of conns) { + try { + res.write(payload); + } catch { + this.removeConnection(userId, res); + } + } + } + + publishTo(res: Response, event: string, data: unknown = {}): void { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + try { + res.write(payload); + } catch { + // Connection already closed + } + } + + handleImportGCalStart(userId: string): void { + this.publish(userId, IMPORT_GCAL_START); + } + + handleImportGCalEnd(userId: string, payload?: ImportGCalEndPayload): void { + this.publish(userId, IMPORT_GCAL_END, payload ?? {}); + } + + handleBackgroundCalendarChange(userId: string): void { + this.publish(userId, EVENT_CHANGED); + } + + handleBackgroundSomedayChange(userId: string): void { + this.publish(userId, SOMEDAY_EVENT_CHANGED); + } + + handleGoogleRevoked(userId: string): void { + this.publish(userId, GOOGLE_REVOKED); + } +} + +export const sseServer = new SSEServer(); diff --git a/packages/backend/src/servers/websocket/websocket.server.test.ts b/packages/backend/src/servers/websocket/websocket.server.test.ts deleted file mode 100644 index fa836917b..000000000 --- a/packages/backend/src/servers/websocket/websocket.server.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * @jest-environment node - * - * we do not need the database for this test - */ -import { ObjectId } from "mongodb"; -import { randomUUID } from "node:crypto"; -import { updateUserMetadata } from "supertokens-node/recipe/usermetadata"; -import { - EVENT_CHANGED, - EVENT_CHANGE_PROCESSED, - FETCH_USER_METADATA, - SOMEDAY_EVENT_CHANGED, - SOMEDAY_EVENT_CHANGE_PROCESSED, - USER_METADATA, -} from "@core/constants/websocket.constants"; -import { type UserMetadata } from "@core/types/user.types"; -import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; - -jest.mock("@backend/user/queries/user.queries", () => ({ - findCompassUserBy: jest.fn(), -})); - -const mockFindCompassUserBy = findCompassUserBy as jest.MockedFunction< - typeof findCompassUserBy ->; - -describe("WebSocket Server", () => { - const baseDriver = new BaseDriver(); - - const userMetadata: UserMetadata = { sync: { importGCal: null } }; - - beforeAll(async () => { - baseDriver.initWebsocketServer(); - - await baseDriver.listen(); - }); - - beforeEach(() => { - mockFindCompassUserBy.mockResolvedValue(null); - }); - - afterAll(async () => baseDriver.teardown()); - - describe("Connection: ", () => { - describe("With Valid Session: ", () => { - it("connects a client connecting with a valid session", async () => { - const userId = new ObjectId().toString(); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - await expect( - baseDriver.waitUntilWebsocketEvent( - client, - "connect", - async () => client.connect(), - () => Promise.resolve(client.connected), - ), - ).resolves.toEqual(true); - }); - }); - - describe("With Invalid Session: ", () => { - it("refuses connections from a client connecting without a valid session", async () => { - const userId = Symbol("invalid-uuid") as unknown as string; - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - await expect( - baseDriver.waitUntilWebsocketEvent( - client, - "connect_error", - async () => client.connect(), - (error) => { - client.disconnect(); - return Promise.resolve({ error, connected: client.connected }); - }, - ), - ).resolves.toEqual( - expect.objectContaining({ - error: expect.any(Error), - connected: false, - }), - ); - }); - }); - - describe("Without Session: ", () => { - it("refuses connections from a client connecting without a session", async () => { - const client = baseDriver.createWebsocketClient(undefined, { - autoConnect: false, - }); - - await expect( - baseDriver.waitUntilWebsocketEvent( - client, - "connect_error", - async () => client.connect(), - (error) => { - client.disconnect(); - return Promise.resolve({ error, connected: client.connected }); - }, - ), - ).resolves.toEqual( - expect.objectContaining({ - error: expect.any(Error), - connected: false, - }), - ); - }); - }); - }); - - describe("Emission: ", () => { - describe("To Specific User Session: ", () => { - it("emits event to the correct user session socketId", async () => { - const userId = new ObjectId().toString(); - const sessionIdOne = randomUUID(); - const sessionIdTwo = randomUUID(); - - const clientOne = baseDriver.createWebsocketClient( - { userId, sessionId: sessionIdOne }, - { autoConnect: false }, - ); - - const clientTwo = baseDriver.createWebsocketClient( - { userId, sessionId: sessionIdTwo }, - { autoConnect: false }, - ); - - clientOne.once("connect", () => { - webSocketServer.handleUserMetadata(sessionIdOne, userMetadata); - }); - - clientTwo.once("connect", () => - webSocketServer.handleUserMetadata(sessionIdTwo, { - skipOnboarding: false, - }), - ); - - await expect( - Promise.allSettled([ - baseDriver.waitUntilWebsocketEvent(clientOne, USER_METADATA, () => - Promise.resolve(clientOne.connect()), - ), - baseDriver.waitUntilWebsocketEvent(clientTwo, USER_METADATA, () => - Promise.resolve(clientTwo.connect()), - ), - ]), - ).resolves.toEqual([ - expect.objectContaining({ - status: "fulfilled", - value: [userMetadata], - }), - expect.objectContaining({ - status: "fulfilled", - value: [{ skipOnboarding: false }], - }), - ]); - }); - }); - - describe("To All User Sessions: ", () => { - it("emits event to all the active sessions of a user", async () => { - const userIdOne = new ObjectId().toString(); - const userIdTwo = new ObjectId().toString(); - const sessionIdOne = randomUUID(); - const sessionIdTwo = randomUUID(); - const sessionIdThree = randomUUID(); - - const clientOne = baseDriver.createWebsocketClient( - { userId: userIdOne, sessionId: sessionIdOne }, - { autoConnect: false }, - ); - - const clientTwo = baseDriver.createWebsocketClient( - { userId: userIdOne, sessionId: sessionIdTwo }, - { autoConnect: false }, - ); - - const clientThree = baseDriver.createWebsocketClient( - { userId: userIdTwo, sessionId: sessionIdThree }, - { autoConnect: false }, - ); - - clientOne.once("connect", () => { - clientTwo.connect(); - }); - - clientTwo.once("connect", () => { - clientThree.connect(); - }); - - clientThree.once("connect", () => { - webSocketServer.handleBackgroundCalendarChange(userIdOne); - }); - - await expect( - Promise.allSettled([ - baseDriver.waitUntilWebsocketEvent(clientOne, EVENT_CHANGED, () => - Promise.resolve(clientOne.connect()), - ), - baseDriver.waitUntilWebsocketEvent(clientTwo, EVENT_CHANGED), - baseDriver.waitUntilWebsocketEvent(clientThree, EVENT_CHANGED), - ]), - ).resolves.toEqual([ - expect.objectContaining({ status: "fulfilled", value: [] }), - expect.objectContaining({ status: "fulfilled", value: [] }), - expect.objectContaining({ - status: "rejected", - reason: new Error( - `Operation timed out. Wait for ${EVENT_CHANGED} timed out`, - ), - }), - ]); - }); - }); - - describe("To Disconnected Session: ", () => { - it("ignores change if no connection between client and ws server", () => { - const userId = new ObjectId().toString(); - const sessionId = randomUUID(); - - expect( - webSocketServer.handleBackgroundCalendarChange(userId), - ).toBeUndefined(); - - expect(webSocketServer.handleImportGCalStart(userId)).toBeUndefined(); - - expect(webSocketServer.handleImportGCalEnd(userId)).toBeUndefined(); - - expect( - webSocketServer.handleUserMetadata(sessionId, userMetadata), - ).toEqual("IGNORED"); - }); - }); - }); - - describe("Server Sent Events: ", () => { - describe("handleBackgroundCalendarChange: ", () => { - it("emits the `EVENT_CHANGED` event without a payload to the client", async () => { - const userId = new ObjectId().toString(); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - client.once("connect", () => - webSocketServer.handleBackgroundCalendarChange(userId), - ); - - await expect( - baseDriver.waitUntilWebsocketEvent(client, EVENT_CHANGED, async () => - client.connect(), - ), - ).resolves.toEqual([]); - }); - }); - - describe("handleBackgroundSomedayChange: ", () => { - it("emits the `SOMEDAY_EVENT_CHANGED` event without a payload to the client", async () => { - const userId = new ObjectId().toString(); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - client.once("connect", () => - webSocketServer.handleBackgroundSomedayChange(userId), - ); - - await expect( - baseDriver.waitUntilWebsocketEvent( - client, - SOMEDAY_EVENT_CHANGED, - async () => client.connect(), - ), - ).resolves.toEqual([]); - }); - }); - - describe("handleUserMetadata: ", () => { - it("emits the `USER_METADATA` event with a `UserMetadata` payload to the client", async () => { - const userId = new ObjectId().toString(); - const sessionId = randomUUID(); - - const client = baseDriver.createWebsocketClient( - { userId, sessionId }, - { autoConnect: false }, - ); - - client.once("connect", async () => { - webSocketServer.handleUserMetadata(sessionId, userMetadata); - }); - - await expect( - baseDriver.waitUntilWebsocketEvent(client, USER_METADATA, async () => - client.connect(), - ), - ).resolves.toEqual([userMetadata]); - }); - }); - }); - - describe("Client Sent Events: ", () => { - describe(EVENT_CHANGE_PROCESSED, () => { - it("listens for the `EVENT_CHANGE_PROCESSED` event", async () => { - const userId = new ObjectId().toString(); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - const connectedUserSockets = - await baseDriver.getConnectedUserClientSockets(userId, client); - - await expect( - Promise.all( - connectedUserSockets.map((socket) => - baseDriver.waitUntilWebsocketEvent( - socket, - EVENT_CHANGE_PROCESSED, - async () => - Promise.resolve(client.emit(EVENT_CHANGE_PROCESSED)), - ), - ), - ).then((res) => res.flat()), - ).resolves.toEqual([]); - }); - - it("listens for the `SOMEDAY_EVENT_CHANGE_PROCESSED` event", async () => { - const userId = new ObjectId().toString(); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - const connectedUserSockets = - await baseDriver.getConnectedUserClientSockets(userId, client); - - await expect( - Promise.all( - connectedUserSockets.map((socket) => - baseDriver.waitUntilWebsocketEvent( - socket, - SOMEDAY_EVENT_CHANGE_PROCESSED, - async () => - Promise.resolve(client.emit(SOMEDAY_EVENT_CHANGE_PROCESSED)), - ), - ), - ).then((res) => res.flat()), - ).resolves.toEqual([]); - }); - }); - - describe(FETCH_USER_METADATA, () => { - it("listens for the `FETCH_USER_METADATA` event", async () => { - const userId = new ObjectId().toString(); - - await updateUserMetadata(userId, userMetadata); - - const client = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - const connectedUserSockets = - await baseDriver.getConnectedUserClientSockets(userId, client); - - await expect( - Promise.all( - connectedUserSockets.map((socket) => - baseDriver.waitUntilWebsocketEvent( - socket, - FETCH_USER_METADATA, - async () => Promise.resolve(client.emit(FETCH_USER_METADATA)), - ), - ), - ).then((res) => res.flat()), - ).resolves.toEqual([]); - - await expect( - baseDriver.waitUntilWebsocketEvent(client, USER_METADATA), - ).resolves.toEqual([ - { - ...userMetadata, - google: { - connectionState: "NOT_CONNECTED", - }, - }, - ]); - }); - }); - }); -}); diff --git a/packages/backend/src/servers/websocket/websocket.server.ts b/packages/backend/src/servers/websocket/websocket.server.ts deleted file mode 100644 index c80ab0b66..000000000 --- a/packages/backend/src/servers/websocket/websocket.server.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { type Request } from "express"; -import { type Server as HttpServer } from "node:http"; -import { type ExtendedError, Server as SocketIOServer } from "socket.io"; -import { verifySession } from "supertokens-node/recipe/session/framework/express"; -import { - EVENT_CHANGED, - EVENT_CHANGE_PROCESSED, - FETCH_USER_METADATA, - GOOGLE_REVOKED, - IMPORT_GCAL_END, - IMPORT_GCAL_START, - RESULT_IGNORED, - RESULT_NOTIFIED_CLIENT, - SOMEDAY_EVENT_CHANGED, - SOMEDAY_EVENT_CHANGE_PROCESSED, - USER_METADATA, -} from "@core/constants/websocket.constants"; -import { Logger } from "@core/logger/winston.logger"; -import { StringV4Schema } from "@core/types/type.utils"; -import { type UserMetadata } from "@core/types/user.types"; -import { - type ClientToServerEvents, - type CompassSocket, - type CompassSocketServer, - type ImportGCalEndPayload, - type InterServerEvents, - type ServerToClientEvents, - type SocketData, -} from "@core/types/websocket.types"; -import { ENV } from "@backend/common/constants/env.constants"; -import { error } from "@backend/common/errors/handlers/error.handler"; -import { SocketError } from "@backend/common/errors/socket/socket.errors"; -import { handleWsError } from "@backend/servers/websocket/websocket.util"; -import userMetadataService from "@backend/user/services/user-metadata.service"; - -const logger = Logger("app:websocket.server"); - -class WebSocketServer { - #sessionConnections = new Map(); - #userConnections = new Map(); - - private wsServer?: CompassSocketServer; - - private onConnection(socket: CompassSocket) { - const userId = socket.data.session.getUserId(); - - const sessionId = StringV4Schema.parse(socket.data.session.getHandle()); - - const userConnections = this.#userConnections.get(userId) ?? []; - - this.#sessionConnections.set(sessionId, socket.id); - this.#userConnections.set(userId, userConnections?.concat(socket.id)); - - logger.debug(`Connection made to: ${socket.id}`); - - socket.on( - "disconnect", - handleWsError(this.onDisconnect({ socket, userId, sessionId })), - ); - - socket.on( - EVENT_CHANGE_PROCESSED, - handleWsError(() => { - logger.debug( - `Client(${socket.id}) successfully processed event changes`, - ); - }), - ); - - socket.on( - SOMEDAY_EVENT_CHANGE_PROCESSED, - handleWsError(() => { - logger.debug( - `Client(${socket.id}) successfully processed someday event changes`, - ); - }), - ); - - socket.on( - FETCH_USER_METADATA, - handleWsError(() => - userMetadataService - .fetchUserMetadata(socket.data.session.getUserId()) - .then((data) => this.handleUserMetadata(sessionId, data)), - ), - ); - } - - private onDisconnect({ - socket, - userId, - sessionId, - }: { - socket: CompassSocket; - userId: string; - sessionId: string; - }): () => void { - return () => { - logger.debug(`Disconnecting from: ${socket.id}`); - - const userConnections = this.#userConnections.get(userId)!; - - this.#sessionConnections.delete(sessionId); - - this.#userConnections.set( - userId, - userConnections.filter((id) => id !== socket.id), - ); - }; - } - - private bindSessionToSocket( - socket: CompassSocket, - next: (err?: ExtendedError) => void, - ) { - try { - const session = (socket.request as Request).session; - - if (!session) { - return next(error(SocketError.SessionNotFound, "Session not found")); - } - - socket.data.session = session; - - next(); - } catch (error) { - logger.error("WebSocket Error:\n\t", error); - return next(error as ExtendedError); - } - } - - private generateId(req: Request): string { - return StringV4Schema.parse(req.session?.getHandle()); - } - - private notifyClient( - socketId: string, - event: keyof ServerToClientEvents, - ...payload: Parameters - ) { - if (this.wsServer === undefined) { - throw error(SocketError.ServerNotReady, "Client not notified"); - } - - const socket = this.wsServer.sockets.sockets.get(socketId); - - const isClientConnected = socket?.connected; - - if (!isClientConnected) return RESULT_IGNORED; - - socket.emit(event, ...payload); - - return RESULT_NOTIFIED_CLIENT; - } - - /* - * notifyUser - * - * Notify all the sessions of an active user - * all logged in device sessions for this user - * - * @private - * - * @memberOf WebSocketServer - */ - private notifyUser( - userId: string, - event: keyof ServerToClientEvents, - ...payload: Parameters - ) { - return this.#userConnections - .get(userId) - ?.map((socketId) => this.notifyClient(socketId, event, ...payload)); - } - - /* - * notifySession - * - * Notify a specific session of an active user - * single logged in user device session - * - * @private - * - * @memberOf WebSocketServer - */ - private notifySession( - sessionId: string, - event: keyof ServerToClientEvents, - ...payload: Parameters - ) { - const socketId = this.#sessionConnections.get(sessionId); - - return this.notifyClient(socketId!, event, ...payload); - } - - init(server: HttpServer) { - this.wsServer = new SocketIOServer< - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData - >(server, { - cors: { origin: ENV.ORIGINS_ALLOWED, credentials: true }, - transports: ["websocket", "polling"], - }); - - this.wsServer.engine.use(verifySession()); - - this.wsServer.engine.generateId = this.generateId.bind(this); - - this.wsServer.use(this.bindSessionToSocket.bind(this)); - - this.wsServer.on("connection", handleWsError(this.onConnection.bind(this))); - - this.wsServer.engine.on("connection_error", (err: Error) => { - logger.debug(`Connection error: ${err.message}`); - }); - - return this.wsServer; - } - - handleUserMetadata(sessionId: string, payload: UserMetadata) { - return this.notifySession(sessionId, USER_METADATA, payload); - } - - handleImportGCalStart(userId: string) { - return this.notifyUser(userId, IMPORT_GCAL_START); - } - - handleImportGCalEnd(userId: string, payload?: ImportGCalEndPayload) { - return this.notifyUser(userId, IMPORT_GCAL_END, payload); - } - - handleBackgroundCalendarChange(userId: string) { - return this.notifyUser(userId, EVENT_CHANGED); - } - - handleBackgroundSomedayChange(userId: string) { - return this.notifyUser(userId, SOMEDAY_EVENT_CHANGED); - } - - handleGoogleRevoked(userId: string) { - return this.notifyUser(userId, GOOGLE_REVOKED); - } -} - -export const webSocketServer = new WebSocketServer(); diff --git a/packages/backend/src/servers/websocket/websocket.util.ts b/packages/backend/src/servers/websocket/websocket.util.ts deleted file mode 100644 index 1b493d1fa..000000000 --- a/packages/backend/src/servers/websocket/websocket.util.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type Server as HttpServer } from "http"; -import { type AddressInfo } from "node:net"; -import { type BaseError } from "@core/errors/errors.base"; -import { Logger } from "@core/logger/winston.logger"; - -const logger = Logger("app:websocket.util"); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type HandlerFunction = (...args: T) => R | Promise; - -export const getServerUri = (httpServer: HttpServer) => { - const port = (httpServer.address() as AddressInfo).port; - return `http://localhost:${port}`; -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const handleWsError = ( - handler: HandlerFunction, -) => { - const handleError = (err: BaseError) => { - logger.error("WebSocket Error:\n\t", err); - throw err; - }; - - return (...args: T): R | void | Promise => { - try { - const ret = handler(...args); - const isHandlerAsync = - ret && typeof (ret as Promise).catch === "function"; - if (isHandlerAsync) { - (ret as Promise).catch(handleError); - } - return ret; - } catch (e) { - // sync handler - handleError(e as BaseError); - } - }; -}; diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 69135ff88..cd0d1a069 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -1,20 +1,17 @@ import { ObjectId, type WithId } from "mongodb"; import { randomUUID } from "node:crypto"; -import { type DefaultEventsMap } from "socket.io"; -import { type Socket } from "socket.io-client"; import { faker } from "@faker-js/faker"; import { EVENT_CHANGED, GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, -} from "@core/constants/websocket.constants"; +} from "@core/constants/sse.constants"; import { Status } from "@core/errors/status.codes"; +import { type ImportGCalEndPayload } from "@core/types/sse.types"; import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; import { type Schema_User } from "@core/types/user.types"; -import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { isBase, isInstance } from "@core/util/event/event.util"; -import { waitUntilEvent } from "@core/util/wait-until-event.util"; import { BaseDriver } from "@backend/__tests__/drivers/base.driver"; import { SyncControllerDriver } from "@backend/__tests__/drivers/sync.controller.driver"; import { SyncDriver } from "@backend/__tests__/drivers/sync.driver"; @@ -33,7 +30,7 @@ import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error import { missingRefreshTokenError } from "@backend/__tests__/mocks.gcal/errors/error.missingRefreshToken"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import { GCalNotificationHandler } from "@backend/sync/services/notify/handler/gcal.notification.handler"; import syncService from "@backend/sync/services/sync.service"; import * as syncQueries from "@backend/sync/util/sync.queries"; @@ -68,100 +65,32 @@ describe("SyncController", () => { return result as ImportSummary; } - async function waitUntilImportGCalStart( - websocketClient: Socket, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: void[]) => Promise = (...args) => - Promise.resolve(args as Result), - ): Promise { - return waitUntilEvent( - websocketClient, - IMPORT_GCAL_START, - importTimeoutMs, - beforeEvent, - afterEvent, - ); - } - - async function waitUntilImportGCalEnd( - websocketClient: Socket, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: ( - ...args: [ImportGCalEndPayload | undefined] - ) => Promise = (...args) => Promise.resolve(args as Result), - ): Promise { - return waitUntilEvent( - websocketClient, - IMPORT_GCAL_END, - importTimeoutMs, - beforeEvent, - afterEvent, - ); - } - - async function waitUntilUserWebsocketEvent< - Payload extends unknown[], - Result = Payload, - >( - websocketClient: Socket, - event: string, - beforeEvent: () => Promise = () => Promise.resolve(), - afterEvent: (...args: Payload) => Promise = (...args) => - Promise.resolve(args as unknown as Result), - ): Promise { - return waitUntilEvent( - websocketClient, - event, - importTimeoutMs, - beforeEvent, - afterEvent, - ); - } - - async function websocketUserFlow(waitForEventChanged = false): Promise<{ + async function sseUserFlow(waitForEventChanged = false): Promise<{ user: WithId; - websocketClient: Socket; }> { const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); - const websocketClient = baseDriver.createWebsocketClient( - { userId: user._id.toString(), sessionId: randomUUID() }, - { autoConnect: false }, - ); - - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), - ); + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); - const [importEnd, eventChanged] = await Promise.allSettled([ - waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId: user._id.toString() }), - (reason) => Promise.resolve(reason), - ), + await Promise.allSettled([ + stream.waitForEvent(IMPORT_GCAL_END, importTimeoutMs), + syncDriver.importGCal({ userId }), ...(waitForEventChanged - ? [waitUntilUserWebsocketEvent(websocketClient, EVENT_CHANGED)] + ? [stream.waitForEvent(EVENT_CHANGED, importTimeoutMs)] : []), ]); - expect(importEnd.status).toEqual("fulfilled"); - const importResult = (importEnd as { value: unknown })?.value as - | ImportGCalEndPayload - | undefined; - const parsed = parseImportResult(importResult); - expect(parsed).toHaveProperty("eventsCount"); - expect(parsed).toHaveProperty("calendarsCount"); + stream.close(); - if (waitForEventChanged) expect(eventChanged?.status).toEqual("fulfilled"); - - return { user, websocketClient }; + return { user }; } beforeAll(async () => { await setupTestDb(); - - baseDriver.initWebsocketServer(); - await baseDriver.listen(); }); @@ -344,13 +273,10 @@ describe("SyncController", () => { .spyOn(GCalNotificationHandler.prototype, "handleNotification") .mockResolvedValue({ summary: "PROCESSED", changes: [] }); const backgroundChangeSpy = jest.spyOn( - webSocketServer, + sseServer, "handleBackgroundCalendarChange", ); - const importStartSpy = jest.spyOn( - webSocketServer, - "handleImportGCalStart", - ); + const importStartSpy = jest.spyOn(sseServer, "handleImportGCalStart"); const activeResponse = await syncDriver.handleGoogleNotification( { @@ -388,7 +314,7 @@ describe("SyncController", () => { importStartSpy.mockRestore(); }); - it("should prune Google data, notify client via websocket, and return structured response when user revokes access", async () => { + it("should prune Google data, notify client via SSE, and return structured response when user revokes access", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); @@ -409,7 +335,7 @@ describe("SyncController", () => { .mockResolvedValue(); const handleGoogleRevokedSpy = jest.spyOn( - webSocketServer, + sseServer, "handleGoogleRevoked", ); @@ -436,7 +362,7 @@ describe("SyncController", () => { handleGoogleRevokedSpy.mockRestore(); }); - it("should prune Google data, notify client via websocket, and return structured response when refresh token is missing", async () => { + it("should prune Google data, notify client via SSE, and return structured response when refresh token is missing", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); @@ -457,7 +383,7 @@ describe("SyncController", () => { .mockResolvedValue(); const handleGoogleRevokedSpy = jest.spyOn( - webSocketServer, + sseServer, "handleGoogleRevoked", ); @@ -511,7 +437,7 @@ describe("SyncController", () => { it("should import the first instance of a recurring event (and the base)", async () => { // Importing both the base and first instance helps us find the series recurrence rule. // To prevent duplicates in the UI, the GET API will not return the base event - const { user } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const currentEventsInDb = await getEventsInDb({ user: user._id.toString(), @@ -529,7 +455,7 @@ describe("SyncController", () => { }); it("should connect instances to their base events", async () => { - const { user } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const { baseEvents, instanceEvents } = await getCategorizedEventsInDb({ user: user._id.toString(), @@ -545,7 +471,7 @@ describe("SyncController", () => { }); it("should include regular and recurring events and skip cancelled events", async () => { - const { user } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const currentEventsInDb = await getEventsInDb({ user: user._id.toString(), @@ -580,7 +506,7 @@ describe("SyncController", () => { }); it("should not create duplicate events for recurring events", async () => { - const { user } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const currentEventsInDb = await getEventsInDb({ user: user._id.toString(), @@ -604,7 +530,7 @@ describe("SyncController", () => { }); it("should not create duplicate events for regular events", async () => { - const { user } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const currentEventsInDb = await getEventsInDb({ user: user._id.toString(), @@ -619,7 +545,7 @@ describe("SyncController", () => { }); it("should resume import using stored nextPageToken", async () => { - const { user, websocketClient } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const userId = user._id.toString(); const getGCalEventsSyncPageTokenSpy = jest @@ -637,9 +563,17 @@ describe("SyncController", () => { data: { sync: { importGCal: "RESTART" } }, }); - await waitUntilImportGCalEnd(websocketClient, () => - syncDriver.importGCal({ userId }), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + await importEndPromise; + stream.close(); expect(getAllEventsSpy).toHaveBeenCalledWith( expect.objectContaining({ pageToken: "5" }), @@ -652,7 +586,7 @@ describe("SyncController", () => { describe("Import Status:", () => { it("should force a repair import even after a completed sync", async () => { - const { user, websocketClient } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const userId = user._id.toString(); const getAllEventsSpy = jest.spyOn(gcalService, "getAllEvents"); @@ -661,27 +595,29 @@ describe("SyncController", () => { expect(sync?.importGCal).toEqual("COMPLETED"); - const result = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }, { force: true }), - (reason) => Promise.resolve(reason), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }, { force: true }); + const result = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); - const parsed = parseImportResult(result as ImportGCalEndPayload); + const parsed = parseImportResult(result); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); expect(getAllEventsSpy).toHaveBeenCalled(); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); - getAllEventsSpy.mockRestore(); }); it("should not retry import once it has completed", async () => { - const { user, websocketClient } = await websocketUserFlow(true); + const { user } = await sseUserFlow(true); const userId = user._id.toString(); const { sync } = await userMetadataService.fetchUserMetadata(userId); @@ -694,11 +630,17 @@ describe("SyncController", () => { const getAllEventsSpy = jest.spyOn(gcalService, "getAllEvents"); - const failReason = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }), - (reason) => Promise.resolve(reason), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + const failReason = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); expect(failReason).toEqual({ operation: "REPAIR", @@ -708,10 +650,6 @@ describe("SyncController", () => { expect(getAllEventsSpy).not.toHaveBeenCalled(); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); - getAllEventsSpy.mockRestore(); getGCalEventsSyncPageTokenSpy.mockRestore(); }); @@ -727,25 +665,22 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId, sessionId: randomUUID() }, - { autoConnect: false }, - ); - - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), - ); - await userMetadataService.updateUserMetadata({ userId, data: { sync: { importGCal: "IMPORTING" } }, }); - const failReason = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }), - (reason) => Promise.resolve(reason), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + const failReason = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); expect(failReason).toEqual({ operation: "REPAIR", @@ -755,10 +690,6 @@ describe("SyncController", () => { expect(getAllEventsSpy).not.toHaveBeenCalled(); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); - getAllEventsSpy.mockRestore(); getGCalEventsSyncPageTokenSpy.mockRestore(); }); @@ -774,27 +705,24 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId, sessionId: randomUUID() }, - { autoConnect: false }, - ); - - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), - ); - await userMetadataService.updateUserMetadata({ userId, data: { sync: { importGCal: "RESTART" } }, }); - const result = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }), - (reason) => Promise.resolve(reason), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + const result = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); - const parsed = parseImportResult(result as ImportGCalEndPayload); + const parsed = parseImportResult(result); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -802,10 +730,6 @@ describe("SyncController", () => { expect.objectContaining({ pageToken: "5" }), ); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); - getAllEventsSpy.mockRestore(); getGCalEventsSyncPageTokenSpy.mockRestore(); }); @@ -821,27 +745,24 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId, sessionId: randomUUID() }, - { autoConnect: false }, - ); - - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), - ); - await userMetadataService.updateUserMetadata({ userId, data: { sync: { importGCal: "ERRORED" } }, }); - const result = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }), - (reason) => Promise.resolve(reason), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + const result = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); - const parsed = parseImportResult(result as ImportGCalEndPayload); + const parsed = parseImportResult(result); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); @@ -849,10 +770,6 @@ describe("SyncController", () => { expect.objectContaining({ pageToken: "5" }), ); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); - getAllEventsSpy.mockRestore(); getGCalEventsSyncPageTokenSpy.mockRestore(); }); @@ -865,26 +782,16 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); + const importStartSpy = jest.spyOn(sseServer, "handleImportGCalStart"); - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), - ); + await syncDriver.importGCal({ userId }); - await expect( - waitUntilImportGCalStart( - websocketClient, - () => syncDriver.importGCal({ userId }), - () => Promise.resolve(true), - ), - ).resolves.toBeTruthy(); + // Wait a tick for the async fire-and-forget to run + await new Promise((resolve) => setTimeout(resolve, 100)); - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); + expect(importStartSpy).toHaveBeenCalledWith(userId); + + importStartSpy.mockRestore(); }); it("should notify the frontend that the import is complete", async () => { @@ -893,27 +800,21 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, - ); - - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + const result = (await importEndPromise) as ImportGCalEndPayload; + stream.close(); - const result = await waitUntilImportGCalEnd( - websocketClient, - () => syncDriver.importGCal({ userId }), - (reason) => Promise.resolve(reason), - ); - const parsed = parseImportResult(result as ImportGCalEndPayload); + const parsed = parseImportResult(result); expect(parsed).toHaveProperty("eventsCount"); expect(parsed).toHaveProperty("calendarsCount"); - - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); }); it("should notify the frontend to refetch the calendar events on completion", async () => { @@ -922,27 +823,26 @@ describe("SyncController", () => { await SyncDriver.createSync(user); - const websocketClient = baseDriver.createWebsocketClient( - { userId }, - { autoConnect: false }, + const backgroundChangeSpy = jest.spyOn( + sseServer, + "handleBackgroundCalendarChange", ); - await waitUntilEvent(websocketClient, "connect", 100, () => - Promise.resolve(websocketClient.connect()), + const stream = baseDriver.openSSEStream({ + userId, + sessionId: randomUUID(), + }); + const importEndPromise = stream.waitForEvent( + IMPORT_GCAL_END, + importTimeoutMs, ); + await syncDriver.importGCal({ userId }); + await importEndPromise; + stream.close(); - await expect( - waitUntilUserWebsocketEvent( - websocketClient, - EVENT_CHANGED, - () => syncDriver.importGCal({ userId }), - () => Promise.resolve(true), - ), - ).resolves.toBeTruthy(); - - await waitUntilEvent(websocketClient, "disconnect", 100, () => - Promise.resolve(websocketClient.disconnect()), - ); + expect(backgroundChangeSpy).toHaveBeenCalledWith(userId); + + backgroundChangeSpy.mockRestore(); }); }); }); diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index afaf5b0a1..33eedf43a 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -2,7 +2,7 @@ import { type NextFunction, type Request, type Response } from "express"; import { ObjectId } from "mongodb"; import { ZodError } from "zod/v4"; import { COMPASS_RESOURCE_HEADER } from "@core/constants/core.constants"; -import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; +import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { @@ -19,7 +19,7 @@ import { isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import syncService from "@backend/sync/services/sync.service"; import { getSync } from "@backend/sync/util/sync.queries"; import { isMissingGoogleRefreshToken } from "@backend/sync/util/sync.util"; @@ -310,5 +310,5 @@ const pruneAndNotifyGoogleRevoked = async ( ): Promise => { logger.warn(`Cleaning data after ${reason} for user: ${userId}`); await userService.pruneGoogleData(userId); - webSocketServer.handleGoogleRevoked(userId); + sseServer.handleGoogleRevoked(userId); }; diff --git a/packages/backend/src/sync/controllers/sync.debug.controller.ts b/packages/backend/src/sync/controllers/sync.debug.controller.ts index 87b7b0f84..62c21d622 100644 --- a/packages/backend/src/sync/controllers/sync.debug.controller.ts +++ b/packages/backend/src/sync/controllers/sync.debug.controller.ts @@ -6,14 +6,14 @@ import { type Res_Promise, type SReqBody, } from "@backend/common/types/express.types"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import syncService from "../services/sync.service"; import { getSync } from "../util/sync.queries"; class SyncDebugController { dispatchEventToClient = (_req: Request, res: Response) => { try { - const userId = process.env["SOCKET_USER"]; + const userId = process.env["SSE_DEBUG_USER"]; if (!userId) { throw new Error("No demo user"); } @@ -21,7 +21,7 @@ class SyncDebugController { const endDate = new Date(startDate); endDate.setDate(startDate.getDate() + 1); - webSocketServer.handleBackgroundCalendarChange(userId); + sseServer.handleBackgroundCalendarChange(userId); res.sendStatus(200); } catch (e) { console.error("Error during dispatch:", e); diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts index 6e4a65078..abf005719 100644 --- a/packages/backend/src/sync/services/sync.service.test.ts +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -17,7 +17,7 @@ import calendarService from "@backend/calendar/services/calendar.service"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import * as syncImportService from "@backend/sync/services/import/sync.import"; import syncService from "@backend/sync/services/sync.service"; import { isUsingHttps } from "@backend/sync/util/sync.util"; @@ -283,7 +283,7 @@ describe("SyncService", () => { it("emits INCREMENTAL operation when incremental import is ignored", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); await userMetadataService.updateUserMetadata({ userId, @@ -302,7 +302,7 @@ describe("SyncService", () => { it("emits INCREMENTAL operation when incremental import completes", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); const createSyncImportSpy = jest .spyOn(syncImportService, "createSyncImport") .mockResolvedValue({ @@ -324,7 +324,7 @@ describe("SyncService", () => { it("emits INCREMENTAL operation when incremental import fails", async () => { const user = await UserDriver.createUser(); const userId = user._id.toString(); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); const error = new Error("incremental failed"); const createSyncImportSpy = jest .spyOn(syncImportService, "createSyncImport") @@ -519,7 +519,7 @@ describe("SyncService", () => { it("cleans up partial watch state when restart fails", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); const stopWatchesSpy = jest .spyOn(syncService, "stopWatches") .mockImplementation(async (targetUserId) => { @@ -566,11 +566,8 @@ describe("SyncService", () => { it("prunes Google data and notifies revoked state when repair loses access", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); - const googleRevokedSpy = jest.spyOn( - webSocketServer, - "handleGoogleRevoked", - ); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const googleRevokedSpy = jest.spyOn(sseServer, "handleGoogleRevoked"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); const startSpy = jest .spyOn(syncService, "startGoogleCalendarSync") .mockRejectedValue(invalidGrant400Error); @@ -599,7 +596,7 @@ describe("SyncService", () => { it("emits a friendly quota error when Google repair hits rate limits", async () => { const { user } = await UtilDriver.setupTestUser(); const userId = user._id.toString(); - const importEndSpy = jest.spyOn(webSocketServer, "handleImportGCalEnd"); + const importEndSpy = jest.spyOn(sseServer, "handleImportGCalEnd"); const quotaError = createGoogleError({ code: "403", responseStatus: 403, diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index 7a75c7bc7..b0f5a313c 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -1,5 +1,4 @@ import { type ClientSession, type Filter, ObjectId } from "mongodb"; -import { RESULT_NOTIFIED_CLIENT } from "@core/constants/websocket.constants"; import { Logger } from "@core/logger/winston.logger"; import { MapEvent } from "@core/mappers/map.event"; import { @@ -36,7 +35,7 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { _createGcal } from "@backend/event/services/event.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import { createSyncImport } from "@backend/sync/services/import/sync.import"; import { prepWatchMaintenanceForUser, @@ -250,11 +249,9 @@ class SyncService { await handler.handleNotification(); - const wsResult = webSocketServer.handleBackgroundCalendarChange(userId); + sseServer.handleBackgroundCalendarChange(userId); - const result = wsResult?.includes(RESULT_NOTIFIED_CLIENT) - ? "PROCESSED AND NOTIFIED CLIENT" - : "PROCESSED IN BACKGROUND"; + const result = "PROCESSED"; logger.info( `GCal Notification for user: ${userId}, calendarId: ${calendarId} ${result}`, @@ -317,7 +314,7 @@ class SyncService { ); try { - webSocketServer.handleImportGCalStart(userId); + sseServer.handleImportGCalStart(userId); const userMeta = await userMetadataService.fetchUserMetadata( userId, @@ -329,7 +326,7 @@ class SyncService { const proceed = shouldDoIncrementalGCalSync(userMeta); if (!proceed) { - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "INCREMENTAL", status: "IGNORED", message: `User ${userId} gcal incremental sync is in progress or completed, ignoring this request`, @@ -354,11 +351,11 @@ class SyncService { data: { sync: { incrementalGCalSync: "COMPLETED" } }, }); - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "INCREMENTAL", status: "COMPLETED", }); - webSocketServer.handleBackgroundCalendarChange(userId); + sseServer.handleBackgroundCalendarChange(userId); return result; } catch (error) { @@ -372,7 +369,7 @@ class SyncService { error, ); - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "INCREMENTAL", status: "ERRORED", message: `Incremental Google Calendar sync failed for user: ${userId}`, @@ -541,7 +538,7 @@ class SyncService { const proceed = isForce ? !isImporting : shouldImportGCal(userMeta); if (!proceed) { - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "REPAIR", status: "IGNORED", message: `User ${userId} gcal import is in progress or completed, ignoring this request`, @@ -553,7 +550,7 @@ class SyncService { logger.warn( `Restarting Google Calendar sync for user: ${userId}${isForce ? " (forced)" : ""}`, ); - webSocketServer.handleImportGCalStart(userId); + sseServer.handleImportGCalStart(userId); await userMetadataService.updateUserMetadata({ userId, data: { sync: { importGCal: "IMPORTING" } }, @@ -574,12 +571,12 @@ class SyncService { data: { sync: { importGCal: "COMPLETED" } }, }); - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "REPAIR", status: "COMPLETED", ...importResults, }); - webSocketServer.handleBackgroundCalendarChange(userId); + sseServer.handleBackgroundCalendarChange(userId); } catch (err) { try { await userService.stopGoogleCalendarSync(userId); @@ -596,7 +593,7 @@ class SyncService { ); await userService.pruneGoogleData(userId); - webSocketServer.handleGoogleRevoked(userId); + sseServer.handleGoogleRevoked(userId); return; } @@ -607,7 +604,7 @@ class SyncService { logger.error(`Re-sync failed for user: ${userId}`, err); - webSocketServer.handleImportGCalEnd(userId, { + sseServer.handleImportGCalEnd(userId, { operation: "REPAIR", status: "ERRORED", message: getGoogleRepairErrorMessage(err), diff --git a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts index 498bc6c07..2b2f7c937 100644 --- a/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts +++ b/packages/backend/src/sync/services/sync/__tests__/compass.sync.processor.test.ts @@ -3,7 +3,7 @@ import { faker } from "@faker-js/faker"; import { EVENT_CHANGED, SOMEDAY_EVENT_CHANGED, -} from "@core/constants/websocket.constants"; +} from "@core/constants/sse.constants"; import { CalendarProvider, Categories_Recurrence, @@ -21,7 +21,7 @@ import { type CompassApplyResult } from "@backend/event/classes/compass.event.ex import * as compassExecutor from "@backend/event/classes/compass.event.executor"; import * as compassParser from "@backend/event/classes/compass.event.parser"; import * as eventService from "@backend/event/services/event.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import { CompassSyncProcessor } from "@backend/sync/services/sync/compass/compass.sync.processor"; import { type Event_Transition } from "@backend/sync/sync.types"; @@ -79,20 +79,14 @@ describe("CompassSyncProcessor.getNotificationType", () => { describe("CompassSyncProcessor.notifyClients", () => { beforeEach(() => { - jest.spyOn(webSocketServer, "handleBackgroundCalendarChange").mockClear(); - jest.spyOn(webSocketServer, "handleBackgroundSomedayChange").mockClear(); + jest.spyOn(sseServer, "handleBackgroundCalendarChange").mockClear(); + jest.spyOn(sseServer, "handleBackgroundSomedayChange").mockClear(); }); it("notifies correct users and events", () => { - const calendarSpy = jest.spyOn( - webSocketServer, - "handleBackgroundCalendarChange", - ); + const calendarSpy = jest.spyOn(sseServer, "handleBackgroundCalendarChange"); - const somedaySpy = jest.spyOn( - webSocketServer, - "handleBackgroundSomedayChange", - ); + const somedaySpy = jest.spyOn(sseServer, "handleBackgroundSomedayChange"); const applyTo = RecurringEventUpdateScope.THIS_EVENT; const status = CompassEventStatus.CONFIRMED; diff --git a/packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts b/packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts index 0c4f4be03..2a37b7ad1 100644 --- a/packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts +++ b/packages/backend/src/sync/services/sync/compass/compass.sync.processor.ts @@ -2,7 +2,7 @@ import { type ClientSession, ObjectId } from "mongodb"; import { EVENT_CHANGED, SOMEDAY_EVENT_CHANGED, -} from "@core/constants/websocket.constants"; +} from "@core/constants/sse.constants"; import { Logger } from "@core/logger/winston.logger"; import { type CompassEvent } from "@core/types/event.types"; import { GenericError } from "@backend/common/errors/generic/generic.errors"; @@ -19,7 +19,7 @@ import { _deleteGcal, _updateGcal, } from "@backend/event/services/event.service"; -import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import { sseServer } from "@backend/servers/sse/sse.server"; import { type Event_Transition } from "@backend/sync/sync.types"; import { isMissingGoogleRefreshToken } from "@backend/sync/util/sync.util"; import { @@ -105,10 +105,10 @@ export class CompassSyncProcessor { notifications.forEach((notification) => { switch (notification) { case EVENT_CHANGED: - webSocketServer.handleBackgroundCalendarChange(userId); + sseServer.handleBackgroundCalendarChange(userId); break; case SOMEDAY_EVENT_CHANGED: - webSocketServer.handleBackgroundSomedayChange(userId); + sseServer.handleBackgroundSomedayChange(userId); break; default: logger.error(`Unknown notification type for user: ${userId}`); diff --git a/packages/core/src/constants/sse.constants.ts b/packages/core/src/constants/sse.constants.ts new file mode 100644 index 000000000..187ce4f3e --- /dev/null +++ b/packages/core/src/constants/sse.constants.ts @@ -0,0 +1,10 @@ +/** + * SSE event names (`event:` field) shared by backend and web. + * Use these for `EventSource.addEventListener` and server `publish`. + */ +export const EVENT_CHANGED = "EVENT_CHANGED"; +export const GOOGLE_REVOKED = "GOOGLE_REVOKED"; +export const IMPORT_GCAL_END = "IMPORT_GCAL_END"; +export const IMPORT_GCAL_START = "IMPORT_GCAL_START"; +export const SOMEDAY_EVENT_CHANGED = "SOMEDAY_EVENT_CHANGED"; +export const USER_METADATA = "USER_METADATA"; diff --git a/packages/core/src/constants/websocket.constants.ts b/packages/core/src/constants/websocket.constants.ts deleted file mode 100644 index ee22e33f0..000000000 --- a/packages/core/src/constants/websocket.constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -// server to client events -export const EVENT_CHANGED = "EVENT_CHANGED"; -export const SOMEDAY_EVENT_CHANGED = "SOMEDAY_EVENT_CHANGED"; -export const EVENT_RECEIVED = "EVENT_RECEIVED"; - -export const RESULT_IGNORED = "IGNORED"; -export const RESULT_NOTIFIED_CLIENT = "NOTIFIED_CLIENT"; - -export const USER_METADATA = "USER_METADATA"; - -export const IMPORT_GCAL_START = "IMPORT_GCAL_START"; -export const IMPORT_GCAL_END = "IMPORT_GCAL_END"; - -export const GOOGLE_REVOKED = "GOOGLE_REVOKED"; - -// client to server events -export const EVENT_CHANGE_PROCESSED = "EVENT_CHANGE_PROCESSED"; -export const SOMEDAY_EVENT_CHANGE_PROCESSED = "SOMEDAY_EVENT_CHANGE_PROCESSED"; - -export const FETCH_USER_METADATA = "FETCH_USER_METADATA"; diff --git a/packages/core/src/types/sse.types.ts b/packages/core/src/types/sse.types.ts new file mode 100644 index 000000000..82e9a6c80 --- /dev/null +++ b/packages/core/src/types/sse.types.ts @@ -0,0 +1,14 @@ +export type ImportGCalOperation = "INCREMENTAL" | "REPAIR"; + +export type ImportGCalEndPayload = + | { + operation: ImportGCalOperation; + status: "COMPLETED"; + eventsCount?: number; + calendarsCount?: number; + } + | { + operation: ImportGCalOperation; + status: "ERRORED" | "IGNORED"; + message: string; + }; diff --git a/packages/core/src/types/websocket.types.ts b/packages/core/src/types/websocket.types.ts deleted file mode 100644 index 3e1c6dc89..000000000 --- a/packages/core/src/types/websocket.types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type Request } from "express"; -import { type Socket, type Server as SocketIOServer } from "socket.io"; -import { type Schema_Event } from "@core/types/event.types"; -import { type UserMetadata } from "@core/types/user.types"; - -export type ImportGCalOperation = "INCREMENTAL" | "REPAIR"; - -export type ImportGCalEndPayload = - | { - operation: ImportGCalOperation; - status: "COMPLETED"; - eventsCount?: number; - calendarsCount?: number; - } - | { - operation: ImportGCalOperation; - status: "ERRORED" | "IGNORED"; - message: string; - }; - -export interface ClientToServerEvents { - EVENT_CHANGE_PROCESSED: () => void; - SOMEDAY_EVENT_CHANGE_PROCESSED: () => void; - FETCH_USER_METADATA: () => void; -} - -export type CompassSocketServer = SocketIOServer< - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData ->; - -export type CompassSocket = Socket< - ClientToServerEvents, - ServerToClientEvents, - InterServerEvents, - SocketData ->; - -export interface InterServerEvents { - EVENT_RECEIVED: (data: Schema_Event) => Schema_Event; -} - -export interface ServerToClientEvents { - EVENT_CHANGED: () => void; - SOMEDAY_EVENT_CHANGED: () => void; - USER_METADATA: (data: UserMetadata) => void; - IMPORT_GCAL_START: () => void; - IMPORT_GCAL_END: (payload?: ImportGCalEndPayload) => void; - GOOGLE_REVOKED: () => void; -} - -export interface SocketData { - session: Exclude; -} diff --git a/packages/core/src/util/wait-until-event.util.ts b/packages/core/src/util/wait-until-event.util.ts index 74fa13016..2288a363a 100644 --- a/packages/core/src/util/wait-until-event.util.ts +++ b/packages/core/src/util/wait-until-event.util.ts @@ -1,27 +1,12 @@ import type { EventEmitter2 } from "eventemitter2"; import type EventEmitter from "node:events"; import type { Server } from "node:http"; -import type { Socket } from "socket.io"; -import type { Socket as Client } from "socket.io-client"; -import type { - CompassSocket, - CompassSocketServer, -} from "@core/types/websocket.types"; export async function waitUntilEvent< Payload extends unknown[], Result = Payload, >( - emitter: Pick< - | Socket - | CompassSocket - | Client - | Server - | CompassSocketServer - | EventEmitter - | EventEmitter2, - "once" - >, + emitter: Pick, event: Parameters<(typeof emitter)["once"]>["0"], timeoutMs: number = 2000, beforeEvent: () => Promise = () => Promise.resolve(), diff --git a/packages/web/package.json b/packages/web/package.json index f69fb53f5..1fce1c5dd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -46,7 +46,6 @@ "redux-saga": "^1.1.3", "regenerator-runtime": "^0.14.1", "rxjs": "^7.8.0", - "socket.io-client": "^4.7.5", "style-loader": "^3.2.1", "styled-components": "^5.3.1", "supertokens-web-js": "^0.16.0", diff --git a/packages/web/src/auth/google/google.auth.util.test.ts b/packages/web/src/auth/google/google.auth.util.test.ts index 0e810fe21..28177ae9b 100644 --- a/packages/web/src/auth/google/google.auth.util.test.ts +++ b/packages/web/src/auth/google/google.auth.util.test.ts @@ -15,7 +15,7 @@ import { importGCalSlice, triggerFetch, } from "@web/ducks/events/slices/sync.slice"; -import { reconnect } from "@web/socket/client/socket.client"; +import { closeStream, openStream } from "@web/sse/client/sse.client"; import { store } from "@web/store"; import { type GoogleAuthConfig } from "../hooks/google/googe.auth.types"; import { @@ -39,12 +39,10 @@ jest.mock("@web/store", () => ({ dispatch: jest.fn(), }, })); -jest.mock("@web/socket/client/socket.client", () => ({ - socket: { - connected: true, - emit: jest.fn(), - }, - reconnect: jest.fn(), +jest.mock("@web/sse/client/sse.client", () => ({ + closeStream: jest.fn(), + openStream: jest.fn(), + getStream: jest.fn().mockReturnValue(null), })); const mockAuthApi = AuthApi as jest.Mocked; @@ -239,10 +237,11 @@ describe("google-auth.util", () => { ); }); - it("reconnects socket so the client gets a fresh session after revocation", () => { + it("reconnects SSE stream so the client gets a fresh session after revocation", () => { handleGoogleRevoked(); - expect(reconnect).toHaveBeenCalled(); + expect(closeStream).toHaveBeenCalled(); + expect(openStream).toHaveBeenCalled(); }); it("marks Google as revoked in session state", () => { diff --git a/packages/web/src/auth/google/google.auth.util.ts b/packages/web/src/auth/google/google.auth.util.ts index f02a6f3fc..760653d22 100644 --- a/packages/web/src/auth/google/google.auth.util.ts +++ b/packages/web/src/auth/google/google.auth.util.ts @@ -16,7 +16,7 @@ import { importGCalSlice, triggerFetch, } from "@web/ducks/events/slices/sync.slice"; -import { reconnect } from "@web/socket/client/socket.client"; +import { closeStream, openStream } from "@web/sse/client/sse.client"; import { type AppDispatch, store } from "@web/store"; import { type GoogleAuthConfig } from "../hooks/google/googe.auth.types"; @@ -74,9 +74,10 @@ export const handleGoogleRevoked = () => { triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), ); - // Always reconnect so the socket gets a fresh session; the backend has pruned + // Always reconnect so the stream gets a fresh session; the backend has pruned // Google data and the current connection may carry stale auth state. - reconnect(); + closeStream(); + openStream(); }; export const showLocalEventsSyncFailure = (error: Error | undefined) => { diff --git a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts index aced7d3f5..3b4c8a439 100644 --- a/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/hooks/google/useConnectGoogle/useConnectGoogle.ts @@ -1,6 +1,6 @@ import { type AxiosError, isAxiosError } from "axios"; import { useCallback } from "react"; -import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; +import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { type GoogleConnectionState } from "@core/types/user.types"; import { syncPendingLocalEvents } from "@web/auth/google/google.auth.util"; import { useGoogleAuth } from "@web/auth/hooks/google/useGoogleAuth/useGoogleAuth"; diff --git a/packages/web/src/auth/session/SessionProvider.test.tsx b/packages/web/src/auth/session/SessionProvider.test.tsx index 63e9e07f1..e8dd65e89 100644 --- a/packages/web/src/auth/session/SessionProvider.test.tsx +++ b/packages/web/src/auth/session/SessionProvider.test.tsx @@ -11,9 +11,9 @@ describe("SessionProvider sessionInit", () => { it("refreshes user metadata when a session already exists", async () => { const refreshUserMetadata = jest.fn().mockResolvedValue(undefined); - const reconnect = jest.fn(); - const connect = jest.fn(); - const disconnect = jest.fn(); + const openStream = jest.fn(); + const closeStream = jest.fn(); + const getStream = jest.fn().mockReturnValue(null); const dispatch = jest.fn(); const markUserAsAuthenticated = jest.fn(); const getLastKnownEmail = jest.fn().mockReturnValue("test@example.com"); @@ -21,14 +21,10 @@ describe("SessionProvider sessionInit", () => { jest.doMock("@web/auth/session/user-metadata.util", () => ({ refreshUserMetadata, })); - jest.doMock("@web/socket/provider/SocketProvider", () => ({ - socket: { - connected: false, - connect, - disconnect, - }, - reconnect, - disconnect, + jest.doMock("@web/sse/provider/SSEProvider", () => ({ + openStream, + closeStream, + getStream, })); jest.doMock("@web/store", () => ({ store: { @@ -54,15 +50,15 @@ describe("SessionProvider sessionInit", () => { ); expect(refreshUserMetadata).toHaveBeenCalledTimes(1); }); - expect(connect).toHaveBeenCalledTimes(1); + expect(openStream).toHaveBeenCalledTimes(1); }); }); it("refreshes metadata on session creation and clears it on sign out", async () => { const refreshUserMetadata = jest.fn().mockResolvedValue(undefined); - const reconnect = jest.fn(); - const connect = jest.fn(); - const disconnect = jest.fn(); + const openStream = jest.fn(); + const closeStream = jest.fn(); + const getStream = jest.fn().mockReturnValue({} as EventSource); // stream already open const dispatch = jest.fn(); const markUserAsAuthenticated = jest.fn(); const getLastKnownEmail = jest.fn().mockReturnValue("test@example.com"); @@ -70,14 +66,10 @@ describe("SessionProvider sessionInit", () => { jest.doMock("@web/auth/session/user-metadata.util", () => ({ refreshUserMetadata, })); - jest.doMock("@web/socket/provider/SocketProvider", () => ({ - socket: { - connected: true, - connect, - disconnect, - }, - reconnect, - disconnect, + jest.doMock("@web/sse/provider/SSEProvider", () => ({ + openStream, + closeStream, + getStream, })); jest.doMock("@web/store", () => ({ store: { @@ -105,7 +97,9 @@ describe("SessionProvider sessionInit", () => { ); expect(refreshUserMetadata).toHaveBeenCalledTimes(1); }); - expect(reconnect).toHaveBeenCalledTimes(1); + // closeStream + openStream both called for SESSION_CREATED + expect(closeStream).toHaveBeenCalledTimes(1); + expect(openStream).toHaveBeenCalledTimes(1); session.emit("SIGN_OUT", { action: "SIGN_OUT" } as never); @@ -116,7 +110,7 @@ describe("SessionProvider sessionInit", () => { expect(dispatch).toHaveBeenCalledWith( userMetadataSlice.actions.clear(undefined), ); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(closeStream).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/web/src/auth/session/SessionProvider.tsx b/packages/web/src/auth/session/SessionProvider.tsx index af9fb6e19..33e6020de 100644 --- a/packages/web/src/auth/session/SessionProvider.tsx +++ b/packages/web/src/auth/session/SessionProvider.tsx @@ -26,7 +26,7 @@ import { ROOT_ROUTES } from "@web/common/constants/routes"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; import { importGCalSlice } from "@web/ducks/events/slices/sync.slice"; -import * as socket from "@web/socket/provider/SocketProvider"; +import * as sse from "@web/sse/provider/SSEProvider"; import { store } from "@web/store"; import { type CompassSession } from "./session.types"; import { refreshUserMetadata } from "./user-metadata.util"; @@ -89,8 +89,8 @@ async function checkIfSessionExists(): Promise { if (exists) { handleSessionExists(); - if (!socket.socket.connected) { - socket.socket.connect(); + if (!sse.getStream()) { + sse.openStream(); } } else { handleSessionMissing(); @@ -121,11 +121,12 @@ export function sessionInit() { // This ensures the flag is set even if markUserAsAuthenticated wasn't called during OAuth markUserAsAuthenticated(getLastKnownEmail()); void refreshUserMetadata(); - socket.reconnect(); + sse.closeStream(); + sse.openStream(); break; case "SIGN_OUT": store.dispatch(userMetadataSlice.actions.clear(undefined)); - socket.disconnect(); + sse.closeStream(); break; } }); diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index 25f2a625c..2e0a37921 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -1,5 +1,5 @@ import axios, { type AxiosError } from "axios"; -import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; +import { GOOGLE_REVOKED } from "@core/constants/sse.constants"; import { Status } from "@core/errors/status.codes"; import { getApiErrorCode } from "@web/common/apis/compass.api.util"; import { session } from "@web/common/classes/Session"; diff --git a/packages/web/src/ducks/events/context/sync.context.ts b/packages/web/src/ducks/events/context/sync.context.ts index c8200b8de..32d49470d 100644 --- a/packages/web/src/ducks/events/context/sync.context.ts +++ b/packages/web/src/ducks/events/context/sync.context.ts @@ -1,6 +1,8 @@ +import * as sse from "@core/constants/sse.constants"; + export enum Sync_AsyncStateContextReason { - SOCKET_EVENT_CHANGED = "SOCKET_EVENT_CHANGED", - SOCKET_SOMEDAY_EVENT_CHANGED = "SOCKET_SOMEDAY_EVENT_CHANGED", + EVENT_CHANGED = sse.EVENT_CHANGED, + SOMEDAY_EVENT_CHANGED = sse.SOMEDAY_EVENT_CHANGED, IMPORT_COMPLETE = "IMPORT_COMPLETE", - GOOGLE_REVOKED = "GOOGLE_REVOKED", + GOOGLE_REVOKED = sse.GOOGLE_REVOKED, } diff --git a/packages/web/src/routers/loaders.ts b/packages/web/src/routers/loaders.ts index 966c14ca5..c4c561c22 100644 --- a/packages/web/src/routers/loaders.ts +++ b/packages/web/src/routers/loaders.ts @@ -9,6 +9,13 @@ export interface DayLoaderData { } export async function loadAuthenticated() { + // Playwright e2e serves the web app without a backend; SuperTokens session + // checks can block navigation until the HTTP client times out. The e2e + // webpack build uses NODE_ENV=test (see playwright.config.ts webServer env). + if (process.env.NODE_ENV === "test") { + return { authenticated: false }; + } + const { session } = await import("../common/classes/Session"); const authenticated = await session.doesSessionExist(); diff --git a/packages/web/src/socket/client/socket.client.test.ts b/packages/web/src/socket/client/socket.client.test.ts deleted file mode 100644 index 8ce3773d5..000000000 --- a/packages/web/src/socket/client/socket.client.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; -import { ENV_WEB } from "@web/common/constants/env.constants"; - -// Mock socket.io-client -jest.mock("socket.io-client", () => { - const mSocket = { - disconnect: jest.fn(), - connect: jest.fn(), - emit: jest.fn(), - once: jest.fn(), - on: jest.fn(), - // Add properties that might be checked - connected: false, - }; - return { - io: jest.fn(() => mSocket), - }; -}); - -describe("socket.client", () => { - let socketClientModule: typeof import("./socket.client"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let mockSocket: any; - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - jest.useFakeTimers(); - - // Re-import module to trigger top-level code - // eslint-disable-next-line @typescript-eslint/no-require-imports - socketClientModule = require("./socket.client"); - mockSocket = socketClientModule.socket; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("initializes socket with correct config", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const ioMock = require("socket.io-client").io; - expect(ioMock).toHaveBeenCalledWith(ENV_WEB.BACKEND_BASEURL, { - withCredentials: true, - autoConnect: false, - reconnection: false, - closeOnBeforeunload: true, - transports: ["websocket", "polling"], - }); - }); - - it("sets up default listeners", () => { - expect(mockSocket.on).toHaveBeenCalledWith( - "connect", - socketClientModule.onConnected, - ); - expect(mockSocket.on).toHaveBeenCalledWith("error", expect.any(Function)); - }); - - describe("disconnect", () => { - it("calls socket.disconnect", () => { - socketClientModule.disconnect(); - expect(mockSocket.disconnect).toHaveBeenCalled(); - }); - }); - - describe("reconnect", () => { - it("disconnects and then connects immediately", () => { - socketClientModule.reconnect(); - - expect(mockSocket.disconnect).toHaveBeenCalled(); - expect(mockSocket.connect).toHaveBeenCalled(); - }); - }); - - describe("onConnected", () => { - it("emits FETCH_USER_METADATA", () => { - socketClientModule.onConnected(); - expect(mockSocket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - }); - }); - - describe("onError", () => { - it("logs error to console", () => { - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - - // Get the error handler registered in the module - const errorHandler = (mockSocket.on as jest.Mock).mock.calls.find( - (call) => call[0] === "error", - )[1]; - - expect(errorHandler).toBeDefined(); - - const testError = new Error("Test socket error"); - errorHandler(testError); - - expect(consoleSpy).toHaveBeenCalledWith("Socket error:", testError); - - consoleSpy.mockRestore(); - }); - }); -}); diff --git a/packages/web/src/socket/client/socket.client.ts b/packages/web/src/socket/client/socket.client.ts deleted file mode 100644 index a7da3de42..000000000 --- a/packages/web/src/socket/client/socket.client.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { io } from "socket.io-client"; -import { FETCH_USER_METADATA } from "@core/constants/websocket.constants"; -import { ENV_WEB } from "@web/common/constants/env.constants"; - -export const socket = io(ENV_WEB.BACKEND_BASEURL, { - withCredentials: true, - autoConnect: false, - reconnection: false, - closeOnBeforeunload: true, - transports: ["websocket", "polling"], -}); - -export const disconnect = () => { - socket.disconnect(); -}; - -export const reconnect = () => { - disconnect(); - socket.connect(); -}; - -const onError = (error: unknown) => { - console.error("Socket error:", error); -}; - -export const onConnected = () => { - socket.emit(FETCH_USER_METADATA); -}; - -socket.on("connect", onConnected); -socket.on("error", onError); diff --git a/packages/web/src/socket/hooks/useEventSync.test.ts b/packages/web/src/socket/hooks/useEventSync.test.ts deleted file mode 100644 index 7398f0d18..000000000 --- a/packages/web/src/socket/hooks/useEventSync.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useDispatch } from "react-redux"; -import { renderHook } from "@testing-library/react"; -import { - EVENT_CHANGED, - SOMEDAY_EVENT_CHANGED, -} from "@core/constants/websocket.constants"; -import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; -import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; -import { socket } from "../client/socket.client"; -import { useEventSync } from "./useEventSync"; - -// Mock dependencies -jest.mock("react-redux", () => ({ - useDispatch: jest.fn(), -})); -jest.mock("../client/socket.client", () => ({ - socket: { - on: jest.fn(), - removeListener: jest.fn(), - }, -})); -jest.mock("@web/ducks/events/slices/sync.slice", () => ({ - triggerFetch: jest.fn(), -})); - -describe("useEventSync", () => { - const mockDispatch = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - (useDispatch as jest.Mock).mockReturnValue(mockDispatch); - }); - - it("sets up socket listeners for EVENT_CHANGED and SOMEDAY_EVENT_CHANGED", () => { - renderHook(() => useEventSync()); - - expect(socket.on).toHaveBeenCalledWith(EVENT_CHANGED, expect.any(Function)); - expect(socket.on).toHaveBeenCalledWith( - SOMEDAY_EVENT_CHANGED, - expect.any(Function), - ); - }); - - it("dispatches triggerFetch when EVENT_CHANGED event is received", () => { - let eventChangedHandler: () => void; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - if (event === EVENT_CHANGED) { - eventChangedHandler = handler; - } - }); - - renderHook(() => useEventSync()); - - // Simulate event - if (eventChangedHandler!) { - eventChangedHandler(); - } - - expect(triggerFetch).toHaveBeenCalledWith({ - reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, - }); - expect(mockDispatch).toHaveBeenCalled(); - }); - - it("dispatches triggerFetch when SOMEDAY_EVENT_CHANGED event is received", () => { - let somedayEventChangedHandler: () => void; - (socket.on as jest.Mock).mockImplementation((event, handler) => { - if (event === SOMEDAY_EVENT_CHANGED) { - somedayEventChangedHandler = handler; - } - }); - - renderHook(() => useEventSync()); - - // Simulate event - if (somedayEventChangedHandler!) { - somedayEventChangedHandler(); - } - - expect(triggerFetch).toHaveBeenCalledWith({ - reason: Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED, - }); - expect(mockDispatch).toHaveBeenCalled(); - }); - - it("cleans up listeners on unmount", () => { - const { unmount } = renderHook(() => useEventSync()); - - unmount(); - - expect(socket.removeListener).toHaveBeenCalledWith( - EVENT_CHANGED, - expect.any(Function), - ); - expect(socket.removeListener).toHaveBeenCalledWith( - SOMEDAY_EVENT_CHANGED, - expect.any(Function), - ); - }); -}); diff --git a/packages/web/src/socket/hooks/useEventSync.ts b/packages/web/src/socket/hooks/useEventSync.ts deleted file mode 100644 index f83eaf0d0..000000000 --- a/packages/web/src/socket/hooks/useEventSync.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { useDispatch } from "react-redux"; -import { - EVENT_CHANGED, - SOMEDAY_EVENT_CHANGED, -} from "@core/constants/websocket.constants"; -import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; -import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; -import { socket } from "../client/socket.client"; - -export const useEventSync = () => { - const dispatch = useDispatch(); - - const onEventChanged = useCallback( - (reason: Sync_AsyncStateContextReason) => { - dispatch(triggerFetch({ reason })); - }, - [dispatch], - ); - - useEffect(() => { - const handler = () => - onEventChanged(Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED); - socket.on(EVENT_CHANGED, handler); - return () => { - socket.removeListener(EVENT_CHANGED, handler); - }; - }, [onEventChanged]); - - useEffect(() => { - const handler = () => - onEventChanged(Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED); - socket.on(SOMEDAY_EVENT_CHANGED, handler); - return () => { - socket.removeListener(SOMEDAY_EVENT_CHANGED, handler); - }; - }, [onEventChanged]); -}; diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts deleted file mode 100644 index d3a2d917a..000000000 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { useDispatch } from "react-redux"; -import { renderHook } from "@testing-library/react"; -import { - FETCH_USER_METADATA, - GOOGLE_REVOKED, - IMPORT_GCAL_END, - USER_METADATA, -} from "@core/constants/websocket.constants"; -import { type ImportGCalEndPayload } from "@core/types/websocket.types"; -import { handleGoogleRevoked } from "@web/auth/google/google.auth.util"; -import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; -import { - importGCalSlice, - triggerFetch, -} from "@web/ducks/events/slices/sync.slice"; -import { socket } from "../client/socket.client"; -import { useGcalSync } from "./useGcalSync"; - -// Mock dependencies -jest.mock("react-redux", () => ({ - useDispatch: jest.fn(), -})); -jest.mock("../client/socket.client", () => ({ - socket: { - emit: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), - }, -})); -jest.mock("@web/ducks/events/slices/sync.slice", () => ({ - importGCalSlice: { - actions: { - clearImportResults: jest.fn(), - stopRepair: jest.fn(), - setImportResults: jest.fn(), - setImportError: jest.fn(), - request: jest.fn(), - }, - }, - importLatestSlice: { - reducer: (state = { isFetchNeeded: false, reason: null }) => state, - actions: { resetIsFetchNeeded: jest.fn() }, - }, - triggerFetch: jest.fn(), -})); -jest.mock("@web/ducks/auth/slices/user-metadata.slice", () => ({ - userMetadataSlice: { - actions: { - set: jest.fn((payload: unknown) => ({ - type: "userMetadata/set", - payload, - })), - clear: jest.fn(() => ({ type: "userMetadata/clear" })), - }, - }, -})); -jest.mock("@web/auth/google/google.auth.util", () => ({ - handleGoogleRevoked: jest.fn(), -})); -jest.mock("@web/common/utils/toast/error-toast.util", () => ({ - showErrorToast: jest.fn(), -})); - -describe("useGcalSync", () => { - const mockDispatch = jest.fn(); - const mockHandleGoogleRevoked = jest.mocked(handleGoogleRevoked); - const mockShowErrorToast = showErrorToast as jest.MockedFunction< - typeof showErrorToast - >; - - beforeEach(() => { - jest.clearAllMocks(); - (useDispatch as jest.Mock).mockReturnValue(mockDispatch); - }); - - it("sets up socket listeners", () => { - renderHook(() => useGcalSync()); - - expect(socket.on).toHaveBeenCalledWith(USER_METADATA, expect.any(Function)); - expect(socket.on).toHaveBeenCalledWith( - IMPORT_GCAL_END, - expect.any(Function), - ); - expect(socket.on).toHaveBeenCalledWith( - GOOGLE_REVOKED, - expect.any(Function), - ); - }); - - describe("GOOGLE_REVOKED", () => { - it("calls handleGoogleRevoked when socket event fires", () => { - let onGoogleRevoked: (() => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: () => void) => { - if (event === GOOGLE_REVOKED) { - onGoogleRevoked = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - onGoogleRevoked?.(); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(mockHandleGoogleRevoked).toHaveBeenCalledTimes(1); - }); - }); - - describe("USER_METADATA", () => { - it("updates Redux with server metadata (includes connectionState)", () => { - let metadataHandler: ((metadata: unknown) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (metadata: unknown) => void) => { - if (event === USER_METADATA) { - metadataHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - metadataHandler?.({ - sync: { importGCal: "IMPORTING" }, - google: { connectionState: "IMPORTING" }, - }); - - expect(mockDispatch).toHaveBeenCalledWith( - userMetadataSlice.actions.set({ - sync: { importGCal: "IMPORTING" }, - google: { connectionState: "IMPORTING" }, - }), - ); - }); - - it("requests an import when metadata says restart is needed", () => { - let metadataHandler: ((metadata: unknown) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (metadata: unknown) => void) => { - if (event === USER_METADATA) { - metadataHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - metadataHandler?.({ - sync: { importGCal: "RESTART" }, - google: { connectionState: "ATTENTION" }, - }); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.request(), - ); - }); - - it("does not auto-request an import when reconnect is required", () => { - let metadataHandler: ((metadata: unknown) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (metadata: unknown) => void) => { - if (event === USER_METADATA) { - metadataHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - metadataHandler?.({ - sync: { importGCal: "RESTART" }, - google: { connectionState: "RECONNECT_REQUIRED" }, - }); - - expect(importGCalSlice.actions.request).not.toHaveBeenCalled(); - }); - - it("does not auto-request an import when account is not connected", () => { - let metadataHandler: ((metadata: unknown) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (metadata: unknown) => void) => { - if (event === USER_METADATA) { - metadataHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - metadataHandler?.({ - sync: { importGCal: "RESTART" }, - google: { connectionState: "NOT_CONNECTED" }, - }); - - expect(importGCalSlice.actions.request).not.toHaveBeenCalled(); - }); - }); - - describe("IMPORT_GCAL_END", () => { - it("sets results and triggers fetch on successful import", () => { - let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - importEndHandler?.({ - operation: "REPAIR", - status: "COMPLETED", - eventsCount: 10, - calendarsCount: 2, - }); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportResults({ - eventsCount: 10, - calendarsCount: 2, - }), - ); - expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - expect(triggerFetch).toHaveBeenCalledWith({ - reason: "IMPORT_COMPLETE", - }); - }); - - it("does not clear repair state for incremental import completion", () => { - let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - importEndHandler?.({ - operation: "INCREMENTAL", - status: "COMPLETED", - eventsCount: 10, - calendarsCount: 2, - }); - - expect(importGCalSlice.actions.stopRepair).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportResults({ - eventsCount: 10, - calendarsCount: 2, - }), - ); - }); - - it("does not trigger fetch when import is ignored", () => { - let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - importEndHandler?.({ - operation: "REPAIR", - status: "IGNORED", - message: - "User test-user gcal import is in progress or completed, ignoring this request", - }); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); - expect(triggerFetch).not.toHaveBeenCalled(); - }); - - it("sets error state when backend returns an errored payload", () => { - let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - importEndHandler?.({ - operation: "REPAIR", - status: "ERRORED", - message: "Google Calendar repair failed. Please try again.", - }); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportError( - "Google Calendar repair failed. Please try again.", - ), - ); - expect(mockShowErrorToast).toHaveBeenCalledWith( - "Google Calendar repair failed. Please try again.", - { toastId: "google-repair-failed" }, - ); - expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - expect(importGCalSlice.actions.setImportResults).not.toHaveBeenCalled(); - }); - - it("does not show a toast for non-repair import errors", () => { - let importEndHandler: ((data?: ImportGCalEndPayload) => void) | undefined; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndHandler = handler; - } - }, - ); - - renderHook(() => useGcalSync()); - - importEndHandler?.({ - operation: "INCREMENTAL", - status: "ERRORED", - message: "Incremental Google Calendar sync failed for user: 123", - }); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportError( - "Incremental Google Calendar sync failed for user: 123", - ), - ); - expect(importGCalSlice.actions.stopRepair).not.toHaveBeenCalled(); - expect(mockShowErrorToast).not.toHaveBeenCalled(); - }); - }); - - describe("import flow interaction", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); - - it("processes successful import end and triggers metadata refresh", () => { - const handlers: Record void> = {}; - (socket.on as jest.Mock).mockImplementation( - (event: string, handler: (...args: unknown[]) => void) => { - handlers[event] = handler; - }, - ); - - renderHook(() => useGcalSync()); - - expect(handlers[IMPORT_GCAL_END]).toBeDefined(); - - // Simulate backend processing time - jest.advanceTimersByTime(2000); - - // Backend signals import complete - const successfulResponse: ImportGCalEndPayload = { - operation: "REPAIR", - status: "COMPLETED", - eventsCount: 25, - calendarsCount: 3, - }; - handlers[IMPORT_GCAL_END](successfulResponse); - - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.stopRepair(), - ); - expect(mockDispatch).toHaveBeenCalledWith( - importGCalSlice.actions.setImportResults({ - eventsCount: 25, - calendarsCount: 3, - }), - ); - // Requests fresh metadata which will update connectionState - expect(socket.emit).toHaveBeenCalledWith(FETCH_USER_METADATA); - expect(triggerFetch).toHaveBeenCalledWith({ - reason: "IMPORT_COMPLETE", - }); - }); - }); -}); diff --git a/packages/web/src/socket/hooks/useSocketConnection.test.ts b/packages/web/src/socket/hooks/useSocketConnection.test.ts deleted file mode 100644 index 678f22c05..000000000 --- a/packages/web/src/socket/hooks/useSocketConnection.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { useUser } from "@web/auth/hooks/user/useUser"; -import { socket } from "../client/socket.client"; -import { useSocketConnection } from "./useSocketConnection"; - -// Mock dependencies -jest.mock("@web/auth/hooks/user/useUser"); -jest.mock("../client/socket.client", () => ({ - socket: { - connect: jest.fn(), - disconnect: jest.fn(), - connected: false, - }, -})); - -const mockUseUser = useUser as jest.MockedFunction; - -describe("useSocketConnection", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("connects socket when user is authenticated and socket is disconnected", () => { - mockUseUser.mockReturnValue({ userId: "test-user-id" }); - (socket as any).connected = false; - - renderHook(() => useSocketConnection()); - - expect(socket.connect).toHaveBeenCalledTimes(1); - expect(socket.disconnect).not.toHaveBeenCalled(); - }); - - it("does not connect socket when user is authenticated but socket is already connected", () => { - mockUseUser.mockReturnValue({ userId: "test-user-id" }); - (socket as any).connected = true; - - renderHook(() => useSocketConnection()); - - expect(socket.connect).not.toHaveBeenCalled(); - expect(socket.disconnect).not.toHaveBeenCalled(); - }); - - it("disconnects socket when user is not authenticated and socket is connected", () => { - mockUseUser.mockReturnValue({ userId: null }); - (socket as any).connected = true; - - renderHook(() => useSocketConnection()); - - expect(socket.disconnect).toHaveBeenCalledTimes(1); - expect(socket.connect).not.toHaveBeenCalled(); - }); - - it("does nothing when user is not authenticated and socket is already disconnected", () => { - mockUseUser.mockReturnValue({ userId: null }); - (socket as any).connected = false; - - renderHook(() => useSocketConnection()); - - expect(socket.connect).not.toHaveBeenCalled(); - expect(socket.disconnect).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/web/src/socket/hooks/useSocketConnection.ts b/packages/web/src/socket/hooks/useSocketConnection.ts deleted file mode 100644 index 1079e8ca8..000000000 --- a/packages/web/src/socket/hooks/useSocketConnection.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from "react"; -import { useUser } from "@web/auth/hooks/user/useUser"; -import { socket } from "../client/socket.client"; - -export const useSocketConnection = () => { - const { userId } = useUser(); - - useEffect(() => { - if (userId && !socket.connected) { - socket.connect(); - } else if (!userId && socket.connected) { - socket.disconnect(); - } - }, [userId]); -}; diff --git a/packages/web/src/socket/provider/SocketProvider.test.tsx b/packages/web/src/socket/provider/SocketProvider.test.tsx deleted file mode 100644 index 674b4866f..000000000 --- a/packages/web/src/socket/provider/SocketProvider.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Provider } from "react-redux"; -import { combineReducers, configureStore } from "@reduxjs/toolkit"; -import { render, waitFor } from "@testing-library/react"; -import { IMPORT_GCAL_END } from "@core/constants/websocket.constants"; -import { type ImportGCalEndPayload } from "@core/types/websocket.types"; -import { useUser } from "@web/auth/hooks/user/useUser"; -import { - importGCalSlice, - importLatestSlice, -} from "@web/ducks/events/slices/sync.slice"; -import { socket } from "./SocketProvider"; -import SocketProvider from "./SocketProvider"; - -// Mock dependencies -jest.mock("@web/auth/hooks/user/useUser"); -jest.mock("socket.io-client", () => ({ - io: jest.fn(() => ({ - on: jest.fn(), - once: jest.fn(), - emit: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - removeListener: jest.fn(), - connected: false, - })), -})); - -const mockUseUser = useUser as jest.MockedFunction; - -describe("SocketProvider", () => { - const mockUserId = "test-user-id"; - let importEndCallback: ((data?: ImportGCalEndPayload) => void) | undefined; - - beforeEach(() => { - jest.clearAllMocks(); - importEndCallback = undefined; - mockUseUser.mockReturnValue({ userId: mockUserId }); - - (socket.on as jest.Mock).mockImplementation( - (event: string, callback: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndCallback = callback; - } - }, - ); - }); - - it("sets import results and triggers refetch on import completion", async () => { - const store = configureStore({ - reducer: { - sync: combineReducers({ - importGCal: importGCalSlice.reducer, - importLatest: importLatestSlice.reducer, - }), - }, - }); - - render( - - -
Test
-
-
, - ); - - await waitFor(() => { - expect(importEndCallback).toBeDefined(); - }); - - importEndCallback?.({ - operation: "REPAIR", - status: "COMPLETED", - eventsCount: 10, - calendarsCount: 2, - }); - - const state = store.getState(); - expect(state.sync.importGCal.importResults).toEqual({ - eventsCount: 10, - calendarsCount: 2, - }); - expect(state.sync.importLatest.isFetchNeeded).toBe(true); - }); -}); diff --git a/packages/web/src/socket/provider/SocketProvider.tsx b/packages/web/src/socket/provider/SocketProvider.tsx deleted file mode 100644 index 00ea45ce6..000000000 --- a/packages/web/src/socket/provider/SocketProvider.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { type ReactNode } from "react"; -import { useEventSync } from "../hooks/useEventSync"; -import { useGcalSync } from "../hooks/useGcalSync"; -import { useSocketConnection } from "../hooks/useSocketConnection"; - -export * from "../client/socket.client"; - -const SocketProvider = ({ children }: { children: ReactNode }) => { - useSocketConnection(); - useEventSync(); - useGcalSync(); - - return children; -}; - -export default SocketProvider; diff --git a/packages/web/src/sse/client/sse.client.ts b/packages/web/src/sse/client/sse.client.ts new file mode 100644 index 000000000..c5a3f5378 --- /dev/null +++ b/packages/web/src/sse/client/sse.client.ts @@ -0,0 +1,58 @@ +import { EventEmitter2 } from "eventemitter2"; +import { + EVENT_CHANGED, + GOOGLE_REVOKED, + IMPORT_GCAL_END, + IMPORT_GCAL_START, + SOMEDAY_EVENT_CHANGED, + USER_METADATA, +} from "@core/constants/sse.constants"; +import { ENV_WEB } from "@web/common/constants/env.constants"; + +const SSE_EVENTS = [ + EVENT_CHANGED, + SOMEDAY_EVENT_CHANGED, + IMPORT_GCAL_END, + IMPORT_GCAL_START, + GOOGLE_REVOKED, + USER_METADATA, +] as const; + +// Stable emitter that survives stream reconnects. Hooks subscribe here instead +// of directly to the EventSource, so closeStream()+openStream() cycles are +// invisible to anything above this module. +export const sseEmitter = new EventEmitter2({ + wildcard: false, + maxListeners: 20, + verboseMemoryLeak: true, +}); + +let es: EventSource | null = null; +let forwardingHandlers: Map void> | null = null; + +export const openStream = (): EventSource => { + if (es) return es; + es = new EventSource(`${ENV_WEB.BACKEND_BASEURL}/api/events/stream`, { + withCredentials: true, + }); + forwardingHandlers = new Map(); + for (const eventName of SSE_EVENTS) { + const handler = (e: Event) => sseEmitter.emit(eventName, e); + forwardingHandlers.set(eventName, handler); + es.addEventListener(eventName, handler); + } + return es; +}; + +export const closeStream = (): void => { + if (es && forwardingHandlers) { + for (const [eventName, handler] of forwardingHandlers) { + es.removeEventListener(eventName, handler); + } + } + es?.close(); + es = null; + forwardingHandlers = null; +}; + +export const getStream = (): EventSource | null => es; diff --git a/packages/web/src/sse/hooks/useEventSSE.ts b/packages/web/src/sse/hooks/useEventSSE.ts new file mode 100644 index 000000000..4affb15f9 --- /dev/null +++ b/packages/web/src/sse/hooks/useEventSSE.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { + EVENT_CHANGED, + SOMEDAY_EVENT_CHANGED, +} from "@core/constants/sse.constants"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { sseEmitter } from "../client/sse.client"; + +export const useEventSSE = () => { + const dispatch = useDispatch(); + + const onEventChanged = useCallback( + (reason: Sync_AsyncStateContextReason) => { + dispatch(triggerFetch({ reason })); + }, + [dispatch], + ); + + useEffect(() => { + const eventChangedHandler = () => + onEventChanged(Sync_AsyncStateContextReason.EVENT_CHANGED); + + const somedayChangedHandler = () => + onEventChanged(Sync_AsyncStateContextReason.SOMEDAY_EVENT_CHANGED); + + sseEmitter.on(EVENT_CHANGED, eventChangedHandler); + sseEmitter.on(SOMEDAY_EVENT_CHANGED, somedayChangedHandler); + + return () => { + sseEmitter.off(EVENT_CHANGED, eventChangedHandler); + sseEmitter.off(SOMEDAY_EVENT_CHANGED, somedayChangedHandler); + }; + }, [onEventChanged]); +}; diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/sse/hooks/useGcalSSE.ts similarity index 68% rename from packages/web/src/socket/hooks/useGcalSync.ts rename to packages/web/src/sse/hooks/useGcalSSE.ts index 9c8a01822..5cab2c8c3 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/sse/hooks/useGcalSSE.ts @@ -1,14 +1,14 @@ import { useCallback, useEffect } from "react"; import { useDispatch } from "react-redux"; import { - FETCH_USER_METADATA, GOOGLE_REVOKED, IMPORT_GCAL_END, USER_METADATA, -} from "@core/constants/websocket.constants"; +} from "@core/constants/sse.constants"; +import { type ImportGCalEndPayload } from "@core/types/sse.types"; import { type UserMetadata } from "@core/types/user.types"; -import { type ImportGCalEndPayload } from "@core/types/websocket.types"; import { handleGoogleRevoked } from "@web/auth/google/google.auth.util"; +import { refreshUserMetadata } from "@web/auth/session/user-metadata.util"; import { GOOGLE_REPAIR_FAILED_TOAST_ID } from "@web/common/constants/toast.constants"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { userMetadataSlice } from "@web/ducks/auth/slices/user-metadata.slice"; @@ -17,9 +17,9 @@ import { importGCalSlice, triggerFetch, } from "@web/ducks/events/slices/sync.slice"; -import { socket } from "../client/socket.client"; +import { sseEmitter } from "../client/sse.client"; -export const useGcalSync = () => { +export const useGcalSSE = () => { const dispatch = useDispatch(); const onImportEnd = useCallback( @@ -27,7 +27,7 @@ export const useGcalSync = () => { if (payload?.operation === "REPAIR") { dispatch(importGCalSlice.actions.stopRepair()); } - socket.emit(FETCH_USER_METADATA); + void refreshUserMetadata(); if (payload?.status === "ERRORED") { dispatch(importGCalSlice.actions.setImportError(payload.message)); @@ -85,23 +85,32 @@ export const useGcalSync = () => { ); useEffect(() => { - socket.on(USER_METADATA, onMetadataFetch); - return () => { - socket.removeListener(USER_METADATA, onMetadataFetch); + const importEndHandler = (e: Event) => { + const payload = JSON.parse( + String((e as MessageEvent).data), + ) as ImportGCalEndPayload; + onImportEnd(payload); }; - }, [onMetadataFetch]); - useEffect(() => { - socket.on(IMPORT_GCAL_END, onImportEnd); - return () => { - socket.removeListener(IMPORT_GCAL_END, onImportEnd); + const googleRevokedHandler = () => { + onGoogleRevoked(); }; - }, [onImportEnd]); - useEffect(() => { - socket.on(GOOGLE_REVOKED, onGoogleRevoked); + const userMetadataHandler = (e: Event) => { + const metadata = JSON.parse( + String((e as MessageEvent).data), + ) as UserMetadata; + onMetadataFetch(metadata); + }; + + sseEmitter.on(IMPORT_GCAL_END, importEndHandler); + sseEmitter.on(GOOGLE_REVOKED, googleRevokedHandler); + sseEmitter.on(USER_METADATA, userMetadataHandler); + return () => { - socket.removeListener(GOOGLE_REVOKED, onGoogleRevoked); + sseEmitter.off(IMPORT_GCAL_END, importEndHandler); + sseEmitter.off(GOOGLE_REVOKED, googleRevokedHandler); + sseEmitter.off(USER_METADATA, userMetadataHandler); }; - }, [onGoogleRevoked]); + }, [onImportEnd, onGoogleRevoked, onMetadataFetch]); }; diff --git a/packages/web/src/sse/hooks/useSSEConnection.ts b/packages/web/src/sse/hooks/useSSEConnection.ts new file mode 100644 index 000000000..a14246bc1 --- /dev/null +++ b/packages/web/src/sse/hooks/useSSEConnection.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; +import { useUser } from "@web/auth/hooks/user/useUser"; +import { closeStream, openStream } from "../client/sse.client"; + +export const useSSEConnection = () => { + const { userId } = useUser(); + + useEffect(() => { + if (userId) { + openStream(); + } else { + closeStream(); + } + }, [userId]); +}; diff --git a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx similarity index 78% rename from packages/web/src/socket/provider/SocketProvider.interaction.test.tsx rename to packages/web/src/sse/provider/SSEProvider.interaction.test.tsx index 71a9a1a43..e9f3f9b25 100644 --- a/packages/web/src/socket/provider/SocketProvider.interaction.test.tsx +++ b/packages/web/src/sse/provider/SSEProvider.interaction.test.tsx @@ -1,3 +1,4 @@ +import { type EventEmitter2 } from "eventemitter2"; import { act } from "react"; import { Provider } from "react-redux"; import { @@ -7,16 +8,15 @@ import { } from "@reduxjs/toolkit"; import "@testing-library/jest-dom"; import { render, screen, waitFor } from "@testing-library/react"; -import { IMPORT_GCAL_END } from "@core/constants/websocket.constants"; -import { type ImportGCalEndPayload } from "@core/types/websocket.types"; +import { IMPORT_GCAL_END } from "@core/constants/sse.constants"; +import { type ImportGCalEndPayload } from "@core/types/sse.types"; import { SyncEventsOverlay } from "@web/components/SyncEventsOverlay/SyncEventsOverlay"; import { authSlice } from "@web/ducks/auth/slices/auth.slice"; import { importGCalSlice, importLatestSlice, } from "@web/ducks/events/slices/sync.slice"; -import { socket } from "./SocketProvider"; -import SocketProvider from "./SocketProvider"; +import SSEProvider from "./SSEProvider"; type TestStore = EnhancedStore<{ sync: { @@ -30,19 +30,34 @@ type TestStore = EnhancedStore<{ jest.mock("@web/auth/hooks/user/useUser", () => ({ useUser: () => ({ userId: "test-user-id" }), })); - -jest.mock("socket.io-client", () => ({ - io: jest.fn(() => ({ - on: jest.fn(), - once: jest.fn(), - emit: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - removeListener: jest.fn(), - connected: false, - })), +jest.mock("@web/auth/session/user-metadata.util", () => ({ + refreshUserMetadata: jest.fn().mockResolvedValue(undefined), })); +jest.mock("../client/sse.client", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const { EventEmitter2 } = require("eventemitter2"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + const sseEmitter = new EventEmitter2({ maxListeners: 20 }); + return { + openStream: jest.fn(), + closeStream: jest.fn(), + getStream: jest.fn(() => null), + sseEmitter: sseEmitter as unknown as EventEmitter2, + }; +}); + +function fireImportEnd(payload: ImportGCalEndPayload) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sseEmitter } = require("../client/sse.client") as { + sseEmitter: EventEmitter2; + }; + sseEmitter.emit( + IMPORT_GCAL_END, + new MessageEvent(IMPORT_GCAL_END, { data: JSON.stringify(payload) }), + ); +} + /** * Integration tests for the Google Calendar authentication and import flow. * @@ -52,9 +67,6 @@ jest.mock("socket.io-client", () => ({ * - Overlay disappears immediately after OAuth completes */ describe("GCal Authentication Flow", () => { - // Socket event callbacks captured during render - let importEndCallback: ((data?: ImportGCalEndPayload) => void) | undefined; - const createTestStore = (preloadedState?: { isAuthenticating?: boolean; }): TestStore => { @@ -94,16 +106,6 @@ describe("GCal Authentication Flow", () => { jest.clearAllMocks(); jest.useFakeTimers(); document.body.removeAttribute("data-app-locked"); - importEndCallback = undefined; - - // Capture socket event handlers when they're registered - (socket.on as jest.Mock).mockImplementation( - (event: string, callback: (data?: ImportGCalEndPayload) => void) => { - if (event === IMPORT_GCAL_END) { - importEndCallback = callback; - } - }, - ); }); afterEach(() => { @@ -117,9 +119,9 @@ describe("GCal Authentication Flow", () => { render( - + - + , ); @@ -137,9 +139,9 @@ describe("GCal Authentication Flow", () => { const { rerender, container } = render( - + - + , ); @@ -154,9 +156,9 @@ describe("GCal Authentication Flow", () => { rerender( - + - + , ); @@ -180,9 +182,9 @@ describe("GCal Authentication Flow", () => { const { container } = render( - + - + , ); @@ -203,9 +205,9 @@ describe("GCal Authentication Flow", () => { const { rerender, container } = render( - + - + , ); @@ -214,10 +216,6 @@ describe("GCal Authentication Flow", () => { screen.getByText("Complete Google sign-in..."), ).toBeInTheDocument(); - await waitFor(() => { - expect(importEndCallback).toBeDefined(); - }); - // OAuth completes, auth state resets act(() => { store.dispatch(authSlice.actions.resetAuth()); @@ -225,9 +223,9 @@ describe("GCal Authentication Flow", () => { rerender( - + - + , ); @@ -243,7 +241,7 @@ describe("GCal Authentication Flow", () => { // Backend completes import act(() => { - importEndCallback?.({ + fireImportEnd({ operation: "REPAIR", status: "COMPLETED", eventsCount: 42, @@ -267,18 +265,14 @@ describe("GCal Authentication Flow", () => { render( - + - + , ); - await waitFor(() => { - expect(importEndCallback).toBeDefined(); - }); - act(() => { - importEndCallback?.({ + fireImportEnd({ operation: "INCREMENTAL", status: "ERRORED", message: @@ -286,10 +280,11 @@ describe("GCal Authentication Flow", () => { }); }); - const state = store.getState(); - expect(state.sync.importGCal.importError).toBe( - "Incremental Google Calendar sync failed for user: test-user", - ); + await waitFor(() => { + expect(store.getState().sync.importGCal.importError).toBe( + "Incremental Google Calendar sync failed for user: test-user", + ); + }); }); }); }); diff --git a/packages/web/src/sse/provider/SSEProvider.test.tsx b/packages/web/src/sse/provider/SSEProvider.test.tsx new file mode 100644 index 000000000..bbed672a9 --- /dev/null +++ b/packages/web/src/sse/provider/SSEProvider.test.tsx @@ -0,0 +1,90 @@ +import { type EventEmitter2 } from "eventemitter2"; +import { act } from "react"; +import { Provider } from "react-redux"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { render, waitFor } from "@testing-library/react"; +import { IMPORT_GCAL_END } from "@core/constants/sse.constants"; +import { type ImportGCalEndPayload } from "@core/types/sse.types"; +import { useUser } from "@web/auth/hooks/user/useUser"; +import { + importGCalSlice, + importLatestSlice, +} from "@web/ducks/events/slices/sync.slice"; +import SSEProvider from "./SSEProvider"; + +// Mock dependencies +jest.mock("@web/auth/hooks/user/useUser"); +jest.mock("@web/auth/session/user-metadata.util", () => ({ + refreshUserMetadata: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("../client/sse.client", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter2 } = require("eventemitter2"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const sseEmitter = new EventEmitter2({ maxListeners: 20 }); + return { + openStream: jest.fn(), + closeStream: jest.fn(), + getStream: jest.fn(() => null), + sseEmitter, + }; +}); + +const mockUseUser = useUser as jest.MockedFunction; + +describe("SSEProvider", () => { + const mockUserId = "test-user-id"; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseUser.mockReturnValue({ userId: mockUserId }); + }); + + it("sets import results and triggers refetch on import completion", async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sseEmitter } = require("../client/sse.client") as { + sseEmitter: EventEmitter2; + }; + + const store = configureStore({ + reducer: { + sync: combineReducers({ + importGCal: importGCalSlice.reducer, + importLatest: importLatestSlice.reducer, + }), + }, + }); + + render( + + +
Test
+
+
, + ); + + const payload: ImportGCalEndPayload = { + operation: "REPAIR", + status: "COMPLETED", + eventsCount: 10, + calendarsCount: 2, + }; + + act(() => { + sseEmitter.emit( + IMPORT_GCAL_END, + new MessageEvent(IMPORT_GCAL_END, { data: JSON.stringify(payload) }), + ); + }); + + await waitFor(() => { + const state = store.getState(); + expect(state.sync.importGCal.importResults).toEqual({ + eventsCount: 10, + calendarsCount: 2, + }); + }); + + expect(store.getState().sync.importLatest.isFetchNeeded).toBe(true); + }); +}); diff --git a/packages/web/src/sse/provider/SSEProvider.tsx b/packages/web/src/sse/provider/SSEProvider.tsx new file mode 100644 index 000000000..e7a068240 --- /dev/null +++ b/packages/web/src/sse/provider/SSEProvider.tsx @@ -0,0 +1,16 @@ +import { type ReactNode } from "react"; +import { useEventSSE } from "../hooks/useEventSSE"; +import { useGcalSSE } from "../hooks/useGcalSSE"; +import { useSSEConnection } from "../hooks/useSSEConnection"; + +export * from "../client/sse.client"; + +const SSEProvider = ({ children }: { children: ReactNode }) => { + useSSEConnection(); + useEventSSE(); + useGcalSSE(); + + return children; +}; + +export default SSEProvider; diff --git a/packages/web/src/views/Calendar/hooks/useRefetch.test.ts b/packages/web/src/views/Calendar/hooks/useRefetch.test.ts index f753ad335..1038cfa91 100644 --- a/packages/web/src/views/Calendar/hooks/useRefetch.test.ts +++ b/packages/web/src/views/Calendar/hooks/useRefetch.test.ts @@ -81,7 +81,7 @@ describe("useRefetch", () => { expect(mockDispatch).toHaveBeenCalledWith(resetIsFetchNeeded()); }); - it("should map SOCKET_EVENT_CHANGED to WEEK_VIEW_CHANGE for week view", () => { + it("should map EVENT_CHANGED to WEEK_VIEW_CHANGE for week view", () => { mockUseLocation.mockReturnValue({ pathname: ROOT_ROUTES.WEEK }); mockUseParams.mockReturnValue({}); @@ -96,7 +96,7 @@ describe("useRefetch", () => { ) { return { isFetchNeeded: true, - reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.EVENT_CHANGED, }; } if ( @@ -118,7 +118,7 @@ describe("useRefetch", () => { startDate: toUTCOffset(weekStart), endDate: toUTCOffset(weekEnd), __context: { - reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.EVENT_CHANGED, }, }), ); @@ -228,7 +228,7 @@ describe("useRefetch", () => { ); }); - it("should map SOCKET_EVENT_CHANGED to WEEK_VIEW_CHANGE for day view", () => { + it("should map EVENT_CHANGED to WEEK_VIEW_CHANGE for day view", () => { const testDate = "2024-01-15"; mockUseLocation.mockReturnValue({ pathname: `/day/${testDate}` }); mockUseParams.mockReturnValue({ date: testDate }); @@ -236,7 +236,7 @@ describe("useRefetch", () => { mockUseAppSelector .mockReturnValueOnce({ isFetchNeeded: true, - reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.EVENT_CHANGED, }) .mockReturnValueOnce({ start: "2024-01-15T00:00:00Z", @@ -260,7 +260,7 @@ describe("useRefetch", () => { startDate: expectedStart, endDate: expectedEnd, __context: { - reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.EVENT_CHANGED, }, }), ); @@ -268,7 +268,7 @@ describe("useRefetch", () => { }); describe("Someday events handling", () => { - it("should fetch someday events when SOCKET_SOMEDAY_EVENT_CHANGED reason", () => { + it("should fetch someday events when SOMEDAY_EVENT_CHANGED reason", () => { mockUseLocation.mockReturnValue({ pathname: ROOT_ROUTES.WEEK }); mockUseParams.mockReturnValue({}); @@ -278,7 +278,7 @@ describe("useRefetch", () => { mockUseAppSelector .mockReturnValueOnce({ isFetchNeeded: true, - reason: Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.SOMEDAY_EVENT_CHANGED, }) .mockReturnValueOnce({ start: weekStart, @@ -302,7 +302,7 @@ describe("useRefetch", () => { expect(mockDispatch).toHaveBeenCalledWith(resetIsFetchNeeded()); }); - it("should fetch someday events for day view when SOCKET_SOMEDAY_EVENT_CHANGED", () => { + it("should fetch someday events for day view when SOMEDAY_EVENT_CHANGED", () => { const testDate = "2024-01-15"; mockUseLocation.mockReturnValue({ pathname: `/day/${testDate}` }); mockUseParams.mockReturnValue({ date: testDate }); @@ -310,7 +310,7 @@ describe("useRefetch", () => { mockUseAppSelector .mockReturnValueOnce({ isFetchNeeded: true, - reason: Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED, + reason: Sync_AsyncStateContextReason.SOMEDAY_EVENT_CHANGED, }) .mockReturnValueOnce({ start: "2024-01-15T00:00:00Z", diff --git a/packages/web/src/views/Calendar/hooks/useRefetch.ts b/packages/web/src/views/Calendar/hooks/useRefetch.ts index bb79bfa4b..f426d91ad 100644 --- a/packages/web/src/views/Calendar/hooks/useRefetch.ts +++ b/packages/web/src/views/Calendar/hooks/useRefetch.ts @@ -46,9 +46,7 @@ export const useRefetch = () => { useEffect(() => { if (isFetchNeeded) { - if ( - _reason === Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED - ) { + if (_reason === Sync_AsyncStateContextReason.SOMEDAY_EVENT_CHANGED) { const dateStart = dayjs(dateRange.start); const { startDate, endDate } = computeSomedayEventsRequestFilter( dateStart, diff --git a/packages/web/src/views/Root.tsx b/packages/web/src/views/Root.tsx index b9b8cbabe..b7e59fffe 100644 --- a/packages/web/src/views/Root.tsx +++ b/packages/web/src/views/Root.tsx @@ -3,7 +3,7 @@ import { useIsMobile } from "@web/common/hooks/useIsMobile"; import { AuthenticatedLayout } from "@web/components/AuthenticatedLayout/AuthenticatedLayout"; import { GlobalShortcutsHost } from "@web/components/CompassProvider/CompassProvider"; import { MobileGate } from "@web/components/MobileGate/MobileGate"; -import SocketProvider from "@web/socket/provider/SocketProvider"; +import SSEProvider from "@web/sse/provider/SSEProvider"; export const RootView = () => { const isMobile = useIsMobile(); @@ -14,10 +14,10 @@ export const RootView = () => { return ( - + - + ); }; diff --git a/packages/web/webpack.config.mjs b/packages/web/webpack.config.mjs index b09576fcb..4006cc628 100644 --- a/packages/web/webpack.config.mjs +++ b/packages/web/webpack.config.mjs @@ -292,6 +292,7 @@ export default (env, argv) => { "@web/ducks": resolvePath("./src/ducks/"), "@web/public": resolvePath("./src/public"), "@web/routers": resolvePath("./src/routers"), + "@web/sse": resolvePath("./src/sse"), "@web/socket": resolvePath("./src/socket"), "@web/store": resolvePath("./src/store"), "@web/views": resolvePath("./src/views"), diff --git a/yarn.lock b/yarn.lock index 54c937cfe..f61fcf8b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 8 cacheKey: 10c0 -"@adobe/css-tools@npm:^4.0.1": +"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.4.0": version: 4.4.4 resolution: "@adobe/css-tools@npm:4.4.4" checksum: 10c0/8f3e6cfaa5e6286e6f05de01d91d060425be2ebaef490881f5fe6da8bbdb336835c5d373ea337b0c3b0a1af4be048ba18780f0f6021d30809b4545922a7e13d9 @@ -1456,8 +1456,6 @@ __metadata: p-limit: "npm:^7.2.0" rrule: "npm:^2.7.2" saslprep: "npm:^1.0.3" - socket.io: "npm:^4.7.5" - socket.io-client: "npm:^4.7.5" supertest: "npm:^7.1.0" supertokens-node: "npm:^23.0.1" tsconfig-paths: "npm:^4.1.2" @@ -1582,7 +1580,6 @@ __metadata: redux-saga: "npm:^1.1.3" regenerator-runtime: "npm:^0.14.1" rxjs: "npm:^7.8.0" - socket.io-client: "npm:^4.7.5" style-loader: "npm:^3.2.1" styled-components: "npm:^5.3.1" supertokens-web-js: "npm:^0.16.0" @@ -1674,7 +1671,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.7.1, @emnapi/core@npm:^1.8.1": +"@emnapi/core@npm:^1.8.1": version: 1.9.1 resolution: "@emnapi/core@npm:1.9.1" dependencies: @@ -1684,7 +1681,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.7.1, @emnapi/runtime@npm:^1.8.1": +"@emnapi/runtime@npm:^1.8.1": version: 1.9.1 resolution: "@emnapi/runtime@npm:1.9.1" dependencies: @@ -3009,13 +3006,14 @@ __metadata: linkType: hard "@napi-rs/wasm-runtime@npm:^1.1.1": - version: 1.1.1 - resolution: "@napi-rs/wasm-runtime@npm:1.1.1" + version: 1.1.2 + resolution: "@napi-rs/wasm-runtime@npm:1.1.2" dependencies: - "@emnapi/core": "npm:^1.7.1" - "@emnapi/runtime": "npm:^1.7.1" "@tybys/wasm-util": "npm:^0.10.1" - checksum: 10c0/04d57b67e80736e41fe44674a011878db0a8ad893f4d44abb9d3608debb7c174224cba2796ed5b0c1d367368159f3ca6be45f1c59222f70e32ddc880f803d447 + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/725c30ec9c480a8d0c1a6a4ce31dc6c830365d485e23ad560e143d1cb9db89a0c95fbb5b9d53c07121729817a3683db6f1ab65d7e4f38fa7482a11b15ef6c6fd languageName: node linkType: hard @@ -3271,9 +3269,9 @@ __metadata: linkType: hard "@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.0": - version: 1.9.0 - resolution: "@opentelemetry/api@npm:1.9.0" - checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add + version: 1.9.1 + resolution: "@opentelemetry/api@npm:1.9.1" + checksum: 10c0/c608485fc8b5a91e1f7e05e843b45b509307456b31cd2ad365933d90813e40ebfedf179f1451c762037e82d7c76aa8500e95d2da3609f640a1206cde5322cd14 languageName: node linkType: hard @@ -3288,14 +3286,14 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:2.6.0": - version: 2.6.0 - resolution: "@opentelemetry/core@npm:2.6.0" +"@opentelemetry/core@npm:2.6.1": + version: 2.6.1 + resolution: "@opentelemetry/core@npm:2.6.1" dependencies: "@opentelemetry/semantic-conventions": "npm:^1.29.0" peerDependencies: "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/526854a3d8917c82b41bfea6ed48f2e9ae38705d1758710b86f879e5c4910b9dbe7fa03d36205e98ebebe4854ae781116d8f298a10cd0fe2e51138e75926ec3a + checksum: 10c0/a144749e4fc4b8aa56a67310136ae37ba446cdd84a5286d76b441206e80362d5059e496b11373909d5ada8be32cfb00fcebc5a90401b29a08a3ce34c9caacbdd languageName: node linkType: hard @@ -3356,14 +3354,14 @@ __metadata: linkType: hard "@opentelemetry/resources@npm:^2.2.0": - version: 2.6.0 - resolution: "@opentelemetry/resources@npm:2.6.0" + version: 2.6.1 + resolution: "@opentelemetry/resources@npm:2.6.1" dependencies: - "@opentelemetry/core": "npm:2.6.0" + "@opentelemetry/core": "npm:2.6.1" "@opentelemetry/semantic-conventions": "npm:^1.29.0" peerDependencies: "@opentelemetry/api": ">=1.3.0 <1.10.0" - checksum: 10c0/9c75654690c0917be948ed18453f3085a54541f0db8c8b728515f5a26b67c5fc1f6acd2644462e17368045e3c3fd65f728e8bd0a19c31fe12cc443fa0f0f058c + checksum: 10c0/d9376881bb9dad39ed08ede591bbdbe02b79eb5ac6608b7245ebee3ec03043d33ee29bad649db4dafc61a442bbf5ad9e73cd03cbc7645ea016999b7b13aab052 languageName: node linkType: hard @@ -3752,19 +3750,19 @@ __metadata: languageName: node linkType: hard -"@posthog/core@npm:1.24.1": - version: 1.24.1 - resolution: "@posthog/core@npm:1.24.1" +"@posthog/core@npm:1.24.3": + version: 1.24.3 + resolution: "@posthog/core@npm:1.24.3" dependencies: cross-spawn: "npm:^7.0.6" - checksum: 10c0/9845666353f204e43b7cfbe6382522ebb97c27c830107ca764551c4e7e4d0137be4fae9ade9be0bd94668cb2e866667be62eefdd0d79462bef82b9eb0b62ceee + checksum: 10c0/e36019d9358e82d3e1dc484ce68af5bb7e3615630454925d8243e848f307d0a6df38b58e05cbe31774fc06af5ccb264ea32584ddf849d20bdc36de0ebdcac14a languageName: node linkType: hard -"@posthog/types@npm:1.363.1": - version: 1.363.1 - resolution: "@posthog/types@npm:1.363.1" - checksum: 10c0/a69c22d24d566aff5afb5598617b436afa7cb244f4be0b1c2f322801b00d57d570238562d354cf97e66d41087f16ff1d2e75f4698e3763ab3c679a0055d8072c +"@posthog/types@npm:1.364.1": + version: 1.364.1 + resolution: "@posthog/types@npm:1.364.1" + checksum: 10c0/36d767cc7485934fcc9393eb1fb373eb9ea49bf94f43377b8fb23f922983bc81d3d41506374647ce708860c29920359f20366cc102681e7bb7aecc04db1b7a39 languageName: node linkType: hard @@ -4009,9 +4007,9 @@ __metadata: linkType: hard "@sinclair/typebox@npm:^0.34.0": - version: 0.34.48 - resolution: "@sinclair/typebox@npm:0.34.48" - checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d + version: 0.34.49 + resolution: "@sinclair/typebox@npm:0.34.49" + checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8 languageName: node linkType: hard @@ -4043,13 +4041,6 @@ __metadata: languageName: node linkType: hard -"@socket.io/component-emitter@npm:~3.1.0": - version: 3.1.2 - resolution: "@socket.io/component-emitter@npm:3.1.2" - checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 - languageName: node - linkType: hard - "@svgr/babel-plugin-add-jsx-attribute@npm:^6.5.1": version: 6.5.1 resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:6.5.1" @@ -4393,15 +4384,15 @@ __metadata: linkType: hard "@tanstack/react-store@npm:^0.9.1": - version: 0.9.2 - resolution: "@tanstack/react-store@npm:0.9.2" + version: 0.9.3 + resolution: "@tanstack/react-store@npm:0.9.3" dependencies: - "@tanstack/store": "npm:0.9.2" + "@tanstack/store": "npm:0.9.3" use-sync-external-store: "npm:^1.6.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/54c00bcfe7563ea7d8ed1c7cd7290172b6381952b8756faeac8e4f22c31c431e5c38b5c105053be629672e371b1dde17e7b9b81d4f2ab274e601507e1cc3c018 + checksum: 10c0/4d043b7211129418efa5ec1fafec7315b46b7ef92658f434b8e7a9a5dbf7687418c70f3c11f9d1636f304fa868a6d969380444d2042442ef94fc5517d19c5e4c languageName: node linkType: hard @@ -4417,10 +4408,10 @@ __metadata: languageName: node linkType: hard -"@tanstack/store@npm:0.9.2, @tanstack/store@npm:^0.9.1": - version: 0.9.2 - resolution: "@tanstack/store@npm:0.9.2" - checksum: 10c0/62e0c6ed2d437d7e2f54a2c9793c7f0c469779d48bc1e502af3e6151c30fdfdc1ec222ed2c42472edce56cfab5ee99db496ced59ba1ec760b814375a1db0a60f +"@tanstack/store@npm:0.9.3, @tanstack/store@npm:^0.9.1": + version: 0.9.3 + resolution: "@tanstack/store@npm:0.9.3" + checksum: 10c0/ec022c792c298be0717d7a2d06d6db4459077db775a27d86a2a248f446257e133c5d7c77a74bf6a4fa6993cfaef09b6f58a68a601c3becca834ed0b457ebe824 languageName: node linkType: hard @@ -4951,7 +4942,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0": +"@types/node@npm:*, @types/node@npm:>=13.7.0": version: 25.5.0 resolution: "@types/node@npm:25.5.0" dependencies: @@ -5290,7 +5281,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.10, @types/ws@npm:^8.5.12": +"@types/ws@npm:^8.5.10": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" dependencies: @@ -5315,105 +5306,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.57.1, @typescript-eslint/eslint-plugin@npm:^8.0.0": - version: 8.57.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.57.1" +"@typescript-eslint/eslint-plugin@npm:8.57.2, @typescript-eslint/eslint-plugin@npm:^8.0.0": + version: 8.57.2 + resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2" dependencies: "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.57.1" - "@typescript-eslint/type-utils": "npm:8.57.1" - "@typescript-eslint/utils": "npm:8.57.1" - "@typescript-eslint/visitor-keys": "npm:8.57.1" + "@typescript-eslint/scope-manager": "npm:8.57.2" + "@typescript-eslint/type-utils": "npm:8.57.2" + "@typescript-eslint/utils": "npm:8.57.2" + "@typescript-eslint/visitor-keys": "npm:8.57.2" ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" ts-api-utils: "npm:^2.4.0" peerDependencies: - "@typescript-eslint/parser": ^8.57.1 + "@typescript-eslint/parser": ^8.57.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/5bf9227f5d608d4313c9f898da3a2f6737eca985aa925df9e90b73499b9d552221781d3d09245543c6d09995ab262ea0d6773d2dae4b8bdf319765d46b22d0e1 + checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.57.1, @typescript-eslint/parser@npm:^8.0.0": - version: 8.57.1 - resolution: "@typescript-eslint/parser@npm:8.57.1" +"@typescript-eslint/parser@npm:8.57.2, @typescript-eslint/parser@npm:^8.0.0": + version: 8.57.2 + resolution: "@typescript-eslint/parser@npm:8.57.2" dependencies: - "@typescript-eslint/scope-manager": "npm:8.57.1" - "@typescript-eslint/types": "npm:8.57.1" - "@typescript-eslint/typescript-estree": "npm:8.57.1" - "@typescript-eslint/visitor-keys": "npm:8.57.1" + "@typescript-eslint/scope-manager": "npm:8.57.2" + "@typescript-eslint/types": "npm:8.57.2" + "@typescript-eslint/typescript-estree": "npm:8.57.2" + "@typescript-eslint/visitor-keys": "npm:8.57.2" debug: "npm:^4.4.3" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/ab624f5ad6f3585ee690d11be36597135779a373e7f07810ed921163de2e879000f6d3213db67413ee630bcf25d5cfaa24b089ee49596cd11b0456372bc17163 + checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6 languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/project-service@npm:8.57.1" +"@typescript-eslint/project-service@npm:8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/project-service@npm:8.57.2" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.57.1" - "@typescript-eslint/types": "npm:^8.57.1" + "@typescript-eslint/tsconfig-utils": "npm:^8.57.2" + "@typescript-eslint/types": "npm:^8.57.2" debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/7830f61e35364ba77799f4badeaca8bd8914bbcda6afe37b788821f94f4b88b9c49817c50f4bdba497e8e542a705e9d921d36f5e67960ebf33f4f3d3111cdfee + checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.57.1, @typescript-eslint/scope-manager@npm:^8.56.0": - version: 8.57.1 - resolution: "@typescript-eslint/scope-manager@npm:8.57.1" +"@typescript-eslint/scope-manager@npm:8.57.2, @typescript-eslint/scope-manager@npm:^8.56.0": + version: 8.57.2 + resolution: "@typescript-eslint/scope-manager@npm:8.57.2" dependencies: - "@typescript-eslint/types": "npm:8.57.1" - "@typescript-eslint/visitor-keys": "npm:8.57.1" - checksum: 10c0/42b0b54981318bf21be6b107df82910718497b7b7b2b60df635aa06d78e313759e4b675830c0e542b6d87104d35b49df41b9fb7739b8ae326eaba2d6f7116166 + "@typescript-eslint/types": "npm:8.57.2" + "@typescript-eslint/visitor-keys": "npm:8.57.2" + checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.57.1, @typescript-eslint/tsconfig-utils@npm:^8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.1" +"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.2" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/3d3c8d80621507d31e4656c693534f28a1c04dfb047538cb79b0b6da874ef41875f5df5e814fa3a38812451cff6d5a7ae38d0bf77eb7fec7867f9c80af361b00 + checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/type-utils@npm:8.57.1" +"@typescript-eslint/type-utils@npm:8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/type-utils@npm:8.57.2" dependencies: - "@typescript-eslint/types": "npm:8.57.1" - "@typescript-eslint/typescript-estree": "npm:8.57.1" - "@typescript-eslint/utils": "npm:8.57.1" + "@typescript-eslint/types": "npm:8.57.2" + "@typescript-eslint/typescript-estree": "npm:8.57.2" + "@typescript-eslint/utils": "npm:8.57.2" debug: "npm:^4.4.3" ts-api-utils: "npm:^2.4.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/e8eae4e3b9ca71ad065c307fd3cdefdcc6abc31bda2ef74f0e54b5c9ac0ee6bc0e2d69ec9097899f4d7a99d4a8a72391503b47f4317b3b6b9ba41cea24e6b9e9 + checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.57.1, @typescript-eslint/types@npm:^8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/types@npm:8.57.1" - checksum: 10c0/f447015276a31871440b07e328c2bbcee8337d72dca90ae00ac91e87d09e28a8a9c2fe44726a5226fcaa7db9d5347aafa650d59f7577a074dc65ea1414d24da1 +"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/types@npm:8.57.2" + checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.57.1" +"@typescript-eslint/typescript-estree@npm:8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/typescript-estree@npm:8.57.2" dependencies: - "@typescript-eslint/project-service": "npm:8.57.1" - "@typescript-eslint/tsconfig-utils": "npm:8.57.1" - "@typescript-eslint/types": "npm:8.57.1" - "@typescript-eslint/visitor-keys": "npm:8.57.1" + "@typescript-eslint/project-service": "npm:8.57.2" + "@typescript-eslint/tsconfig-utils": "npm:8.57.2" + "@typescript-eslint/types": "npm:8.57.2" + "@typescript-eslint/visitor-keys": "npm:8.57.2" debug: "npm:^4.4.3" minimatch: "npm:^10.2.2" semver: "npm:^7.7.3" @@ -5421,32 +5412,32 @@ __metadata: ts-api-utils: "npm:^2.4.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/a87e1d920a8fd2231b6a98b279dc7680d10ceac072001e85a72cd43adce288ed471afcaf8f171378f5a3221c500b3cf0ffc10a75fd521fb69fbd8b26d4626677 + checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.57.1, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.56.0": - version: 8.57.1 - resolution: "@typescript-eslint/utils@npm:8.57.1" +"@typescript-eslint/utils@npm:8.57.2, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.56.0": + version: 8.57.2 + resolution: "@typescript-eslint/utils@npm:8.57.2" dependencies: "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.57.1" - "@typescript-eslint/types": "npm:8.57.1" - "@typescript-eslint/typescript-estree": "npm:8.57.1" + "@typescript-eslint/scope-manager": "npm:8.57.2" + "@typescript-eslint/types": "npm:8.57.2" + "@typescript-eslint/typescript-estree": "npm:8.57.2" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/c85d6e7c618dbf902fda98cc795883388bc512bc2c34c7ac0481ea43acb6dd3cd38d60bdb571b586f392419a17998c89330fd7b0b9a344161f4a595637dd3f55 + checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.57.1": - version: 8.57.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.57.1" +"@typescript-eslint/visitor-keys@npm:8.57.2": + version: 8.57.2 + resolution: "@typescript-eslint/visitor-keys@npm:8.57.2" dependencies: - "@typescript-eslint/types": "npm:8.57.1" + "@typescript-eslint/types": "npm:8.57.2" eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/088a545c4aec6d9cabb266e1e40634f5fafa06cb05ef172526555957b0d99ac08822733fb788a09227071fdd6bd8b63f054393a0ecf9d4599c54b57918aa0e57 + checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574 languageName: node linkType: hard @@ -5635,9 +5626,9 @@ __metadata: linkType: hard "@xmldom/xmldom@npm:^0.8.3": - version: 0.8.11 - resolution: "@xmldom/xmldom@npm:0.8.11" - checksum: 10c0/e768623de72c95d3dae6b5da8e33dda0d81665047811b5498d23a328d45b13feb5536fe921d0308b96a4a8dd8addf80b1f6ef466508051c0b581e63e0dc74ed5 + version: 0.8.12 + resolution: "@xmldom/xmldom@npm:0.8.12" + checksum: 10c0/b733c84292d1bee32ef21a05aba8f9063456b51a54068d0b4a1abf5545156ee0b9894b7ae23775b5881b11c35a8a03871d1b514fb7e1b11654cdbee57e1c2707 languageName: node linkType: hard @@ -5676,7 +5667,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.4, accepts@npm:~1.3.8": +"accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -6126,13 +6117,13 @@ __metadata: linkType: hard "axios@npm:^1.2.2, axios@npm:^1.6.0": - version: 1.13.6 - resolution: "axios@npm:1.13.6" + version: 1.14.0 + resolution: "axios@npm:1.14.0" dependencies: follow-redirects: "npm:^1.15.11" form-data: "npm:^4.0.5" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/51fb5af055c3b85662fa97df17d986ae2c37d13bf86d50b6bb36b6b3a2dec6966a1d3a14ab3774b71707b155ae3597ed9b7babdf1a1a863d1a31840cb8e7ec71 + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/2541f4aa215a7d1842429dad006fc682d82bc0e74bd14500823f7d8cce3bbae0e0a8c328c8538946718f366ab8ce5a4c12e9ad40e5a0f3482ff8bff0cd115d45 languageName: node linkType: hard @@ -6354,9 +6345,9 @@ __metadata: linkType: hard "bare-os@npm:^3.0.1": - version: 3.8.0 - resolution: "bare-os@npm:3.8.0" - checksum: 10c0/2211c5f9734c7d3c387a6ba2ff7fd6df805736a1d9b865a1b9e2ba0904782340ae9b9a58eb359e7e4b50d457a1b656290cc3c8d1628d5df5d95d327eeb3dab63 + version: 3.8.4 + resolution: "bare-os@npm:3.8.4" + checksum: 10c0/2c9e422c191cc534a19d155799320c820c30f4cdc88ad7cc202a62ef4fedb8720ca38762e56343f602082372487914c7699e57a4f1615db34c59ad3840f387fd languageName: node linkType: hard @@ -6370,20 +6361,23 @@ __metadata: linkType: hard "bare-stream@npm:^2.6.4": - version: 2.10.0 - resolution: "bare-stream@npm:2.10.0" + version: 2.11.0 + resolution: "bare-stream@npm:2.11.0" dependencies: streamx: "npm:^2.25.0" teex: "npm:^1.0.1" peerDependencies: + bare-abort-controller: "*" bare-buffer: "*" bare-events: "*" peerDependenciesMeta: + bare-abort-controller: + optional: true bare-buffer: optional: true bare-events: optional: true - checksum: 10c0/d4eab67fc6762d626ed8473dda3c30a7d2d1edf0c0f4f9bf53d23317287be1b43ea162e53871eb64af870d70cc642230a776cb056d093f36f375e69ef47c4537 + checksum: 10c0/1abdae0faa365ebbc6376ecf50adc6794939959e3b05f6a03cbe5817393507363358d790b2aa274208d170a1857994fb76e91e0a676f6cbe97bea239a47263bc languageName: node linkType: hard @@ -6403,19 +6397,12 @@ __metadata: languageName: node linkType: hard -"base64id@npm:2.0.0, base64id@npm:~2.0.0": - version: 2.0.0 - resolution: "base64id@npm:2.0.0" - checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 - languageName: node - linkType: hard - "baseline-browser-mapping@npm:^2.9.0, baseline-browser-mapping@npm:^2.9.11": - version: 2.10.10 - resolution: "baseline-browser-mapping@npm:2.10.10" + version: 2.10.12 + resolution: "baseline-browser-mapping@npm:2.10.12" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10c0/39dee9d955a5e017852f338cb9057feee8d938c82f217d63158f04ccdbbc1c19e80bbed8d15223e3d410ee8b3703829d41fd7eb345e6e44230034ea9adaf8a1d + checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363 languageName: node linkType: hard @@ -6508,11 +6495,11 @@ __metadata: linkType: hard "brace-expansion@npm:^5.0.2": - version: 5.0.4 - resolution: "brace-expansion@npm:5.0.4" + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" dependencies: balanced-match: "npm:^4.0.2" - checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a + checksum: 10c0/4d238e14ed4f5cc9c07285550a41cef23121ca08ba99fa9eb5b55b580dcb6bf868b8210aa10526bdc9f8dc97f33ca2a7259039c4cc131a93042beddb424c48e3 languageName: node linkType: hard @@ -6725,9 +6712,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001759": - version: 1.0.30001780 - resolution: "caniuse-lite@npm:1.0.30001780" - checksum: 10c0/8a88f39758a228852d6f3ac92362ecb7694b1b2b022f194d8dfe59123ad40a5de6202bf2dff0fe316bb3d5ca9caf316c22056e0da693459c3be2771cde4f4bf9 + version: 1.0.30001782 + resolution: "caniuse-lite@npm:1.0.30001782" + checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c languageName: node linkType: hard @@ -7231,7 +7218,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.2, cookie@npm:~0.7.1, cookie@npm:~0.7.2": +"cookie@npm:^0.7.2, cookie@npm:~0.7.1": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -7277,7 +7264,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5, cors@npm:~2.8.5": +"cors@npm:^2.8.5": version: 2.8.6 resolution: "cors@npm:2.8.6" dependencies: @@ -7613,7 +7600,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3, debug@npm:~4.4.1": +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -7801,9 +7788,9 @@ __metadata: linkType: hard "dexie@npm:^4.2.1": - version: 4.4.0 - resolution: "dexie@npm:4.4.0" - checksum: 10c0/42a29d103aaf13565dbe05be85f0c9bfbb097d56100a318e4d139d378dd88c898a53fe12df2bb1d00c969873712026902b365a0d0ded42f7ed18849e93aa02e4 + version: 4.4.1 + resolution: "dexie@npm:4.4.1" + checksum: 10c0/159abc1267a17ad6d1ea7363c162eb714a5ce01148a476ee1ea85525f3a91f3ea203e1a6d0feda6fde435f30fc440e1c6ee43044b644eecc213bd040320400f5 languageName: node linkType: hard @@ -7995,9 +7982,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.263": - version: 1.5.321 - resolution: "electron-to-chromium@npm:1.5.321" - checksum: 10c0/1272703857b8ac9868a75d495c141b71bad36adcb0df53393196da3819012fa2596ba48fccac750bdcb746a523d2a33543b36e9dc0ae727a55e7a6f00b2b155a + version: 1.5.328 + resolution: "electron-to-chromium@npm:1.5.328" + checksum: 10c0/284a642ee800f5e1968696a3b00dcc070f556b6a49d0a7df1aa8cf95558344a300724356c060e911c238a683c30ee01596cefc4664744f6a565555c4238b72e6 languageName: node linkType: hard @@ -8043,44 +8030,6 @@ __metadata: languageName: node linkType: hard -"engine.io-client@npm:~6.6.1": - version: 6.6.4 - resolution: "engine.io-client@npm:6.6.4" - dependencies: - "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.4.1" - engine.io-parser: "npm:~5.2.1" - ws: "npm:~8.18.3" - xmlhttprequest-ssl: "npm:~2.1.1" - checksum: 10c0/d90bc32d614f67db9c198d1c26a787529f3038a7429c75e677f5495938cc45f9e89d435e8860bcfcc01db410e21d2f245b914f2fcbdb03ffd50d30f2aeec5143 - languageName: node - linkType: hard - -"engine.io-parser@npm:~5.2.1": - version: 5.2.3 - resolution: "engine.io-parser@npm:5.2.3" - checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 - languageName: node - linkType: hard - -"engine.io@npm:~6.6.0": - version: 6.6.6 - resolution: "engine.io@npm:6.6.6" - dependencies: - "@types/cors": "npm:^2.8.12" - "@types/node": "npm:>=10.0.0" - "@types/ws": "npm:^8.5.12" - accepts: "npm:~1.3.4" - base64id: "npm:2.0.0" - cookie: "npm:~0.7.2" - cors: "npm:~2.8.5" - debug: "npm:~4.4.1" - engine.io-parser: "npm:~5.2.1" - ws: "npm:~8.18.3" - checksum: 10c0/014b8864e786846e7b896b5a854d7b1117a9f29af9c48975937247f0fc8e8ae5616b2f2bd9398ca66b94e2c7cec4f5d13cbec58054ce513c17aa4f4921c0ad24 - languageName: node - linkType: hard - "enhanced-resolve@npm:^5.19.0, enhanced-resolve@npm:^5.20.0": version: 5.20.1 resolution: "enhanced-resolve@npm:5.20.1" @@ -8509,14 +8458,14 @@ __metadata: linkType: hard "eslint-plugin-testing-library@npm:^7.2.0": - version: 7.16.1 - resolution: "eslint-plugin-testing-library@npm:7.16.1" + version: 7.16.2 + resolution: "eslint-plugin-testing-library@npm:7.16.2" dependencies: "@typescript-eslint/scope-manager": "npm:^8.56.0" "@typescript-eslint/utils": "npm:^8.56.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - checksum: 10c0/d6a93b7bb953a5fc2cc23afc782e5d430543773afb61e63e53200b7d859f6caf16f31e29f4b2217a4ad03aa465ec9a56bcea5fe34f8f1f6c5f90af9bdd42db78 + checksum: 10c0/ea99f41eee929e0b139d927f8a89bef7ffe3bd33c51fc3c2c93d8e9d146e146395e1f7bb8a34d4b5bd9a1ffe7a1879959fc171e952c731e1e8310b079d9d5c97 languageName: node linkType: hard @@ -9515,9 +9464,9 @@ __metadata: linkType: hard "graphql@npm:^16.8.1": - version: 16.13.1 - resolution: "graphql@npm:16.13.1" - checksum: 10c0/0c7a9aea59504fbf3e0674f13ddb82935780f2a388e1db0ef41c3711c0ff8cb0a871c50d30d2d5288f32b946af3570d6f9ba8d13b03a330336f27121f9ac7a6b + version: 16.13.2 + resolution: "graphql@npm:16.13.2" + checksum: 10c0/64e822a0a0e4398781e4bc9765b88d370c08261498b517add4b878038ef7be2005b6b394a79a5102b9379d57052f60bc7f23fec8f39808d101984a74772ebd9d languageName: node linkType: hard @@ -11023,13 +10972,13 @@ __metadata: linkType: hard "jest-styled-components@npm:^7.0.5": - version: 7.2.0 - resolution: "jest-styled-components@npm:7.2.0" + version: 7.4.0 + resolution: "jest-styled-components@npm:7.4.0" dependencies: - "@adobe/css-tools": "npm:^4.0.1" + "@adobe/css-tools": "npm:^4.4.0" peerDependencies: styled-components: ">= 5" - checksum: 10c0/44eecf73cd1ee50686c9c16517222e2c012422dd7d90a07813f82f3ccce4059563e620d25aed60274e05d31fe6375b1f31a3486033aa39ea5e82fd94afcfa32f + checksum: 10c0/51f6a33690532dc8496e65b7c58ecd69f6bd75d1a4e61bd14c89c63095550d97244daef8d15406ba01198f74fc3c2706b95e66c53b17181ddf30ab1ffa4c4a32 languageName: node linkType: hard @@ -11466,9 +11415,9 @@ __metadata: linkType: hard "libphonenumber-js@npm:^1.9.44": - version: 1.12.40 - resolution: "libphonenumber-js@npm:1.12.40" - checksum: 10c0/bd4aaa2052d76abeb58c6febf737e22562b7e5abf7a6a8708b7f9fa0ed8cef5a74f2a616163425fb220b49062a0d311d87869c566b547915fd8a3b5c85b275e2 + version: 1.12.41 + resolution: "libphonenumber-js@npm:1.12.41" + checksum: 10c0/dda2a3ada1d4271a93f6eb9cc9b061e294ab05e7efb71c5e2e2bea02f8f478d7b4359fcd3a67b8a377e53ca671d0303ffa78e3df4857901d5aa03ecfb19becf0 languageName: node linkType: hard @@ -12119,14 +12068,14 @@ __metadata: linkType: hard "mini-css-extract-plugin@npm:^2.3.0": - version: 2.10.1 - resolution: "mini-css-extract-plugin@npm:2.10.1" + version: 2.10.2 + resolution: "mini-css-extract-plugin@npm:2.10.2" dependencies: schema-utils: "npm:^4.0.0" tapable: "npm:^2.2.1" peerDependencies: webpack: ^5.0.0 - checksum: 10c0/2e90dacdca5bc35862e601e6b3989673e498217c789c4bb270a35bbd22b4d6e85f091795d0324d5cda5a9e2aa2a8f1f3340b6db5a96d66ec208c627659bebb08 + checksum: 10c0/ba58afb1c090be144b423a3621c4e0d09a9f8f4875410d3bc108915dfcf8e2fd6e550a7f3ba129eb07fd76599157650f86186b09f3ae2c34ccc5b50e82da0aa3 languageName: node linkType: hard @@ -12187,11 +12136,11 @@ __metadata: linkType: hard "minipass-flush@npm:^1.0.5": - version: 1.0.5 - resolution: "minipass-flush@npm:1.0.5" + version: 1.0.7 + resolution: "minipass-flush@npm:1.0.7" dependencies: minipass: "npm:^3.0.0" - checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + checksum: 10c0/960915c02aa0991662c37c404517dd93708d17f96533b2ca8c1e776d158715d8107c5ced425ffc61674c167d93607f07f48a83c139ce1057f8781e5dfb4b90c2 languageName: node linkType: hard @@ -13183,9 +13132,9 @@ __metadata: linkType: hard "picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard @@ -13402,23 +13351,23 @@ __metadata: linkType: hard "posthog-js@npm:^1.259.0": - version: 1.363.1 - resolution: "posthog-js@npm:1.363.1" + version: 1.364.1 + resolution: "posthog-js@npm:1.364.1" dependencies: "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/api-logs": "npm:^0.208.0" "@opentelemetry/exporter-logs-otlp-http": "npm:^0.208.0" "@opentelemetry/resources": "npm:^2.2.0" "@opentelemetry/sdk-logs": "npm:^0.208.0" - "@posthog/core": "npm:1.24.1" - "@posthog/types": "npm:1.363.1" + "@posthog/core": "npm:1.24.3" + "@posthog/types": "npm:1.364.1" core-js: "npm:^3.38.1" dompurify: "npm:^3.3.2" fflate: "npm:^0.4.8" preact: "npm:^10.28.2" query-selector-shadow-dom: "npm:^1.0.1" web-vitals: "npm:^5.1.0" - checksum: 10c0/953a351958e9ff22c60a1195bff876a58729c823ac6663bd156c35913800ff301e61a3872c2720c8604c94264800583239c31a486e3b9837bf01aa6ff506e4ad + checksum: 10c0/8e93938dc43bbbd7888b75118b80a46e372afb38a4b33b07a4ae1537a23c917e5ddad68e48758e9e16b19a33c570ab11ddc3f368265873a793e4d083ad1a0186 languageName: node linkType: hard @@ -13627,10 +13576,10 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 languageName: node linkType: hard @@ -14876,53 +14825,6 @@ __metadata: languageName: node linkType: hard -"socket.io-adapter@npm:~2.5.2": - version: 2.5.6 - resolution: "socket.io-adapter@npm:2.5.6" - dependencies: - debug: "npm:~4.4.1" - ws: "npm:~8.18.3" - checksum: 10c0/2af9827c3e8e2a445d7d1523f7ad4fcc37009da44f72042e8a9af27e4caf29fe0a34de6771a6e9971a0ff8d527631fe25b80230ff6c42c045e2913f0ac308059 - languageName: node - linkType: hard - -"socket.io-client@npm:^4.7.5": - version: 4.8.3 - resolution: "socket.io-client@npm:4.8.3" - dependencies: - "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.4.1" - engine.io-client: "npm:~6.6.1" - socket.io-parser: "npm:~4.2.4" - checksum: 10c0/76c0d86de0b636d0bf5011cf3425212c900f43dac632db6eb493816920cd035af7ddd92fea9a106b45eb347405953c0414eb3a5d465b180215e46fc8b61420b3 - languageName: node - linkType: hard - -"socket.io-parser@npm:~4.2.4": - version: 4.2.6 - resolution: "socket.io-parser@npm:4.2.6" - dependencies: - "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.4.1" - checksum: 10c0/ba0a0b541b0a8e9d02b45c04c4c93a02331be5ea3478073c65bb9ff87032f12469c9adb309728eb90c0a352618d645ab88999c167a11c783cac861d7fd35c9d1 - languageName: node - linkType: hard - -"socket.io@npm:^4.7.5": - version: 4.8.3 - resolution: "socket.io@npm:4.8.3" - dependencies: - accepts: "npm:~1.3.4" - base64id: "npm:~2.0.0" - cors: "npm:~2.8.5" - debug: "npm:~4.4.1" - engine.io: "npm:~6.6.0" - socket.io-adapter: "npm:~2.5.2" - socket.io-parser: "npm:~4.2.4" - checksum: 10c0/1f7c4118cdbcb346e63db9d8fd657c3dc5caf148404762ed98ac14c4e7b74984a65fe51657bfe1696adcf7c168d1a3aad4a26b52864ce8491556f38218598f0b - languageName: node - linkType: hard - "sockjs@npm:^0.3.24": version: 0.3.24 resolution: "sockjs@npm:0.3.24" @@ -15563,9 +15465,9 @@ __metadata: linkType: hard "tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 + version: 2.3.2 + resolution: "tapable@npm:2.3.2" + checksum: 10c0/45ec8bd8963907f35bba875f9b3e9a5afa5ba11a9a4e4a2d7b2313d983cb2741386fd7dd3e54b13055b2be942971aac369d197e02263ec9216c59c0a8069ed7f languageName: node linkType: hard @@ -15582,15 +15484,15 @@ __metadata: linkType: hard "tar@npm:^7.5.4": - version: 7.5.12 - resolution: "tar@npm:7.5.12" + version: 7.5.13 + resolution: "tar@npm:7.5.13" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/3825c5974f5fde792981f47ee9ffea021ee7f4b552b7ab95eeb98e5dfadfd5a5d5861f01fb772e2e5637a41980d3c019fd6cdad1be48b462b886abd7fe0fa17c + checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b languageName: node linkType: hard @@ -16075,17 +15977,17 @@ __metadata: linkType: hard "typescript-eslint@npm:^8.24.0": - version: 8.57.1 - resolution: "typescript-eslint@npm:8.57.1" + version: 8.57.2 + resolution: "typescript-eslint@npm:8.57.2" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.57.1" - "@typescript-eslint/parser": "npm:8.57.1" - "@typescript-eslint/typescript-estree": "npm:8.57.1" - "@typescript-eslint/utils": "npm:8.57.1" + "@typescript-eslint/eslint-plugin": "npm:8.57.2" + "@typescript-eslint/parser": "npm:8.57.2" + "@typescript-eslint/typescript-estree": "npm:8.57.2" + "@typescript-eslint/utils": "npm:8.57.2" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/be5a19738a785a2695e01874cbedbddbb63ea0a1c2eac331be7d251bda35116505f4d4d8de5a25a77a09392396247af4b89d2a793580217af4891e9e5036a716 + checksum: 10c0/b657195d7f080eae54527354f847af0300f7f3d7126515c692b92f5d4a880bc40b11a350ea98e1decf62846cce085c072005eb867019b3b7e8a76b4f0ec18713 languageName: node linkType: hard @@ -16501,9 +16403,9 @@ __metadata: linkType: hard "web-vitals@npm:^5.1.0": - version: 5.1.0 - resolution: "web-vitals@npm:5.1.0" - checksum: 10c0/1af22ddbe2836ba880fcb492cfba24c3349f4760ebb5e92f38324ea67bca3c4dbb9c86f1a32af4795b6115cdaf98b90000cf3a7402bffef6e8c503f0d1b2e706 + version: 5.2.0 + resolution: "web-vitals@npm:5.2.0" + checksum: 10c0/b8d4d02025afb5e1668fb9725c337616c266d9891979b1e58f81b610ee3bf53727b97babb16322fdac2b9d43ce6daa1787f42b959695796474a2061e79e219d7 languageName: node linkType: hard @@ -16966,21 +16868,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:~8.18.3": - version: 8.18.3 - resolution: "ws@npm:8.18.3" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 - languageName: node - linkType: hard - "wsl-utils@npm:^0.1.0": version: 0.1.0 resolution: "wsl-utils@npm:0.1.0" @@ -17011,13 +16898,6 @@ __metadata: languageName: node linkType: hard -"xmlhttprequest-ssl@npm:~2.1.1": - version: 2.1.2 - resolution: "xmlhttprequest-ssl@npm:2.1.2" - checksum: 10c0/70d60869323e823f473a238f78fd108437edbc3690ecd5859c39c83217080090a18899b272e515769c0d1f518cc64cbed6b6995b23fdd7ba13b297d530b6e631 - languageName: node - linkType: hard - "xtend@npm:^4.0.0": version: 4.0.2 resolution: "xtend@npm:4.0.2"