Browse Source

feat(websockets): add websockets service

Karol Sójko 2 years ago
parent
commit
d28c268e86
54 changed files with 1518 additions and 7 deletions
  1. 182 0
      .github/workflows/websockets.release.yml
  2. 71 0
      .pnp.cjs
  3. BIN
      .yarn/cache/@standardnotes-api-npm-1.16.1-7af309f020-1f939c1825.zip
  4. BIN
      .yarn/cache/@standardnotes-encryption-npm-1.18.0-d83f58c21a-f9c39d0986.zip
  5. 10 6
      package.json
  6. 1 0
      packages/common/src/Domain/DataType/JSONString.ts
  7. 1 0
      packages/common/src/Domain/index.ts
  8. 7 0
      packages/domain-events/src/Domain/Event/WebSocketMessageRequestedEvent.ts
  9. 6 0
      packages/domain-events/src/Domain/Event/WebSocketMessageRequestedEventPayload.ts
  10. 2 0
      packages/domain-events/src/Domain/index.ts
  11. 28 0
      packages/websockets/.env.sample
  12. 3 0
      packages/websockets/.eslintignore
  13. 6 0
      packages/websockets/.eslintrc
  14. 27 0
      packages/websockets/Dockerfile
  15. 70 0
      packages/websockets/bin/server.ts
  16. 25 0
      packages/websockets/bin/worker.ts
  17. 22 0
      packages/websockets/docker/entrypoint.sh
  18. 12 0
      packages/websockets/jest.config.js
  19. 4 0
      packages/websockets/linter.tsconfig.json
  20. 59 0
      packages/websockets/package.json
  21. 186 0
      packages/websockets/src/Bootstrap/Container.ts
  22. 24 0
      packages/websockets/src/Bootstrap/Env.ts
  23. 40 0
      packages/websockets/src/Bootstrap/Types.ts
  24. 5 0
      packages/websockets/src/Client/ClientMessengerInterface.ts
  25. 99 0
      packages/websockets/src/Controller/ApiGatewayAuthMiddleware.spec.ts
  26. 59 0
      packages/websockets/src/Controller/ApiGatewayAuthMiddleware.ts
  27. 28 0
      packages/websockets/src/Controller/WebSocketsController.spec.ts
  28. 29 0
      packages/websockets/src/Controller/WebSocketsController.ts
  29. 14 0
      packages/websockets/src/Domain/Handler/WebSocketMessageRequestedEventHandler.ts
  30. 26 0
      packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.spec.ts
  31. 26 0
      packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.ts
  32. 4 0
      packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionDTO.ts
  33. 3 0
      packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionResponse.ts
  34. 25 0
      packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnection.spec.ts
  35. 3 0
      packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionDTO.ts
  36. 3 0
      packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionResponse.ts
  37. 26 0
      packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken.ts
  38. 26 0
      packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.spec.ts
  39. 26 0
      packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.ts
  40. 3 0
      packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionDTO.ts
  41. 3 0
      packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionResponse.ts
  42. 3 0
      packages/websockets/src/Domain/UseCase/UseCaseInterface.ts
  43. 5 0
      packages/websockets/src/Domain/WebSockets/WebSocketsConnectionRepositoryInterface.ts
  44. 9 0
      packages/websockets/src/Infra/InversifyExpressUtils/InversifyExpressHealthCheckController.ts
  45. 56 0
      packages/websockets/src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController.ts
  46. 44 0
      packages/websockets/src/Infra/Redis/RedisWebSocketsConnectionRepository.spec.ts
  47. 28 0
      packages/websockets/src/Infra/Redis/RedisWebSocketsConnectionRepository.ts
  48. 36 0
      packages/websockets/src/Infra/WebSockets/WebSocketsClientMessenger.ts
  49. 43 0
      packages/websockets/src/Infra/WebSockets/WebSocketsClientService.spec.ts
  50. 0 0
      packages/websockets/test-setup.ts
  51. 13 0
      packages/websockets/tsconfig.json
  52. 17 0
      packages/websockets/wait-for.sh
  53. 3 0
      tsconfig.json
  54. 67 1
      yarn.lock

+ 182 - 0
.github/workflows/websockets.release.yml

@@ -0,0 +1,182 @@
+name: Websockets Server
+
+concurrency:
+  group: websockets
+  cancel-in-progress: true
+
+on:
+  push:
+    tags:
+      - '*standardnotes/websockets-server*'
+  workflow_dispatch:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Set up Node
+      uses: actions/setup-node@v3
+      with:
+        registry-url: 'https://registry.npmjs.org'
+        node-version-file: '.nvmrc'
+
+    - name: Build
+      run: yarn build
+
+    - name: Lint
+      run: yarn lint:websockets
+
+    - name: Test
+      run: yarn test:websockets
+
+  publish-aws-ecr:
+    needs: test
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Node
+      uses: actions/setup-node@v3
+      with:
+        registry-url: 'https://registry.npmjs.org'
+        node-version-file: '.nvmrc'
+    - name: Build locally
+      run: yarn build
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+    - name: Login to Amazon ECR
+      id: login-ecr
+      uses: aws-actions/amazon-ecr-login@v1
+    - name: Build, tag, and push image to Amazon ECR
+      id: build-image
+      env:
+        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+        ECR_REPOSITORY: websockets
+        IMAGE_TAG: ${{ github.sha }}
+      run: |
+        yarn docker build @standardnotes/websockets-server -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+        docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
+        docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
+
+  publish-docker-hub:
+    needs: test
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Node
+      uses: actions/setup-node@v3
+      with:
+        registry-url: 'https://registry.npmjs.org'
+        node-version-file: '.nvmrc'
+    - name: Build locally
+      run: yarn build
+    - name: Login to Docker Hub
+      uses: docker/login-action@v2
+      with:
+        username: ${{ secrets.DOCKER_USERNAME }}
+        password: ${{ secrets.DOCKER_PASSWORD }}
+    - name: Publish Docker image as stable
+      run: |
+        yarn docker build @standardnotes/websockets-server -t standardnotes/websockets:latest
+        docker push standardnotes/websockets:latest
+
+  deploy-web:
+    needs: publish-aws-ecr
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+    - name: Download task definition
+      run: |
+        aws ecs describe-task-definition --task-definition websockets-prod --query taskDefinition > task-definition.json
+    - name: Fill in the new version in the Amazon ECS task definition
+      run: |
+        jq '(.containerDefinitions[] | select(.name=="websockets-prod") | .environment[] | select(.name=="VERSION")).value = "${{ github.sha }}"' task-definition.json > tmp.json && mv tmp.json task-definition.json
+    - name: Fill in the new image ID in the Amazon ECS task definition
+      id: task-def-prod
+      uses: aws-actions/amazon-ecs-render-task-definition@v1
+      with:
+        task-definition: task-definition.json
+        container-name: websockets-prod
+        image: ${{ secrets.AWS_ECR_REGISTRY }}/websockets:${{ github.sha }}
+    - name: Deploy Amazon ECS task definition
+      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+      with:
+        task-definition: ${{ steps.task-def-prod.outputs.task-definition }}
+        service: websockets-prod
+        cluster: prod
+        wait-for-service-stability: true
+
+  deploy-worker:
+    needs: publish-aws-ecr
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+    - name: Download task definition
+      run: |
+        aws ecs describe-task-definition --task-definition websockets-worker-prod --query taskDefinition > task-definition.json
+    - name: Fill in the new version in the Amazon ECS task definition
+      run: |
+        jq '(.containerDefinitions[] | select(.name=="websockets-worker-prod") | .environment[] | select(.name=="VERSION")).value = "${{ github.sha }}"' task-definition.json > tmp.json && mv tmp.json task-definition.json
+    - name: Fill in the new image ID in the Amazon ECS task definition
+      id: task-def-prod
+      uses: aws-actions/amazon-ecs-render-task-definition@v1
+      with:
+        task-definition: task-definition.json
+        container-name: websockets-worker-prod
+        image: ${{ secrets.AWS_ECR_REGISTRY }}/websockets:${{ github.sha }}
+    - name: Deploy Amazon ECS task definition
+      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+      with:
+        task-definition: ${{ steps.task-def-prod.outputs.task-definition }}
+        service: websockets-worker-prod
+        cluster: prod
+        wait-for-service-stability: true
+
+  newrelic:
+    needs: [ deploy-web, deploy-worker ]
+
+    runs-on: ubuntu-latest
+    steps:
+      - name: Create New Relic deployment marker for Web
+        uses: newrelic/deployment-marker-action@v1
+        with:
+          accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }}
+          apiKey: ${{ secrets.NEW_RELIC_API_KEY }}
+          applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_WEBSOCKETS_WEB_PROD }}
+          revision: "${{ github.sha }}"
+          description: "Automated Deployment via Github Actions"
+          user: "${{ github.actor }}"
+      - name: Create New Relic deployment marker for Worker
+        uses: newrelic/deployment-marker-action@v1
+        with:
+          accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }}
+          apiKey: ${{ secrets.NEW_RELIC_API_KEY }}
+          applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_WEBSOCKETS_WORKER_PROD }}
+          revision: "${{ github.sha }}"
+          description: "Automated Deployment via Github Actions"
+          user: "${{ github.actor }}"

