mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
feat(websockets): add websockets service
This commit is contained in:
182
.github/workflows/websockets.release.yml
vendored
Normal file
182
.github/workflows/websockets.release.yml
vendored
Normal file
@@ -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
.pnp.cjs
generated
71
.pnp.cjs
generated
@@ -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
vendored
Normal file
BIN
.yarn/cache/@standardnotes-api-npm-1.16.1-7af309f020-1f939c1825.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@standardnotes-encryption-npm-1.18.0-d83f58c21a-f9c39d0986.zip
vendored
Normal file
BIN
.yarn/cache/@standardnotes-encryption-npm-1.18.0-d83f58c21a-f9c39d0986.zip
vendored
Normal file
Binary file not shown.
16
package.json
16
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
packages/common/src/Domain/DataType/JSONString.ts
Normal file
1
packages/common/src/Domain/DataType/JSONString.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type JSONString = string
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DomainEventInterface } from './DomainEventInterface'
|
||||
import { WebSocketMessageRequestedEventPayload } from './WebSocketMessageRequestedEventPayload'
|
||||
|
||||
export interface WebSocketMessageRequestedEvent extends DomainEventInterface {
|
||||
type: 'WEB_SOCKET_MESSAGE_REQUESTED'
|
||||
payload: WebSocketMessageRequestedEventPayload
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { JSONString, Uuid } from '@standardnotes/common'
|
||||
|
||||
export interface WebSocketMessageRequestedEventPayload {
|
||||
userUuid: Uuid
|
||||
message: JSONString
|
||||
}
|
||||
@@ -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
packages/websockets/.env.sample
Normal file
28
packages/websockets/.env.sample
Normal file
@@ -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
packages/websockets/.eslintignore
Normal file
3
packages/websockets/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
test-setup.ts
|
||||
data
|
||||
6
packages/websockets/.eslintrc
Normal file
6
packages/websockets/.eslintrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "./linter.tsconfig.json"
|
||||
}
|
||||
}
|
||||
27
packages/websockets/Dockerfile
Normal file
27
packages/websockets/Dockerfile
Normal file
@@ -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
packages/websockets/bin/server.ts
Normal file
70
packages/websockets/bin/server.ts
Normal file
@@ -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
packages/websockets/bin/worker.ts
Normal file
25
packages/websockets/bin/worker.ts
Normal file
@@ -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
packages/websockets/docker/entrypoint.sh
Executable file
22
packages/websockets/docker/entrypoint.sh
Executable file
@@ -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
packages/websockets/jest.config.js
Normal file
12
packages/websockets/jest.config.js
Normal file
@@ -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
packages/websockets/linter.tsconfig.json
Normal file
4
packages/websockets/linter.tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["dist", "test-setup.ts"]
|
||||
}
|
||||
59
packages/websockets/package.json
Normal file
59
packages/websockets/package.json
Normal file
@@ -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
packages/websockets/src/Bootstrap/Container.ts
Normal file
186
packages/websockets/src/Bootstrap/Container.ts
Normal file
@@ -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
packages/websockets/src/Bootstrap/Env.ts
Normal file
24
packages/websockets/src/Bootstrap/Env.ts
Normal file
@@ -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
packages/websockets/src/Bootstrap/Types.ts
Normal file
40
packages/websockets/src/Bootstrap/Types.ts
Normal file
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JSONString, Uuid } from '@standardnotes/common'
|
||||
|
||||
export interface ClientMessengerInterface {
|
||||
send(userUuid: Uuid, message: JSONString): Promise<void>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
packages/websockets/src/Controller/WebSocketsController.ts
Normal file
29
packages/websockets/src/Controller/WebSocketsController.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type AddWebSocketsConnectionDTO = {
|
||||
userUuid: string
|
||||
connectionId: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type AddWebSocketsConnectionResponse = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export type CreateWebSocketConnectionDTO = {
|
||||
userUuid: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type CreateWebSocketConnectionResponse = {
|
||||
token: string
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type RemoveWebSocketsConnectionDTO = {
|
||||
connectionId: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type RemoveWebSocketsConnectionResponse = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UseCaseInterface {
|
||||
execute(...args: any[]): Promise<Record<string, unknown>>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface WebSocketsConnectionRepositoryInterface {
|
||||
findAllByUserUuid(userUuid: string): Promise<string[]>
|
||||
saveConnection(userUuid: string, connectionId: string): Promise<void>
|
||||
removeConnection(connectionId: string): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { controller, httpGet } from 'inversify-express-utils'
|
||||
|
||||
@controller('/healthcheck')
|
||||
export class InversifyExpressHealthCheckController {
|
||||
@httpGet('/')
|
||||
public async get(): Promise<string> {
|
||||
return 'OK'
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
packages/websockets/test-setup.ts
Normal file
0
packages/websockets/test-setup.ts
Normal file
13
packages/websockets/tsconfig.json
Normal file
13
packages/websockets/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"bin/**/*",
|
||||
"migrations/**/*",
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
17
packages/websockets/wait-for.sh
Executable file
17
packages/websockets/wait-for.sh
Executable file
@@ -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
|
||||
@@ -64,6 +64,9 @@
|
||||
{
|
||||
"path": "./packages/time"
|
||||
},
|
||||
{
|
||||
"path": "./packages/websockets"
|
||||
},
|
||||
{
|
||||
"path": "./packages/workspace"
|
||||
}
|
||||
|
||||
68
yarn.lock
68
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"
|
||||
|
||||
Reference in New Issue
Block a user