+ 71 - 0
.pnp.cjs

@@ -80,6 +80,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         "name": "@standardnotes/time",\
         "reference": "workspace:packages/time"\
       },\
+      {\
+        "name": "@standardnotes/websockets-server",\
+        "reference": "workspace:packages/websockets"\
+      },\
       {\
         "name": "@standardnotes/workspace-server",\
         "reference": "workspace:packages/workspace"\
@@ -104,6 +108,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
       ["@standardnotes/sncrypto-node", ["workspace:packages/sncrypto-node"]],\
       ["@standardnotes/syncing-server", ["workspace:packages/syncing-server"]],\
       ["@standardnotes/time", ["workspace:packages/time"]],\
+      ["@standardnotes/websockets-server", ["workspace:packages/websockets"]],\
       ["@standardnotes/workspace-server", ["workspace:packages/workspace"]]\
     ],\
     "fallbackPool": [\
@@ -2534,6 +2539,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
           "linkType": "HARD"\
+        }],\
+        ["npm:1.16.1", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.16.1-7af309f020-1f939c1825.zip/node_modules/@standardnotes/api/",\
+          "packageDependencies": [\
+            ["@standardnotes/api", "npm:1.16.1"],\
+            ["@standardnotes/common", "workspace:packages/common"],\
+            ["@standardnotes/encryption", "npm:1.18.0"],\
+            ["@standardnotes/models", "npm:1.27.0"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
+            ["@standardnotes/security", "workspace:packages/security"],\
+            ["@standardnotes/utils", "npm:1.10.0"],\
+            ["reflect-metadata", "npm:0.1.13"]\
+          ],\
+          "linkType": "HARD"\
         }]\
       ]],\
       ["@standardnotes/api-gateway", [\
@@ -2737,6 +2756,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
             ["reflect-metadata", "npm:0.1.13"]\
           ],\
           "linkType": "HARD"\
+        }],\
+        ["npm:1.18.0", {\
+          "packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.18.0-d83f58c21a-f9c39d0986.zip/node_modules/@standardnotes/encryption/",\
+          "packageDependencies": [\
+            ["@standardnotes/encryption", "npm:1.18.0"],\
+            ["@standardnotes/common", "workspace:packages/common"],\
+            ["@standardnotes/models", "npm:1.27.0"],\
+            ["@standardnotes/responses", "npm:1.11.0"],\
+            ["@standardnotes/sncrypto-common", "npm:1.13.0"],\
+            ["@standardnotes/utils", "npm:1.10.0"],\
+            ["reflect-metadata", "npm:0.1.13"]\
+          ],\
+          "linkType": "HARD"\
         }]\
       ]],\
       ["@standardnotes/event-store", [\
@@ -3160,6 +3192,45 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
           "linkType": "HARD"\
         }]\
       ]],\
+      ["@standardnotes/websockets-server", [\
+        ["workspace:packages/websockets", {\
+          "packageLocation": "./packages/websockets/",\
+          "packageDependencies": [\
+            ["@standardnotes/websockets-server", "workspace:packages/websockets"],\
+            ["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
+            ["@sentry/node", "npm:7.5.0"],\
+            ["@standardnotes/api", "npm:1.16.1"],\
+            ["@standardnotes/common", "workspace:packages/common"],\
+            ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
+            ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
+            ["@standardnotes/security", "workspace:packages/security"],\
+            ["@types/cors", "npm:2.8.12"],\
+            ["@types/express", "npm:4.17.13"],\
+            ["@types/ioredis", "npm:4.28.10"],\
+            ["@types/jest", "npm:29.1.1"],\
+            ["@types/newrelic", "npm:7.0.3"],\
+            ["@typescript-eslint/eslint-plugin", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:5.30.5"],\
+            ["aws-sdk", "npm:2.1168.0"],\
+            ["axios", "npm:0.27.2"],\
+            ["cors", "npm:2.8.5"],\
+            ["dotenv", "npm:16.0.1"],\
+            ["eslint", "npm:8.19.0"],\
+            ["eslint-plugin-prettier", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.2.1"],\
+            ["express", "npm:4.18.1"],\
+            ["inversify", "npm:6.0.1"],\
+            ["inversify-express-utils", "npm:6.4.3"],\
+            ["ioredis", "npm:5.2.0"],\
+            ["jest", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:29.1.2"],\
+            ["mysql2", "npm:2.3.3"],\
+            ["newrelic", "npm:9.0.0"],\
+            ["reflect-metadata", "npm:0.1.13"],\
+            ["ts-jest", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:29.0.3"],\
+            ["typeorm", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.7"],\
+            ["winston", "npm:3.8.1"]\
+          ],\
+          "linkType": "SOFT"\
+        }]\
+      ]],\
       ["@standardnotes/workspace-server", [\
         ["workspace:packages/workspace", {\
           "packageLocation": "./packages/workspace/",\

BIN
.yarn/cache/@standardnotes-api-npm-1.16.1-7af309f020-1f939c1825.zip


BIN
.yarn/cache/@standardnotes-encryption-npm-1.18.0-d83f58c21a-f9c39d0986.zip


+ 10 - 6
package.json

@@ -18,6 +18,7 @@
     "lint:files": "yarn workspace @standardnotes/files-server lint",
     "lint:api-gateway": "yarn workspace @standardnotes/api-gateway lint",
     "lint:event-store": "yarn workspace @standardnotes/event-store lint",
+    "lint:websockets": "yarn workspace @standardnotes/websockets-server lint",
     "lint:workspace": "yarn workspace @standardnotes/workspace-server lint",
     "test": "yarn workspaces foreach -p -j 10 --verbose run test",
     "test:auth": "yarn workspace @standardnotes/auth-server test",
@@ -25,16 +26,18 @@
     "test:syncing-server": "yarn workspace @standardnotes/syncing-server test",
     "test:files": "yarn workspace @standardnotes/files-server test",
     "test:event-store": "yarn workspace @standardnotes/event-store test",
+    "test:websockets": "yarn workspace @standardnotes/websockets-server test",
     "test:workspace": "yarn workspace @standardnotes/workspace-server test",
     "clean": "yarn workspaces foreach -p --verbose run clean",
     "setup:env": "cp .env.sample .env && yarn workspaces foreach -p --verbose run setup:env",
     "build": "yarn workspaces foreach -pt -j 10 --verbose run build",
-    "build:auth": "yarn workspace @standardnotes/auth-server build",
-    "build:scheduler": "yarn workspace @standardnotes/scheduler-server build",
-    "build:syncing-server": "yarn workspace @standardnotes/syncing-server build",
-    "build:files": "yarn workspace @standardnotes/files-server build",
-    "build:api-gateway": "yarn workspace @standardnotes/api-gateway build",
-    "build:workspace": "yarn workspace @standardnotes/workspace-server build",
+    "build:auth": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/auth-server run build",
+    "build:scheduler": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/scheduler-server run build",
+    "build:syncing-server": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/syncing-server run build",
+    "build:files": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/files-server run build",
+    "build:api-gateway": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/api-gateway run build",
+    "build:websockets": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/websockets-server run build",
+    "build:workspace": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/workspace-server run build",
     "start:auth": "yarn workspace @standardnotes/auth-server start",
     "start:auth-worker": "yarn workspace @standardnotes/auth-server worker",
     "start:scheduler": "yarn workspace @standardnotes/scheduler-server worker",
@@ -43,6 +46,7 @@
     "start:files": "yarn workspace @standardnotes/files-server start",
     "start:files-worker": "yarn workspace @standardnotes/files-server worker",
     "start:api-gateway": "yarn workspace @standardnotes/api-gateway start",
+    "start:websockets": "yarn workspace @standardnotes/websockets-server start",
     "start:workspace": "yarn workspace @standardnotes/workspace-server start",
     "release": "lerna version --conventional-graduate --conventional-commits --yes -m \"chore(release): publish new version\"",
     "publish": "lerna publish from-git --yes --no-verify-access --loglevel verbose",

+ 1 - 0
packages/common/src/Domain/DataType/JSONString.ts

@@ -0,0 +1 @@
+export type JSONString = string

+ 1 - 0
packages/common/src/Domain/index.ts

@@ -2,6 +2,7 @@ export * from './Content/ContentType'
 export * from './Content/ContentDecoder'
 export * from './Content/ContentDecoderInterface'
 export * from './DataType/AnyRecord'
+export * from './DataType/JSONString'
 export * from './DataType/MicrosecondsTimestamp'
 export * from './DataType/Uuid'
 export * from './DataType/ApplicationIdentifier'

+ 7 - 0
packages/domain-events/src/Domain/Event/WebSocketMessageRequestedEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { WebSocketMessageRequestedEventPayload } from './WebSocketMessageRequestedEventPayload'
+
+export interface WebSocketMessageRequestedEvent extends DomainEventInterface {
+  type: 'WEB_SOCKET_MESSAGE_REQUESTED'
+  payload: WebSocketMessageRequestedEventPayload
+}

+ 6 - 0
packages/domain-events/src/Domain/Event/WebSocketMessageRequestedEventPayload.ts

@@ -0,0 +1,6 @@
+import { JSONString, Uuid } from '@standardnotes/common'
+
+export interface WebSocketMessageRequestedEventPayload {
+  userUuid: Uuid
+  message: JSONString
+}

+ 2 - 0
packages/domain-events/src/Domain/index.ts

@@ -100,6 +100,8 @@ export * from './Event/UserRolesChangedEvent'
 export * from './Event/UserRolesChangedEventPayload'
 export * from './Event/UserSignedInEvent'
 export * from './Event/UserSignedInEventPayload'
+export * from './Event/WebSocketMessageRequestedEvent'
+export * from './Event/WebSocketMessageRequestedEventPayload'
 export * from './Event/WorkspaceInviteCreatedEvent'
 export * from './Event/WorkspaceInviteCreatedEventPayload'
 

+ 28 - 0
packages/websockets/.env.sample

@@ -0,0 +1,28 @@
+LOG_LEVEL=debug
+NODE_ENV=development
+VERSION=development
+
+PORT=3000
+
+AUTH_JWT_SECRET=auth_jwt_secret
+
+REDIS_URL=redis://cache
+
+SNS_TOPIC_ARN=
+SNS_AWS_REGION=
+SQS_QUEUE_URL=
+SQS_AWS_REGION=
+
+REDIS_EVENTS_CHANNEL=events
+
+WEB_SOCKET_CONNECTION_TOKEN_SECRET=
+WEB_SOCKET_CONNECTION_TOKEN_TTL=
+
+# (Optional) New Relic Setup
+NEW_RELIC_ENABLED=false
+NEW_RELIC_APP_NAME=Websockets
+NEW_RELIC_LICENSE_KEY=
+NEW_RELIC_NO_CONFIG_FILE=true
+NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=false
+NEW_RELIC_LOG_ENABLED=false
+NEW_RELIC_LOG_LEVEL=info

+ 3 - 0
packages/websockets/.eslintignore

@@ -0,0 +1,3 @@
+dist
+test-setup.ts
+data

+ 6 - 0
packages/websockets/.eslintrc

@@ -0,0 +1,6 @@
+{
+  "extends": "../../.eslintrc",
+  "parserOptions": {
+    "project": "./linter.tsconfig.json"
+  }
+}

+ 27 - 0
packages/websockets/Dockerfile

@@ -0,0 +1,27 @@
+FROM node:16.15.1-alpine AS builder
+
+# Install dependencies for building native libraries
+RUN apk add --update git openssh-client python3 alpine-sdk
+
+WORKDIR /workspace
+
+# docker-build plugin copies everything needed for `yarn install` to `manifests` folder.
+COPY manifests ./
+
+RUN yarn install --immutable
+
+FROM node:16.15.1-alpine
+
+RUN apk add --update curl
+
+WORKDIR /workspace
+
+# Copy the installed dependencies from the previous stage.
+COPY --from=builder /workspace ./
+
+# docker-build plugin runs `yarn pack` in all workspace dependencies and copies them to `packs` folder.
+COPY packs ./
+
+ENTRYPOINT [ "/workspace/packages/websockets/docker/entrypoint.sh" ]
+
+CMD [ "start-web" ]

+ 70 - 0
packages/websockets/bin/server.ts

@@ -0,0 +1,70 @@
+import 'reflect-metadata'
+
+import 'newrelic'
+
+import * as Sentry from '@sentry/node'
+
+import '../src/Infra/InversifyExpressUtils/InversifyExpressHealthCheckController'
+import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'
+
+import * as cors from 'cors'
+import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'
+import * as winston from 'winston'
+
+import { InversifyExpressServer } from 'inversify-express-utils'
+import { ContainerConfigLoader } from '../src/Bootstrap/Container'
+import TYPES from '../src/Bootstrap/Types'
+import { Env } from '../src/Bootstrap/Env'
+
+const container = new ContainerConfigLoader()
+void container.load().then((container) => {
+  const env: Env = new Env()
+  env.load()
+
+  const server = new InversifyExpressServer(container)
+
+  server.setConfig((app) => {
+    app.use((_request: Request, response: Response, next: NextFunction) => {
+      response.setHeader('X-Websockets-Version', container.get(TYPES.VERSION))
+      next()
+    })
+    app.use(json())
+    app.use(urlencoded({ extended: true }))
+    app.use(cors())
+
+    if (env.get('SENTRY_DSN', true)) {
+      Sentry.init({
+        dsn: env.get('SENTRY_DSN'),
+        integrations: [new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true })],
+        tracesSampleRate: 0,
+      })
+
+      app.use(Sentry.Handlers.requestHandler() as RequestHandler)
+    }
+  })
+
+  const logger: winston.Logger = container.get(TYPES.Logger)
+
+  server.setErrorConfig((app) => {
+    if (env.get('SENTRY_DSN', true)) {
+      app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler)
+    }
+
+    app.use((error: Record<string, unknown>, _request: Request, response: Response, _next: NextFunction) => {
+      logger.error(error.stack)
+
+      response.status(500).send({
+        error: {
+          message:
+            "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.",
+        },
+      })
+    })
+  })
+
+  const serverInstance = server.build()
+
+  serverInstance.listen(env.get('PORT'))
+
+  logger.info(`Server started on port ${process.env.PORT}`)
+})

+ 25 - 0
packages/websockets/bin/worker.ts

@@ -0,0 +1,25 @@
+import 'reflect-metadata'
+
+import 'newrelic'
+
+import { Logger } from 'winston'
+
+import { ContainerConfigLoader } from '../src/Bootstrap/Container'
+import TYPES from '../src/Bootstrap/Types'
+import { Env } from '../src/Bootstrap/Env'
+import { DomainEventSubscriberFactoryInterface } from '@standardnotes/domain-events'
+
+const container = new ContainerConfigLoader()
+void container.load().then((container) => {
+  const env: Env = new Env()
+  env.load()
+
+  const logger: Logger = container.get(TYPES.Logger)
+
+  logger.info('Starting worker...')
+
+  const subscriberFactory: DomainEventSubscriberFactoryInterface = container.get(TYPES.DomainEventSubscriberFactory)
+  subscriberFactory.create().start()
+
+  setInterval(() => logger.info('Alive and kicking!'), 20 * 60 * 1000)
+})

+ 22 - 0
packages/websockets/docker/entrypoint.sh

@@ -0,0 +1,22 @@
+#!/bin/sh
+set -e
+
+COMMAND=$1 && shift 1
+
+case "$COMMAND" in
+  'start-web' )
+    echo "Starting Web..."
+    yarn workspace @standardnotes/websockets-server start
+    ;;
+
+  'start-worker' )
+    echo "Starting Worker..."
+    yarn workspace @standardnotes/websockets-server worker
+    ;;
+
+   * )
+    echo "Unknown command"
+    ;;
+esac
+
+exec "$@"

+ 12 - 0
packages/websockets/jest.config.js

@@ -0,0 +1,12 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const base = require('../../jest.config')
+const { defaults: tsjPreset } = require('ts-jest/presets')
+
+module.exports = {
+  ...base,
+  transform: {
+    ...tsjPreset.transform,
+  },
+  coveragePathIgnorePatterns: ['/Bootstrap/', '/InversifyExpressUtils/'],
+  setupFilesAfterEnv: ['./test-setup.ts'],
+}

+ 4 - 0
packages/websockets/linter.tsconfig.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["dist", "test-setup.ts"]
+}

+ 59 - 0
packages/websockets/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@standardnotes/websockets-server",
+  "version": "1.0.0",
+  "engines": {
+    "node": ">=16.0.0 <17.0.0"
+  },
+  "private": true,
+  "description": "Websockets Server",
+  "main": "dist/src/index.js",
+  "typings": "dist/src/index.d.ts",
+  "author": "Karol Sójko <karol@standardnotes.com>",
+  "license": "AGPL-3.0-or-later",
+  "scripts": {
+    "clean": "rm -fr dist",
+    "setup:env": "cp .env.sample .env",
+    "prebuild": "yarn clean",
+    "build": "tsc --rootDir ./",
+    "lint": "eslint . --ext .ts",
+    "pretest": "yarn lint && yarn build",
+    "test": "jest --coverage --config=./jest.config.js --maxWorkers=50%",
+    "start": "yarn node dist/bin/server.js",
+    "worker": "yarn node dist/bin/worker.js",
+    "typeorm": "typeorm-ts-node-commonjs"
+  },
+  "dependencies": {
+    "@newrelic/winston-enricher": "^4.0.0",
+    "@sentry/node": "^7.3.0",
+    "@standardnotes/api": "^1.16.1",
+    "@standardnotes/common": "workspace:^",
+    "@standardnotes/domain-events": "workspace:^",
+    "@standardnotes/domain-events-infra": "workspace:^",
+    "@standardnotes/security": "workspace:^",
+    "aws-sdk": "^2.1159.0",
+    "axios": "^0.27.2",
+    "cors": "2.8.5",
+    "dotenv": "^16.0.1",
+    "express": "^4.18.1",
+    "inversify": "^6.0.1",
+    "inversify-express-utils": "^6.4.3",
+    "ioredis": "^5.2.0",
+    "mysql2": "^2.3.3",
+    "newrelic": "^9.0.0",
+    "reflect-metadata": "0.1.13",
+    "typeorm": "^0.3.6",
+    "winston": "^3.8.1"
+  },
+  "devDependencies": {
+    "@types/cors": "^2.8.9",
+    "@types/express": "^4.17.11",
+    "@types/ioredis": "^4.28.10",
+    "@types/jest": "^29.1.1",
+    "@types/newrelic": "^7.0.3",
+    "@typescript-eslint/eslint-plugin": "^5.29.0",
+    "eslint": "^8.14.0",
+    "eslint-plugin-prettier": "^4.0.0",
+    "jest": "^29.1.2",
+    "ts-jest": "^29.0.3"
+  }
+}

+ 186 - 0
packages/websockets/src/Bootstrap/Container.ts

@@ -0,0 +1,186 @@
+import * as winston from 'winston'
+import Redis from 'ioredis'
+import * as AWS from 'aws-sdk'
+import { Container } from 'inversify'
+import {
+  DomainEventHandlerInterface,
+  DomainEventMessageHandlerInterface,
+  DomainEventSubscriberFactoryInterface,
+} from '@standardnotes/domain-events'
+import { Env } from './Env'
+import TYPES from './Types'
+import { WebSocketsConnectionRepositoryInterface } from '../Domain/WebSockets/WebSocketsConnectionRepositoryInterface'
+import { RedisWebSocketsConnectionRepository } from '../Infra/Redis/RedisWebSocketsConnectionRepository'
+import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
+import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
+import { WebSocketsClientMessenger } from '../Infra/WebSockets/WebSocketsClientMessenger'
+import {
+  RedisDomainEventSubscriberFactory,
+  RedisEventMessageHandler,
+  SQSDomainEventSubscriberFactory,
+  SQSEventMessageHandler,
+  SQSNewRelicEventMessageHandler,
+} from '@standardnotes/domain-events-infra'
+import { ApiGatewayAuthMiddleware } from '../Controller/ApiGatewayAuthMiddleware'
+
+import {
+  CrossServiceTokenData,
+  TokenDecoder,
+  TokenDecoderInterface,
+  TokenEncoder,
+  TokenEncoderInterface,
+  WebSocketConnectionTokenData,
+} from '@standardnotes/security'
+import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
+import { WebSocketsController } from '../Controller/WebSocketsController'
+import { WebSocketServerInterface } from '@standardnotes/api'
+import { ClientMessengerInterface } from '../Client/ClientMessengerInterface'
+import { WebSocketMessageRequestedEventHandler } from '../Domain/Handler/WebSocketMessageRequestedEventHandler'
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const newrelicFormatter = require('@newrelic/winston-enricher')
+
+export class ContainerConfigLoader {
+  async load(): Promise<Container> {
+    const env: Env = new Env()
+    env.load()
+
+    const container = new Container()
+
+    const redisUrl = env.get('REDIS_URL')
+    const isRedisInClusterMode = redisUrl.indexOf(',') > 0
+    let redis
+    if (isRedisInClusterMode) {
+      redis = new Redis.Cluster(redisUrl.split(','))
+    } else {
+      redis = new Redis(redisUrl)
+    }
+
+    container.bind(TYPES.Redis).toConstantValue(redis)
+
+    const newrelicWinstonFormatter = newrelicFormatter(winston)
+    const winstonFormatters = [winston.format.splat(), winston.format.json()]
+    if (env.get('NEW_RELIC_ENABLED', true) === 'true') {
+      winstonFormatters.push(newrelicWinstonFormatter())
+    }
+
+    const logger = winston.createLogger({
+      level: env.get('LOG_LEVEL') || 'info',
+      format: winston.format.combine(...winstonFormatters),
+      transports: [new winston.transports.Console({ level: env.get('LOG_LEVEL') || 'info' })],
+    })
+    container.bind<winston.Logger>(TYPES.Logger).toConstantValue(logger)
+
+    if (env.get('SNS_AWS_REGION', true)) {
+      container.bind<AWS.SNS>(TYPES.SNS).toConstantValue(
+        new AWS.SNS({
+          apiVersion: 'latest',
+          region: env.get('SNS_AWS_REGION', true),
+        }),
+      )
+    }
+
+    if (env.get('SQS_QUEUE_URL', true)) {
+      const sqsConfig: AWS.SQS.Types.ClientConfiguration = {
+        apiVersion: 'latest',
+        region: env.get('SQS_AWS_REGION', true),
+      }
+      if (env.get('SQS_ACCESS_KEY_ID', true) && env.get('SQS_SECRET_ACCESS_KEY', true)) {
+        sqsConfig.credentials = {
+          accessKeyId: env.get('SQS_ACCESS_KEY_ID', true),
+          secretAccessKey: env.get('SQS_SECRET_ACCESS_KEY', true),
+        }
+      }
+      container.bind<AWS.SQS>(TYPES.SQS).toConstantValue(new AWS.SQS(sqsConfig))
+    }
+
+    // Controller
+    container.bind<WebSocketServerInterface>(TYPES.WebSocketsController).to(WebSocketsController)
+
+    // Repositories
+    container
+      .bind<WebSocketsConnectionRepositoryInterface>(TYPES.WebSocketsConnectionRepository)
+      .to(RedisWebSocketsConnectionRepository)
+
+    // Middleware
+    container.bind<ApiGatewayAuthMiddleware>(TYPES.ApiGatewayAuthMiddleware).to(ApiGatewayAuthMiddleware)
+
+    // env vars
+    container.bind(TYPES.AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET'))
+    container
+      .bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)
+      .toConstantValue(env.get('WEB_SOCKET_CONNECTION_TOKEN_SECRET', true))
+    container
+      .bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL)
+      .toConstantValue(+env.get('WEB_SOCKET_CONNECTION_TOKEN_TTL', true))
+    container.bind(TYPES.REDIS_URL).toConstantValue(env.get('REDIS_URL'))
+    container.bind(TYPES.SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN', true))
+    container.bind(TYPES.SNS_AWS_REGION).toConstantValue(env.get('SNS_AWS_REGION', true))
+    container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL', true))
+    container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL'))
+    container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
+    container.bind(TYPES.WEBSOCKETS_API_URL).toConstantValue(env.get('WEBSOCKETS_API_URL', true))
+    container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION'))
+
+    // use cases
+    container.bind<AddWebSocketsConnection>(TYPES.AddWebSocketsConnection).to(AddWebSocketsConnection)
+    container.bind<RemoveWebSocketsConnection>(TYPES.RemoveWebSocketsConnection).to(RemoveWebSocketsConnection)
+    container
+      .bind<CreateWebSocketConnectionToken>(TYPES.CreateWebSocketConnectionToken)
+      .to(CreateWebSocketConnectionToken)
+
+    // Handlers
+    container
+      .bind<WebSocketMessageRequestedEventHandler>(TYPES.WebSocketMessageRequestedEventHandler)
+      .to(WebSocketMessageRequestedEventHandler)
+
+    // Services
+    container
+      .bind<TokenDecoderInterface<CrossServiceTokenData>>(TYPES.CrossServiceTokenDecoder)
+      .toConstantValue(new TokenDecoder<CrossServiceTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
+    container
+      .bind<TokenEncoderInterface<WebSocketConnectionTokenData>>(TYPES.WebSocketConnectionTokenEncoder)
+      .toConstantValue(
+        new TokenEncoder<WebSocketConnectionTokenData>(container.get(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)),
+      )
+    container.bind<ClientMessengerInterface>(TYPES.WebSocketsClientMessenger).to(WebSocketsClientMessenger)
+
+    const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
+      ['WEB_SOCKET_MESSAGE_REQUESTED', container.get(TYPES.WebSocketMessageRequestedEventHandler)],
+    ])
+
+    if (env.get('SQS_QUEUE_URL', true)) {
+      container
+        .bind<DomainEventMessageHandlerInterface>(TYPES.DomainEventMessageHandler)
+        .toConstantValue(
+          env.get('NEW_RELIC_ENABLED', true) === 'true'
+            ? new SQSNewRelicEventMessageHandler(eventHandlers, container.get(TYPES.Logger))
+            : new SQSEventMessageHandler(eventHandlers, container.get(TYPES.Logger)),
+        )
+      container
+        .bind<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
+        .toConstantValue(
+          new SQSDomainEventSubscriberFactory(
+            container.get(TYPES.SQS),
+            container.get(TYPES.SQS_QUEUE_URL),
+            container.get(TYPES.DomainEventMessageHandler),
+          ),
+        )
+    } else {
+      container
+        .bind<DomainEventMessageHandlerInterface>(TYPES.DomainEventMessageHandler)
+        .toConstantValue(new RedisEventMessageHandler(eventHandlers, container.get(TYPES.Logger)))
+      container
+        .bind<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
+        .toConstantValue(
+          new RedisDomainEventSubscriberFactory(
+            container.get(TYPES.Redis),
+            container.get(TYPES.DomainEventMessageHandler),
+            container.get(TYPES.REDIS_EVENTS_CHANNEL),
+          ),
+        )
+    }
+
+    return container
+  }
+}

+ 24 - 0
packages/websockets/src/Bootstrap/Env.ts

@@ -0,0 +1,24 @@
+import { config, DotenvParseOutput } from 'dotenv'
+import { injectable } from 'inversify'
+
+@injectable()
+export class Env {
+  private env?: DotenvParseOutput
+
+  public load(): void {
+    const output = config()
+    this.env = <DotenvParseOutput>output.parsed
+  }
+
+  public get(key: string, optional = false): string {
+    if (!this.env) {
+      this.load()
+    }
+
+    if (!process.env[key] && !optional) {
+      throw new Error(`Environment variable ${key} not set`)
+    }
+
+    return <string>process.env[key]
+  }
+}

+ 40 - 0
packages/websockets/src/Bootstrap/Types.ts

@@ -0,0 +1,40 @@
+const TYPES = {
+  Logger: Symbol.for('Logger'),
+  Redis: Symbol.for('Redis'),
+  SNS: Symbol.for('SNS'),
+  SQS: Symbol.for('SQS'),
+  // Controller
+  WebSocketsController: Symbol.for('WebSocketsController'),
+  // Repositories
+  WebSocketsConnectionRepository: Symbol.for('WebSocketsConnectionRepository'),
+  // Middleware
+  ApiGatewayAuthMiddleware: Symbol.for('ApiGatewayAuthMiddleware'),
+  // env vars
+  AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'),
+  WEB_SOCKET_CONNECTION_TOKEN_SECRET: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_SECRET'),
+  WEB_SOCKET_CONNECTION_TOKEN_TTL: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_TTL'),
+  REDIS_URL: Symbol.for('REDIS_URL'),
+  SNS_TOPIC_ARN: Symbol.for('SNS_TOPIC_ARN'),
+  SNS_AWS_REGION: Symbol.for('SNS_AWS_REGION'),
+  SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'),
+  SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'),
+  REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'),
+  NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'),
+  WEBSOCKETS_API_URL: Symbol.for('WEBSOCKETS_API_URL'),
+  VERSION: Symbol.for('VERSION'),
+  // use cases
+  AddWebSocketsConnection: Symbol.for('AddWebSocketsConnection'),
+  RemoveWebSocketsConnection: Symbol.for('RemoveWebSocketsConnection'),
+  CreateWebSocketConnectionToken: Symbol.for('CreateWebSocketConnectionToken'),
+  // Handlers
+  WebSocketMessageRequestedEventHandler: Symbol.for('WebSocketMessageRequestedEventHandler'),
+  // Services
+  CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),
+  WebSocketConnectionTokenEncoder: Symbol.for('WebSocketConnectionTokenEncoder'),
+  DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
+  DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'),
+  HTTPClient: Symbol.for('HTTPClient'),
+  WebSocketsClientMessenger: Symbol.for('WebSocketsClientMessenger'),
+}
+
+export default TYPES

+ 5 - 0
packages/websockets/src/Client/ClientMessengerInterface.ts

@@ -0,0 +1,5 @@
+import { JSONString, Uuid } from '@standardnotes/common'
+
+export interface ClientMessengerInterface {
+  send(userUuid: Uuid, message: JSONString): Promise<void>
+}

+ 99 - 0
packages/websockets/src/Controller/ApiGatewayAuthMiddleware.spec.ts

@@ -0,0 +1,99 @@
+import 'reflect-metadata'
+
+import { ApiGatewayAuthMiddleware } from './ApiGatewayAuthMiddleware'
+import { NextFunction, Request, Response } from 'express'
+import { Logger } from 'winston'
+import { CrossServiceTokenData, TokenDecoderInterface } from '@standardnotes/security'
+import { RoleName } from '@standardnotes/common'
+
+describe('ApiGatewayAuthMiddleware', () => {
+  let tokenDecoder: TokenDecoderInterface<CrossServiceTokenData>
+  let request: Request
+  let response: Response
+  let next: NextFunction
+
+  const logger = {
+    debug: jest.fn(),
+  } as unknown as jest.Mocked<Logger>
+
+  const createMiddleware = () => new ApiGatewayAuthMiddleware(tokenDecoder, logger)
+
+  beforeEach(() => {
+    tokenDecoder = {} as jest.Mocked<TokenDecoderInterface<CrossServiceTokenData>>
+    tokenDecoder.decodeToken = jest.fn().mockReturnValue({
+      user: {
+        uuid: '1-2-3',
+        email: 'test@test.te',
+      },
+      roles: [
+        {
+          uuid: 'a-b-c',
+          name: RoleName.CoreUser,
+        },
+      ],
+    })
+
+    request = {
+      headers: {},
+    } as jest.Mocked<Request>
+    response = {
+      locals: {},
+    } as jest.Mocked<Response>
+    response.status = jest.fn().mockReturnThis()
+    response.send = jest.fn()
+    next = jest.fn()
+  })
+
+  it('should authorize user', async () => {
+    request.headers['x-auth-token'] = 'auth-jwt-token'
+
+    await createMiddleware().handler(request, response, next)
+
+    expect(response.locals.user).toEqual({
+      uuid: '1-2-3',
+      email: 'test@test.te',
+    })
+    expect(response.locals.roles).toEqual([
+      {
+        uuid: 'a-b-c',
+        name: RoleName.CoreUser,
+      },
+    ])
+
+    expect(next).toHaveBeenCalled()
+  })
+
+  it('should not authorize if request is missing auth jwt token in headers', async () => {
+    await createMiddleware().handler(request, response, next)
+
+    expect(response.status).toHaveBeenCalledWith(401)
+    expect(next).not.toHaveBeenCalled()
+  })
+
+  it('should not authorize if auth jwt token is malformed', async () => {
+    request.headers['x-auth-token'] = 'auth-jwt-token'
+
+    tokenDecoder.decodeToken = jest.fn().mockReturnValue(undefined)
+
+    await createMiddleware().handler(request, response, next)
+
+    expect(response.status).toHaveBeenCalledWith(401)
+    expect(next).not.toHaveBeenCalled()
+  })
+
+  it('should pass the error to next middleware if one occurres', async () => {
+    request.headers['x-auth-token'] = 'auth-jwt-token'
+
+    const error = new Error('Ooops')
+
+    tokenDecoder.decodeToken = jest.fn().mockImplementation(() => {
+      throw error
+    })
+
+    await createMiddleware().handler(request, response, next)
+
+    expect(response.status).not.toHaveBeenCalled()
+
+    expect(next).toHaveBeenCalledWith(error)
+  })
+})

+ 59 - 0
packages/websockets/src/Controller/ApiGatewayAuthMiddleware.ts

@@ -0,0 +1,59 @@
+import { CrossServiceTokenData, TokenDecoderInterface } from '@standardnotes/security'
+import { NextFunction, Request, Response } from 'express'
+import { inject, injectable } from 'inversify'
+import { BaseMiddleware } from 'inversify-express-utils'
+import { Logger } from 'winston'
+import TYPES from '../Bootstrap/Types'
+
+@injectable()
+export class ApiGatewayAuthMiddleware extends BaseMiddleware {
+  constructor(
+    @inject(TYPES.CrossServiceTokenDecoder) private tokenDecoder: TokenDecoderInterface<CrossServiceTokenData>,
+    @inject(TYPES.Logger) private logger: Logger,
+  ) {
+    super()
+  }
+
+  async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
+    try {
+      if (!request.headers['x-auth-token']) {
+        this.logger.debug('ApiGatewayAuthMiddleware missing x-auth-token header.')
+
+        response.status(401).send({
+          error: {
+            tag: 'invalid-auth',
+            message: 'Invalid login credentials.',
+          },
+        })
+
+        return
+      }
+
+      const token: CrossServiceTokenData | undefined = this.tokenDecoder.decodeToken(
+        request.headers['x-auth-token'] as string,
+      )
+
+      if (token === undefined) {
+        this.logger.debug('ApiGatewayAuthMiddleware authentication failure.')
+
+        response.status(401).send({
+          error: {
+            tag: 'invalid-auth',
+            message: 'Invalid login credentials.',
+          },
+        })
+
+        return
+      }
+
+      response.locals.user = token.user
+      response.locals.roles = token.roles
+      response.locals.session = token.session
+      response.locals.readOnlyAccess = token.session?.readonly_access ?? false
+
+      return next()
+    } catch (error) {
+      return next(error)
+    }
+  }
+}

+ 28 - 0
packages/websockets/src/Controller/WebSocketsController.spec.ts

@@ -0,0 +1,28 @@
+import 'reflect-metadata'
+
+import { WebSocketsController } from './WebSocketsController'
+import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
+
+describe('WebSocketsController', () => {
+  let createWebSocketConnectionToken: CreateWebSocketConnectionToken
+
+  const createController = () => new WebSocketsController(createWebSocketConnectionToken)
+
+  beforeEach(() => {
+    createWebSocketConnectionToken = {} as jest.Mocked<CreateWebSocketConnectionToken>
+    createWebSocketConnectionToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
+  })
+
+  it('should create a web sockets connection token', async () => {
+    const response = await createController().createConnectionToken({ userUuid: '1-2-3' })
+
+    expect(response).toEqual({
+      status: 200,
+      data: { token: 'foobar' },
+    })
+
+    expect(createWebSocketConnectionToken.execute).toHaveBeenCalledWith({
+      userUuid: '1-2-3',
+    })
+  })
+})

+ 29 - 0
packages/websockets/src/Controller/WebSocketsController.ts

@@ -0,0 +1,29 @@
+import {
+  HttpStatusCode,
+  WebSocketConnectionTokenRequestParams,
+  WebSocketConnectionTokenResponse,
+  WebSocketServerInterface,
+} from '@standardnotes/api'
+import { inject, injectable } from 'inversify'
+
+import TYPES from '../Bootstrap/Types'
+import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
+
+@injectable()
+export class WebSocketsController implements WebSocketServerInterface {
+  constructor(
+    @inject(TYPES.CreateWebSocketConnectionToken)
+    private createWebSocketConnectionToken: CreateWebSocketConnectionToken,
+  ) {}
+
+  async createConnectionToken(
+    params: WebSocketConnectionTokenRequestParams,
+  ): Promise<WebSocketConnectionTokenResponse> {
+    const result = await this.createWebSocketConnectionToken.execute({ userUuid: params.userUuid as string })
+
+    return {
+      status: HttpStatusCode.Success,
+      data: result,
+    }
+  }
+}

+ 14 - 0
packages/websockets/src/Domain/Handler/WebSocketMessageRequestedEventHandler.ts

@@ -0,0 +1,14 @@
+import { DomainEventHandlerInterface, WebSocketMessageRequestedEvent } from '@standardnotes/domain-events'
+import { inject, injectable } from 'inversify'
+
+import TYPES from '../../Bootstrap/Types'
+import { ClientMessengerInterface } from '../../Client/ClientMessengerInterface'
+
+@injectable()
+export class WebSocketMessageRequestedEventHandler implements DomainEventHandlerInterface {
+  constructor(@inject(TYPES.WebSocketsClientMessenger) private webSocketsClientMessenger: ClientMessengerInterface) {}
+
+  async handle(event: WebSocketMessageRequestedEvent): Promise<void> {
+    await this.webSocketsClientMessenger.send(event.payload.userUuid, event.payload.message)
+  }
+}

+ 26 - 0
packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.spec.ts

@@ -0,0 +1,26 @@
+import 'reflect-metadata'
+import { Logger } from 'winston'
+import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface'
+
+import { AddWebSocketsConnection } from './AddWebSocketsConnection'
+
+describe('AddWebSocketsConnection', () => {
+  let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface
+  let logger: Logger
+
+  const createUseCase = () => new AddWebSocketsConnection(webSocketsConnectionRepository, logger)
+
+  beforeEach(() => {
+    webSocketsConnectionRepository = {} as jest.Mocked<WebSocketsConnectionRepositoryInterface>
+    webSocketsConnectionRepository.saveConnection = jest.fn()
+
+    logger = {} as jest.Mocked<Logger>
+    logger.debug = jest.fn()
+  })
+
+  it('should save a web sockets connection for a user for further communication', async () => {
+    await createUseCase().execute({ userUuid: '1-2-3', connectionId: '2-3-4' })
+
+    expect(webSocketsConnectionRepository.saveConnection).toHaveBeenCalledWith('1-2-3', '2-3-4')
+  })
+})

+ 26 - 0
packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.ts

@@ -0,0 +1,26 @@
+import { inject, injectable } from 'inversify'
+import { Logger } from 'winston'
+import TYPES from '../../../Bootstrap/Types'
+import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface'
+import { UseCaseInterface } from '../UseCaseInterface'
+import { AddWebSocketsConnectionDTO } from './AddWebSocketsConnectionDTO'
+import { AddWebSocketsConnectionResponse } from './AddWebSocketsConnectionResponse'
+
+@injectable()
+export class AddWebSocketsConnection implements UseCaseInterface {
+  constructor(
+    @inject(TYPES.WebSocketsConnectionRepository)
+    private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface,
+    @inject(TYPES.Logger) private logger: Logger,
+  ) {}
+
+  async execute(dto: AddWebSocketsConnectionDTO): Promise<AddWebSocketsConnectionResponse> {
+    this.logger.debug(`Persisting connection ${dto.connectionId} for user ${dto.userUuid}`)
+
+    await this.webSocketsConnectionRepository.saveConnection(dto.userUuid, dto.connectionId)
+
+    return {
+      success: true,
+    }
+  }
+}

+ 4 - 0
packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionDTO.ts

@@ -0,0 +1,4 @@
+export type AddWebSocketsConnectionDTO = {
+  userUuid: string
+  connectionId: string
+}

+ 3 - 0
packages/websockets/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionResponse.ts

@@ -0,0 +1,3 @@
+export type AddWebSocketsConnectionResponse = {
+  success: boolean
+}

+ 25 - 0
packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnection.spec.ts

@@ -0,0 +1,25 @@
+import 'reflect-metadata'
+
+import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
+
+import { CreateWebSocketConnectionToken } from './CreateWebSocketConnectionToken'
+
+describe('CreateWebSocketConnection', () => {
+  let tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>
+  const tokenTTL = 30
+
+  const createUseCase = () => new CreateWebSocketConnectionToken(tokenEncoder, tokenTTL)
+
+  beforeEach(() => {
+    tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<WebSocketConnectionTokenData>>
+    tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
+  })
+
+  it('should create a web socket connection token', async () => {
+    const result = await createUseCase().execute({ userUuid: '1-2-3' })
+
+    expect(result.token).toEqual('foobar')
+
+    expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith({ userUuid: '1-2-3' }, 30)
+  })
+})

+ 3 - 0
packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionDTO.ts

@@ -0,0 +1,3 @@
+export type CreateWebSocketConnectionDTO = {
+  userUuid: string
+}

+ 3 - 0
packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionResponse.ts

@@ -0,0 +1,3 @@
+export type CreateWebSocketConnectionResponse = {
+  token: string
+}

+ 26 - 0
packages/websockets/src/Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken.ts

@@ -0,0 +1,26 @@
+import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
+import { inject, injectable } from 'inversify'
+
+import TYPES from '../../../Bootstrap/Types'
+import { UseCaseInterface } from '../UseCaseInterface'
+import { CreateWebSocketConnectionDTO } from './CreateWebSocketConnectionDTO'
+import { CreateWebSocketConnectionResponse } from './CreateWebSocketConnectionResponse'
+
+@injectable()
+export class CreateWebSocketConnectionToken implements UseCaseInterface {
+  constructor(
+    @inject(TYPES.WebSocketConnectionTokenEncoder)
+    private tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>,
+    @inject(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL) private tokenTTL: number,
+  ) {}
+
+  async execute(dto: CreateWebSocketConnectionDTO): Promise<CreateWebSocketConnectionResponse> {
+    const data: WebSocketConnectionTokenData = {
+      userUuid: dto.userUuid,
+    }
+
+    return {
+      token: this.tokenEncoder.encodeExpirableToken(data, this.tokenTTL),
+    }
+  }
+}

+ 26 - 0
packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.spec.ts

@@ -0,0 +1,26 @@
+import 'reflect-metadata'
+import { Logger } from 'winston'
+import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface'
+
+import { RemoveWebSocketsConnection } from './RemoveWebSocketsConnection'
+
+describe('RemoveWebSocketsConnection', () => {
+  let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface
+  let logger: Logger
+
+  const createUseCase = () => new RemoveWebSocketsConnection(webSocketsConnectionRepository, logger)
+
+  beforeEach(() => {
+    webSocketsConnectionRepository = {} as jest.Mocked<WebSocketsConnectionRepositoryInterface>
+    webSocketsConnectionRepository.removeConnection = jest.fn()
+
+    logger = {} as jest.Mocked<Logger>
+    logger.debug = jest.fn()
+  })
+
+  it('should remove a web sockets connection', async () => {
+    await createUseCase().execute({ connectionId: '2-3-4' })
+
+    expect(webSocketsConnectionRepository.removeConnection).toHaveBeenCalledWith('2-3-4')
+  })
+})

+ 26 - 0
packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.ts

@@ -0,0 +1,26 @@
+import { inject, injectable } from 'inversify'
+import { Logger } from 'winston'
+import TYPES from '../../../Bootstrap/Types'
+import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface'
+import { UseCaseInterface } from '../UseCaseInterface'
+import { RemoveWebSocketsConnectionDTO } from './RemoveWebSocketsConnectionDTO'
+import { RemoveWebSocketsConnectionResponse } from './RemoveWebSocketsConnectionResponse'
+
+@injectable()
+export class RemoveWebSocketsConnection implements UseCaseInterface {
+  constructor(
+    @inject(TYPES.WebSocketsConnectionRepository)
+    private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface,
+    @inject(TYPES.Logger) private logger: Logger,
+  ) {}
+
+  async execute(dto: RemoveWebSocketsConnectionDTO): Promise<RemoveWebSocketsConnectionResponse> {
+    this.logger.debug(`Removing connection ${dto.connectionId}`)
+
+    await this.webSocketsConnectionRepository.removeConnection(dto.connectionId)
+
+    return {
+      success: true,
+    }
+  }
+}

+ 3 - 0
packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionDTO.ts

@@ -0,0 +1,3 @@
+export type RemoveWebSocketsConnectionDTO = {
+  connectionId: string
+}

+ 3 - 0
packages/websockets/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionResponse.ts

@@ -0,0 +1,3 @@
+export type RemoveWebSocketsConnectionResponse = {
+  success: boolean
+}

+ 3 - 0
packages/websockets/src/Domain/UseCase/UseCaseInterface.ts

@@ -0,0 +1,3 @@
+export interface UseCaseInterface {
+  execute(...args: any[]): Promise<Record<string, unknown>>
+}

+ 5 - 0
packages/websockets/src/Domain/WebSockets/WebSocketsConnectionRepositoryInterface.ts

@@ -0,0 +1,5 @@
+export interface WebSocketsConnectionRepositoryInterface {
+  findAllByUserUuid(userUuid: string): Promise<string[]>
+  saveConnection(userUuid: string, connectionId: string): Promise<void>
+  removeConnection(connectionId: string): Promise<void>
+}

+ 9 - 0
packages/websockets/src/Infra/InversifyExpressUtils/InversifyExpressHealthCheckController.ts

@@ -0,0 +1,9 @@
+import { controller, httpGet } from 'inversify-express-utils'
+
+@controller('/healthcheck')
+export class InversifyExpressHealthCheckController {
+  @httpGet('/')
+  public async get(): Promise<string> {
+    return 'OK'
+  }
+}

+ 56 - 0
packages/websockets/src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController.ts

@@ -0,0 +1,56 @@
+import { WebSocketServerInterface } from '@standardnotes/api'
+import { Request, Response } from 'express'
+import { inject } from 'inversify'
+import {
+  BaseHttpController,
+  controller,
+  httpDelete,
+  httpPost,
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  results,
+} from 'inversify-express-utils'
+import TYPES from '../../Bootstrap/Types'
+import { AddWebSocketsConnection } from '../../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
+import { RemoveWebSocketsConnection } from '../../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
+
+@controller('/sockets')
+export class InversifyExpressWebSocketsController extends BaseHttpController {
+  constructor(
+    @inject(TYPES.AddWebSocketsConnection) private addWebSocketsConnection: AddWebSocketsConnection,
+    @inject(TYPES.RemoveWebSocketsConnection) private removeWebSocketsConnection: RemoveWebSocketsConnection,
+    @inject(TYPES.WebSocketsController) private webSocketsController: WebSocketServerInterface,
+  ) {
+    super()
+  }
+
+  @httpPost('/tokens', TYPES.ApiGatewayAuthMiddleware)
+  async createConnectionToken(_request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.webSocketsController.createConnectionToken({
+      userUuid: response.locals.user.uuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
+  @httpPost('/connections/:connectionId', TYPES.ApiGatewayAuthMiddleware)
+  async storeWebSocketsConnection(
+    request: Request,
+    response: Response,
+  ): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
+    await this.addWebSocketsConnection.execute({
+      userUuid: response.locals.user.uuid,
+      connectionId: request.params.connectionId,
+    })
+
+    return this.json({ success: true })
+  }
+
+  @httpDelete('/connections/:connectionId')
+  async deleteWebSocketsConnection(
+    request: Request,
+  ): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
+    await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
+
+    return this.json({ success: true })
+  }
+}

+ 44 - 0
packages/websockets/src/Infra/Redis/RedisWebSocketsConnectionRepository.spec.ts

@@ -0,0 +1,44 @@
+import 'reflect-metadata'
+
+import * as IORedis from 'ioredis'
+
+import { RedisWebSocketsConnectionRepository } from './RedisWebSocketsConnectionRepository'
+
+describe('RedisWebSocketsConnectionRepository', () => {
+  let redisClient: IORedis.Redis
+
+  const createRepository = () => new RedisWebSocketsConnectionRepository(redisClient)
+
+  beforeEach(() => {
+    redisClient = {} as jest.Mocked<IORedis.Redis>
+    redisClient.sadd = jest.fn()
+    redisClient.set = jest.fn()
+    redisClient.get = jest.fn()
+    redisClient.srem = jest.fn()
+    redisClient.del = jest.fn()
+    redisClient.smembers = jest.fn()
+  })
+
+  it('should save a connection to set of user connections', async () => {
+    await createRepository().saveConnection('1-2-3', '2-3-4')
+
+    expect(redisClient.sadd).toHaveBeenCalledWith('ws_user_connections:1-2-3', '2-3-4')
+    expect(redisClient.set).toHaveBeenCalledWith('ws_connection:2-3-4', '1-2-3')
+  })
+
+  it('should remove a connection from the set of user connections', async () => {
+    redisClient.get = jest.fn().mockReturnValue('1-2-3')
+
+    await createRepository().removeConnection('2-3-4')
+
+    expect(redisClient.srem).toHaveBeenCalledWith('ws_user_connections:1-2-3', '2-3-4')
+    expect(redisClient.del).toHaveBeenCalledWith('ws_connection:2-3-4')
+  })
+
+  it('should return all connections for a user uuid', async () => {
+    const userUuid = '1-2-3'
+
+    await createRepository().findAllByUserUuid(userUuid)
+    expect(redisClient.smembers).toHaveBeenCalledWith(`ws_user_connections:${userUuid}`)
+  })
+})

+ 28 - 0
packages/websockets/src/Infra/Redis/RedisWebSocketsConnectionRepository.ts

@@ -0,0 +1,28 @@
+import * as IORedis from 'ioredis'
+import { inject, injectable } from 'inversify'
+import TYPES from '../../Bootstrap/Types'
+import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface'
+
+@injectable()
+export class RedisWebSocketsConnectionRepository implements WebSocketsConnectionRepositoryInterface {
+  private readonly WEB_SOCKETS_USER_CONNECTIONS_PREFIX = 'ws_user_connections'
+  private readonly WEB_SOCKETS_CONNETION_PREFIX = 'ws_connection'
+
+  constructor(@inject(TYPES.Redis) private redisClient: IORedis.Redis) {}
+
+  async findAllByUserUuid(userUuid: string): Promise<string[]> {
+    return await this.redisClient.smembers(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`)
+  }
+
+  async removeConnection(connectionId: string): Promise<void> {
+    const userUuid = await this.redisClient.get(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`)
+
+    await this.redisClient.srem(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`, connectionId)
+    await this.redisClient.del(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`)
+  }
+
+  async saveConnection(userUuid: string, connectionId: string): Promise<void> {
+    await this.redisClient.set(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`, userUuid)
+    await this.redisClient.sadd(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`, connectionId)
+  }
+}

+ 36 - 0
packages/websockets/src/Infra/WebSockets/WebSocketsClientMessenger.ts

@@ -0,0 +1,36 @@
+import { AxiosInstance } from 'axios'
+import { JSONString, Uuid } from '@standardnotes/common'
+import { inject, injectable } from 'inversify'
+
+import TYPES from '../../Bootstrap/Types'
+import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface'
+import { ClientMessengerInterface } from '../../Client/ClientMessengerInterface'
+
+@injectable()
+export class WebSocketsClientMessenger implements ClientMessengerInterface {
+  constructor(
+    @inject(TYPES.WebSocketsConnectionRepository)
+    private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface,
+    @inject(TYPES.HTTPClient) private httpClient: AxiosInstance,
+    @inject(TYPES.WEBSOCKETS_API_URL) private webSocketsApiUrl: string,
+  ) {}
+
+  async send(userUuid: Uuid, message: JSONString): Promise<void> {
+    const userConnections = await this.webSocketsConnectionRepository.findAllByUserUuid(userUuid)
+
+    for (const connectionUuid of userConnections) {
+      await this.httpClient.request({
+        method: 'POST',
+        url: `${this.webSocketsApiUrl}/${connectionUuid}`,
+        headers: {
+          Accept: 'text/plain',
+          'Content-Type': 'text/plain',
+        },
+        data: message,
+        validateStatus:
+          /* istanbul ignore next */
+          (status: number) => status >= 200 && status < 500,
+      })
+    }
+  }
+}

+ 43 - 0
packages/websockets/src/Infra/WebSockets/WebSocketsClientService.spec.ts

@@ -0,0 +1,43 @@
+import 'reflect-metadata'
+
+import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface'
+import { AxiosInstance } from 'axios'
+
+import { WebSocketsClientMessenger } from './WebSocketsClientMessenger'
+
+describe('WebSocketsClientMessenger', () => {
+  let connectionIds: string[]
+  let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface
+  let httpClient: AxiosInstance
+
+  const webSocketsApiUrl = 'http://test-websockets'
+
+  const createService = () =>
+    new WebSocketsClientMessenger(webSocketsConnectionRepository, httpClient, webSocketsApiUrl)
+
+  beforeEach(() => {
+    connectionIds = ['1', '2']
+
+    webSocketsConnectionRepository = {} as jest.Mocked<WebSocketsConnectionRepositoryInterface>
+    webSocketsConnectionRepository.findAllByUserUuid = jest.fn().mockReturnValue(connectionIds)
+
+    httpClient = {} as jest.Mocked<AxiosInstance>
+    httpClient.request = jest.fn()
+  })
+
+  it('should send a message to all user connections', async () => {
+    await createService().send('1-2-3', 'message')
+
+    expect(httpClient.request).toHaveBeenCalledTimes(connectionIds.length)
+    connectionIds.map((id, index) => {
+      expect(httpClient.request).toHaveBeenNthCalledWith(
+        index + 1,
+        expect.objectContaining({
+          method: 'POST',
+          url: `${webSocketsApiUrl}/${id}`,
+          data: 'message',
+        }),
+      )
+    })
+  })
+})

+ 0 - 0
packages/websockets/test-setup.ts


+ 13 - 0
packages/websockets/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "outDir": "./dist",
+  },
+  "include": [
+    "src/**/*",
+    "bin/**/*",
+    "migrations/**/*",
+  ],
+  "references": []
+}

+ 17 - 0
packages/websockets/wait-for.sh

@@ -0,0 +1,17 @@
+#!/bin/sh
+
+set -e
+
+host="$1"
+shift
+port="$1"
+shift
+cmd="$@"
+
+while ! nc -vz $host $port; do
+  >&2 echo "$host:$port is unavailable yet - waiting for it to start"
+  sleep 10
+done
+
+>&2 echo "$host:$port is up - executing command"
+exec $cmd

+ 3 - 0
tsconfig.json

@@ -64,6 +64,9 @@
     {
       "path": "./packages/time"
     },
+    {
+      "path": "./packages/websockets"
+    },
     {
       "path": "./packages/workspace"
     }

+ 67 - 1
yarn.lock

@@ -1839,6 +1839,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@standardnotes/api@npm:^1.16.1":
+  version: 1.16.1
+  resolution: "@standardnotes/api@npm:1.16.1"
+  dependencies:
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/encryption": 1.18.0
+    "@standardnotes/models": 1.27.0
+    "@standardnotes/responses": 1.11.0
+    "@standardnotes/security": ^1.1.0
+    "@standardnotes/utils": 1.10.0
+    reflect-metadata: ^0.1.13
+  checksum: 1f939c1825e716e8c7acc5b24063f6e8cefa1f40f56830affef968fafd5ae9dfe296462ed6b8b6d9b1436a681e1467b8e433f244d3976cf823016f172ca051b6
+  languageName: node
+  linkType: hard
+
 "@standardnotes/auth-server@workspace:packages/auth":
   version: 0.0.0-use.local
   resolution: "@standardnotes/auth-server@workspace:packages/auth"
@@ -1986,6 +2001,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@standardnotes/encryption@npm:1.18.0":
+  version: 1.18.0
+  resolution: "@standardnotes/encryption@npm:1.18.0"
+  dependencies:
+    "@standardnotes/common": ^1.39.0
+    "@standardnotes/models": 1.27.0
+    "@standardnotes/responses": 1.11.0
+    "@standardnotes/sncrypto-common": 1.13.0
+    "@standardnotes/utils": 1.10.0
+    reflect-metadata: ^0.1.13
+  checksum: f9c39d09861bdd40c50c1ae8bf875176bea3c9caba6b5aa83797f48a555e1d4418d39b14c8a261b2ac45bbfbbd4f84d787ff261baeceabf84cdf448ee9542d1f
+  languageName: node
+  linkType: hard
+
 "@standardnotes/event-store@workspace:packages/event-store":
   version: 0.0.0-use.local
   resolution: "@standardnotes/event-store@workspace:packages/event-store"
@@ -2209,7 +2238,7 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@standardnotes/security@^1.1.0, @standardnotes/security@^1.2.0, @standardnotes/security@workspace:*, @standardnotes/security@workspace:packages/security":
+"@standardnotes/security@^1.1.0, @standardnotes/security@^1.2.0, @standardnotes/security@workspace:*, @standardnotes/security@workspace:^, @standardnotes/security@workspace:packages/security":
   version: 0.0.0-use.local
   resolution: "@standardnotes/security@workspace:packages/security"
   dependencies:
@@ -2386,6 +2415,43 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@standardnotes/websockets-server@workspace:packages/websockets":
+  version: 0.0.0-use.local
+  resolution: "@standardnotes/websockets-server@workspace:packages/websockets"
+  dependencies:
+    "@newrelic/winston-enricher": ^4.0.0
+    "@sentry/node": ^7.3.0
+    "@standardnotes/api": ^1.16.1
+    "@standardnotes/common": "workspace:^"
+    "@standardnotes/domain-events": "workspace:^"
+    "@standardnotes/domain-events-infra": "workspace:^"
+    "@standardnotes/security": "workspace:^"
+    "@types/cors": ^2.8.9
+    "@types/express": ^4.17.11
+    "@types/ioredis": ^4.28.10
+    "@types/jest": ^29.1.1
+    "@types/newrelic": ^7.0.3
+    "@typescript-eslint/eslint-plugin": ^5.29.0
+    aws-sdk: ^2.1159.0
+    axios: ^0.27.2
+    cors: 2.8.5
+    dotenv: ^16.0.1
+    eslint: ^8.14.0
+    eslint-plugin-prettier: ^4.0.0
+    express: ^4.18.1
+    inversify: ^6.0.1
+    inversify-express-utils: ^6.4.3
+    ioredis: ^5.2.0
+    jest: ^29.1.2
+    mysql2: ^2.3.3
+    newrelic: ^9.0.0
+    reflect-metadata: 0.1.13
+    ts-jest: ^29.0.3
+    typeorm: ^0.3.6
+    winston: ^3.8.1
+  languageName: unknown
+  linkType: soft
+
 "@standardnotes/workspace-server@workspace:packages/workspace":
   version: 0.0.0-use.local
   resolution: "@standardnotes/workspace-server@workspace:packages/workspace"