diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index 52788ee5b29..b73b28b9e4a 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -156,7 +156,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushDev.yml b/.github/workflows/pushDev.yml index 075e02461a9..62b6bd4c49b 100644 --- a/.github/workflows/pushDev.yml +++ b/.github/workflows/pushDev.yml @@ -145,7 +145,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushNext.yml b/.github/workflows/pushNext.yml index 02413243927..e924e57c179 100644 --- a/.github/workflows/pushNext.yml +++ b/.github/workflows/pushNext.yml @@ -145,7 +145,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index 928de2b1aae..5d829153eb7 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -37,6 +37,7 @@ "@webiny/api-tenancy-so-ddb": "0.0.0", "@webiny/api-tenant-manager": "0.0.0", "@webiny/api-wcp": "0.0.0", + "@webiny/api-websockets": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/cli": "0.0.0", "@webiny/db-dynamodb": "0.0.0", diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index cd267e14998..fbdbc286faa 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -40,6 +40,7 @@ import scaffoldsPlugins from "./plugins/scaffolds"; import { createBenchmarkEnablePlugin } from "~/plugins/benchmarkEnable"; import { createCountDynamoDbTask } from "~/plugins/countDynamoDbTask"; import { createContinuingTask } from "~/plugins/continuingTask"; +import { createWebsockets } from "@webiny/api-websockets"; const debug = process.env.DEBUG === "true"; const documentClient = getDocumentClient(); @@ -60,6 +61,7 @@ export const handler = createHandler({ tenantManager(), i18nPlugins(), i18nDynamoDbStorageOperations(), + createWebsockets(), createHeadlessCmsContext({ storageOperations: createHeadlessCmsStorageOperations({ documentClient diff --git a/apps/api/graphql/tsconfig.json b/apps/api/graphql/tsconfig.json index 24dd0dfe1b5..4a9ba5ff956 100644 --- a/apps/api/graphql/tsconfig.json +++ b/apps/api/graphql/tsconfig.json @@ -198,7 +198,9 @@ "@webiny/api-background-tasks-ddb/*": ["../../../packages/api-background-tasks-ddb/src/*"], "@webiny/api-background-tasks-ddb": ["../../../packages/api-background-tasks-ddb/src"], "@webiny/tasks/*": ["../../../packages/tasks/src/*"], - "@webiny/tasks": ["../../../packages/tasks/src"] + "@webiny/tasks": ["../../../packages/tasks/src"], + "@webiny/api-websockets/*": ["../../../packages/api-websockets/src/*"], + "@webiny/api-websockets": ["../../../packages/api-websockets/src"] }, "baseUrl": "." } diff --git a/jest.config.base.js b/jest.config.base.js index 5f7980ccac4..6057ad86fe2 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -107,7 +107,9 @@ const createDynaliteTables = (options = {}) => { { AttributeName: "PK", AttributeType: "S" }, { AttributeName: "SK", AttributeType: "S" }, { AttributeName: "GSI1_PK", AttributeType: "S" }, - { AttributeName: "GSI1_SK", AttributeType: "S" } + { AttributeName: "GSI1_SK", AttributeType: "S" }, + { AttributeName: "GSI2_PK", AttributeType: "S" }, + { AttributeName: "GSI2_SK", AttributeType: "S" } ], ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, GlobalSecondaryIndexes: [ @@ -124,6 +126,20 @@ const createDynaliteTables = (options = {}) => { ReadCapacityUnits: 1, WriteCapacityUnits: 1 } + }, + { + IndexName: "GSI2", + KeySchema: [ + { AttributeName: "GSI2_PK", KeyType: "HASH" }, + { AttributeName: "GSI2_SK", KeyType: "RANGE" } + ], + Projection: { + ProjectionType: "ALL" + }, + ProvisionedThroughput: { + ReadCapacityUnits: 1, + WriteCapacityUnits: 1 + } } ], data: options.data || [] diff --git a/packages/api-aco/package.json b/packages/api-aco/package.json index af9c78cc21b..ac6b4e9b4b3 100644 --- a/packages/api-aco/package.json +++ b/packages/api-aco/package.json @@ -42,7 +42,6 @@ "@babel/preset-env": "^7.23.9", "@babel/preset-typescript": "^7.23.3", "@babel/runtime": "^7.23.9", - "@types/ungap__structured-clone": "^0.3.0", "@webiny/api-admin-users": "0.0.0", "@webiny/api-file-manager": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", diff --git a/packages/api-websockets/.babelrc.js b/packages/api-websockets/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-websockets/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-websockets/LICENSE b/packages/api-websockets/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-websockets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-websockets/README.md b/packages/api-websockets/README.md new file mode 100644 index 00000000000..3356b5a5a6a --- /dev/null +++ b/packages/api-websockets/README.md @@ -0,0 +1,10 @@ +# @webiny/api-websockets +[![](https://img.shields.io/npm/dw/@webiny/api-websockets.svg)](https://www.npmjs.com/package/@webiny/api-websockets) +[![](https://img.shields.io/npm/v/@webiny/api-websockets.svg)](https://www.npmjs.com/package/@webiny/api-websockets) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +## Install +``` +yarn add @webiny/api-websockets +``` diff --git a/packages/api-websockets/__tests__/context/websocketsContext.test.ts b/packages/api-websockets/__tests__/context/websocketsContext.test.ts new file mode 100644 index 00000000000..f75a67abc0e --- /dev/null +++ b/packages/api-websockets/__tests__/context/websocketsContext.test.ts @@ -0,0 +1,103 @@ +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import { WebsocketsContext } from "~/context/WebsocketsContext"; +import { WebsocketsConnectionRegistry } from "~/registry"; +import { MockWebsocketsTransport } from "~tests/mocks/MockWebsocketsTransport"; + +interface IMockData { + mockData?: boolean; +} + +describe("websockets context", () => { + it("should properly list connections", async () => { + const documentClient = getDocumentClient(); + const registry = new WebsocketsConnectionRegistry(documentClient); + const transport = new MockWebsocketsTransport(); + + const context = new WebsocketsContext(registry, transport); + expect(context).toBeInstanceOf(WebsocketsContext); + + const resultNoConnections = await context.listConnections({ + where: { + identityId: "id-1" + } + }); + expect(resultNoConnections).toEqual([]); + + await registry.register({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }); + + const resultWithConnections = await context.listConnections({ + where: { + identityId: "id-1" + } + }); + expect(resultWithConnections).toEqual([ + { + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: expect.any(String) + } + ]); + }); + + it("should properly send a message via transport", async () => { + const documentClient = getDocumentClient(); + const registry = new WebsocketsConnectionRegistry(documentClient); + const transport = new MockWebsocketsTransport(); + + const context = new WebsocketsContext(registry, transport); + + await registry.register({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }); + + await context.send( + { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + { + data: { + mockData: true + } + } + ); + + expect(transport.messages.size).toBe(1); + expect(transport.messages.get("connection-1")).toEqual({ + data: { + mockData: true + } + }); + }); +}); diff --git a/packages/api-websockets/__tests__/graphql/crud.graphql.test.ts b/packages/api-websockets/__tests__/graphql/crud.graphql.test.ts new file mode 100644 index 00000000000..07a8147782b --- /dev/null +++ b/packages/api-websockets/__tests__/graphql/crud.graphql.test.ts @@ -0,0 +1,356 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import { IWebsocketsConnectionRegistryData, WebsocketsConnectionRegistry } from "~/registry"; +import { IWebsocketsIdentity } from "~/context"; + +jest.mock("@webiny/aws-sdk/client-apigatewaymanagementapi", () => { + return { + ApiGatewayManagementApiClient: class ApiGatewayManagementApiClient { + async send(cmd: any) { + return cmd; + } + }, + PostToConnectionCommand: class PostToConnectionCommand { + public readonly input: any; + + constructor(input: any) { + this.input = input; + } + } + }; +}); + +interface InsertConnectionsParams { + suffix?: string; + tenant?: string; + locale?: string; + identity?: IWebsocketsIdentity; +} + +const insertConnections = async (amount: number, params?: InsertConnectionsParams) => { + const { suffix, tenant, locale, identity } = params || {}; + const documentClient = getDocumentClient(); + const registry = new WebsocketsConnectionRegistry(documentClient); + + const connections: IWebsocketsConnectionRegistryData[] = []; + for (let i = 0; i < amount; i++) { + const connection: IWebsocketsConnectionRegistryData = { + connectionId: `connection-${i}${suffix ? `-${suffix}` : ""}`, + tenant: tenant || "root", + locale: locale || "en-US", + identity: { + id: `id-${i}`, + type: "admin", + displayName: `Admin ${i}`, + ...identity + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }; + await registry.register(connection); + connections.push(connection); + } + return connections; +}; + +describe("crud graphql", () => { + it("should list all connections", async () => { + const { listConnections } = useGraphQLHandler(); + + const [resultBeforeInsertingConnections] = await listConnections(); + + expect(resultBeforeInsertingConnections.data.websockets.listConnections.data).toHaveLength( + 0 + ); + + const connections = await insertConnections(50); + + const [resultAfterInsertingConnections] = await listConnections(); + + expect(resultAfterInsertingConnections).toMatchObject({ + data: { + websockets: { + listConnections: { + data: expect.arrayContaining( + connections.map(c => expect.objectContaining(c)) + ), + error: null + } + } + } + }); + expect(resultAfterInsertingConnections.data.websockets.listConnections.data).toHaveLength( + 50 + ); + }); + + it("should list all connections for a specific identity", async () => { + const { listConnections } = useGraphQLHandler(); + + const [resultBeforeInsertingConnections] = await listConnections(); + + expect(resultBeforeInsertingConnections.data.websockets.listConnections.data).toHaveLength( + 0 + ); + /** + * Generate connections 5 * 5 + */ + for (let i = 0; i < 5; i++) { + await insertConnections(5, { + suffix: `iteration-${i}` + }); + } + + const [identity1Result] = await listConnections({ + where: { + identityId: "id-1" + } + }); + + expect(identity1Result.data.websockets.listConnections.data).toHaveLength(5); + + const [identity2Result] = await listConnections({ + where: { + identityId: "id-2" + } + }); + + expect(identity2Result.data.websockets.listConnections.data).toHaveLength(5); + + const [identity3Result] = await listConnections({ + where: { + identityId: "id-3" + } + }); + + expect(identity3Result.data.websockets.listConnections.data).toHaveLength(5); + + /** + * Generate some more connections + */ + await insertConnections(5, { + suffix: "iteration-6" + }); + + const [identity1Result2] = await listConnections({ + where: { + identityId: "id-1" + } + }); + + expect(identity1Result2.data.websockets.listConnections.data).toHaveLength(6); + + const [identity2Result2] = await listConnections({ + where: { + identityId: "id-2" + } + }); + + expect(identity2Result2.data.websockets.listConnections.data).toHaveLength(6); + + const [identity3Result2] = await listConnections({ + where: { + identityId: "id-3" + } + }); + + expect(identity3Result2.data.websockets.listConnections.data).toHaveLength(6); + }); + + it("should list all connections for a specific tenant", async () => { + const { listConnections } = useGraphQLHandler(); + await insertConnections(5); + await insertConnections(5, { + suffix: `dev`, + tenant: "dev" + }); + + const [resultAll] = await listConnections(); + expect(resultAll.data.websockets.listConnections.data).toHaveLength(10); + + const [resultRoot] = await listConnections({ + where: { + tenant: "root" + } + }); + expect(resultRoot.data.websockets.listConnections.data).toHaveLength(5); + + const [resultDev] = await listConnections({ + where: { + tenant: "dev" + } + }); + + expect(resultDev.data.websockets.listConnections.data).toHaveLength(5); + }); + + it("should list all connections for specific tenant/locale", async () => { + const { listConnections } = useGraphQLHandler(); + await insertConnections(5); + await insertConnections(5, { + suffix: `root-hr-HR`, + locale: "hr-HR" + }); + + const [resultAll] = await listConnections(); + expect(resultAll.data.websockets.listConnections.data).toHaveLength(10); + + const [resultRoot] = await listConnections({ + where: { + tenant: "root", + locale: "en-US" + } + }); + expect(resultRoot.data.websockets.listConnections.data).toHaveLength(5); + + const [resultDev] = await listConnections({ + where: { + tenant: "root", + locale: "hr-HR" + } + }); + + expect(resultDev.data.websockets.listConnections.data).toHaveLength(5); + }); + + it("should disconnect a specific identity connection", async () => { + const { listConnections, disconnectIdentity } = useGraphQLHandler(); + + await insertConnections(5); + + const [resultBeforeDisconnect] = await listConnections(); + expect(resultBeforeDisconnect.data.websockets.listConnections.data).toHaveLength(5); + + const [result] = await disconnectIdentity("id-1"); + expect(result).toEqual({ + data: { + websockets: { + disconnectIdentity: { + data: true, + error: null + } + } + } + }); + + const [resultAfterDisconnect] = await listConnections(); + expect(resultAfterDisconnect.data.websockets.listConnections.data).toHaveLength(4); + }); + + it("should disconnect a specific tenant connection", async () => { + const { listConnections, disconnectTenant } = useGraphQLHandler(); + + await insertConnections(5); + await insertConnections(5, { + suffix: "dev", + tenant: "dev" + }); + + const [resultBeforeDisconnect] = await listConnections(); + expect(resultBeforeDisconnect.data.websockets.listConnections.data).toHaveLength(10); + + const [result] = await disconnectTenant("root"); + expect(result).toEqual({ + data: { + websockets: { + disconnectTenant: { + data: true, + error: null + } + } + } + }); + + const [resultAfterDisconnect] = await listConnections(); + expect(resultAfterDisconnect.data.websockets.listConnections.data).toHaveLength(5); + }); + + it("should disconnect specific tenant/locale combination", async () => { + const { listConnections, disconnectTenant } = useGraphQLHandler(); + + await insertConnections(5); + await insertConnections(5, { + suffix: "dev-en", + tenant: "dev", + locale: "en-US" + }); + await insertConnections(5, { + suffix: "dev-hr", + tenant: "dev", + locale: "hr-HR" + }); + + const [resultBeforeDisconnect] = await listConnections(); + expect(resultBeforeDisconnect.data.websockets.listConnections.data).toHaveLength(15); + + const [result] = await disconnectTenant("dev", "en-US"); + expect(result).toEqual({ + data: { + websockets: { + disconnectTenant: { + data: true, + error: null + } + } + } + }); + + const [resultAfterDisconnect] = await listConnections(); + expect(resultAfterDisconnect.data.websockets.listConnections.data).toHaveLength(10); + + const [resultRoot] = await disconnectTenant("root", "en-US"); + expect(resultRoot).toEqual({ + data: { + websockets: { + disconnectTenant: { + data: true, + error: null + } + } + } + }); + + const [resultAfterRootDisconnect] = await listConnections(); + expect(resultAfterRootDisconnect.data.websockets.listConnections.data).toHaveLength(5); + }); + + it("should disconnect all connections", async () => { + const { listConnections, disconnectAll } = useGraphQLHandler(); + + await insertConnections(5); + await insertConnections(5, { + suffix: "dev", + tenant: "dev" + }); + await insertConnections(5, { + suffix: "webiny", + tenant: "webiny", + locale: "de-DE" + }); + + await insertConnections(5, { + suffix: "webiny-en", + tenant: "webiny", + locale: "en-US" + }); + + const [resultBeforeDisconnect] = await listConnections(); + expect(resultBeforeDisconnect.data.websockets.listConnections.data).toHaveLength(20); + + const [result] = await disconnectAll(); + expect(result).toEqual({ + data: { + websockets: { + disconnectAll: { + data: true, + error: null + } + } + } + }); + + const [resultAfterDisconnect] = await listConnections(); + expect(resultAfterDisconnect.data.websockets.listConnections.data).toHaveLength(0); + }); +}); diff --git a/packages/api-websockets/__tests__/graphql/schema.graphql.test.ts b/packages/api-websockets/__tests__/graphql/schema.graphql.test.ts new file mode 100644 index 00000000000..8a4b35613ab --- /dev/null +++ b/packages/api-websockets/__tests__/graphql/schema.graphql.test.ts @@ -0,0 +1,25 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("schema graphql", () => { + it("should have websockets schema", async () => { + const { introspect } = useGraphQLHandler(); + const [schema] = await introspect(); + + const types: string[] = [ + "WebsocketsIdentity", + "WebsocketsConnection", + "WebsocketsError", + "WebsocketsListConnectionsResponse", + "WebsocketsListConnectionsWhereInput", + "WebsocketsQuery", + "WebsocketsDisconnectResponse", + "WebsocketsMutation" + ]; + + for (const type of types) { + expect(schema.data.__schema.types.some((t: any) => t.name === type)).toBeTruthy(); + } + + expect.assertions(types.length); + }); +}); diff --git a/packages/api-websockets/__tests__/handler/handler.test.ts b/packages/api-websockets/__tests__/handler/handler.test.ts new file mode 100644 index 00000000000..c60b018e419 --- /dev/null +++ b/packages/api-websockets/__tests__/handler/handler.test.ts @@ -0,0 +1,250 @@ +import { createHandler } from "~/handler/handler"; +import { WebsocketsEventRoute } from "~/handler/types"; +import { createMockLambdaContext } from "~tests/mocks/lambdaContext"; +import { createPlugins } from "~tests/helpers/plugins"; +import { createMockEvent } from "~tests/mocks/event"; +import { useHandler } from "~tests/helpers/useHandler"; + +jest.mock("@webiny/aws-sdk/client-apigatewaymanagementapi", () => { + return { + ApiGatewayManagementApiClient: class ApiGatewayManagementApiClient { + async send(cmd: any) { + return cmd; + } + }, + PostToConnectionCommand: class PostToConnectionCommand { + public readonly input: any; + + constructor(input: any) { + this.input = input; + } + } + }; +}); + +describe("handler", () => { + it("should run handler with the given event - default route - ok status", async () => { + const handler = createHandler({ + plugins: createPlugins() + }); + + const result = await handler( + createMockEvent({ + requestContext: { + routeKey: WebsocketsEventRoute.default, + connectionId: "myConnectionId" + }, + body: JSON.stringify({ + messageId: "message123", + action: "mockAction", + token: "aToken", + locale: "en-US", + tenant: "root" + }) + }), + createMockLambdaContext() + ); + + expect(result).toMatchObject({ + statusCode: 200, + isBase64Encoded: false, + headers: { + "sec-websocket-protocol": "webiny-ws-v1" + }, + body: JSON.stringify({ + statusCode: 200 + }) + }); + }); + + it("should run handler with the given event - connect route - ok status", async () => { + const handler = createHandler({ + plugins: createPlugins() + }); + const contextHandler = useHandler(); + const context = await contextHandler.handle(); + + const connectionsBeforeConnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsBeforeConnect).toHaveLength(0); + + const result = await handler( + createMockEvent({ + requestContext: { + routeKey: WebsocketsEventRoute.connect, + connectionId: "myConnectionId" + }, + body: JSON.stringify({ + messageId: "message123", + action: "mockAction", + token: "aToken", + locale: "en-US", + tenant: "root" + }) + }), + createMockLambdaContext() + ); + + expect(result).toMatchObject({ + statusCode: 200, + isBase64Encoded: false, + headers: { + "sec-websocket-protocol": "webiny-ws-v1" + }, + body: JSON.stringify({ + statusCode: 200 + }) + }); + + const connectionsAfterConnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsAfterConnect).toHaveLength(1); + }); + + it("should run handler with the given event - disconnect route - ok status", async () => { + const handler = createHandler({ + plugins: createPlugins() + }); + const contextHandler = useHandler(); + const context = await contextHandler.handle(); + + const connectionsBeforeConnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsBeforeConnect).toHaveLength(0); + + const connectResult = await handler( + createMockEvent({ + requestContext: { + routeKey: WebsocketsEventRoute.connect, + connectionId: "myConnectionId" + }, + body: JSON.stringify({ + messageId: "message123", + action: "mockAction", + token: "aToken", + locale: "en-US", + tenant: "root" + }) + }), + createMockLambdaContext() + ); + + expect(connectResult).toMatchObject({ + statusCode: 200, + isBase64Encoded: false, + headers: { + "sec-websocket-protocol": "webiny-ws-v1" + }, + body: JSON.stringify({ + statusCode: 200 + }) + }); + + const connectionsAfterConnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsAfterConnect).toHaveLength(1); + + const disconnectResult = await handler( + createMockEvent({ + requestContext: { + routeKey: WebsocketsEventRoute.disconnect, + connectionId: "myConnectionId" + }, + body: JSON.stringify({ + messageId: "message123", + action: "mockAction", + token: "aToken", + locale: "en-US", + tenant: "root" + }) + }), + createMockLambdaContext() + ); + + expect(disconnectResult).toMatchObject({ + statusCode: 200, + isBase64Encoded: false, + headers: { + "sec-websocket-protocol": "webiny-ws-v1" + }, + body: JSON.stringify({ + statusCode: 200 + }) + }); + + const connectionsAfterDisconnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsAfterDisconnect).toHaveLength(0); + }); + + it("should run handler with the given event - disconnect route - error status due to no existing connection", async () => { + const handler = createHandler({ + plugins: createPlugins() + }); + const contextHandler = useHandler(); + const context = await contextHandler.handle(); + + const connectionsBeforeDisconnect = await context.websockets.listConnections({ + where: { + identityId: "id-12345678" + } + }); + expect(connectionsBeforeDisconnect).toHaveLength(0); + + const disconnectResult = await handler( + createMockEvent({ + requestContext: { + routeKey: WebsocketsEventRoute.disconnect, + connectionId: "myConnectionId" + }, + body: JSON.stringify({ + messageId: "message123", + action: "mockAction", + token: "aToken", + locale: "en-US", + tenant: "root" + }) + }), + createMockLambdaContext() + ); + + expect(disconnectResult).toMatchObject({ + statusCode: 200, + isBase64Encoded: false, + headers: { + "sec-websocket-protocol": "webiny-ws-v1" + }, + body: expect.any(String) + }); + const bodyResult = JSON.parse(disconnectResult.body); + + expect(bodyResult).toEqual({ + error: { + code: "CONNECTION_NOT_FOUND", + data: { + PK: "WS#CONNECTIONS", + SK: "myConnectionId" + }, + message: 'There is no connection with ID "myConnectionId".', + stack: expect.any(String) + }, + message: 'Route "$disconnect" action failed.', + statusCode: 200 + }); + }); +}); diff --git a/packages/api-websockets/__tests__/helpers/graphql/connections.ts b/packages/api-websockets/__tests__/helpers/graphql/connections.ts new file mode 100644 index 00000000000..8e63b75cccf --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/graphql/connections.ts @@ -0,0 +1,113 @@ +import { IWebsocketsConnectionRegistryData } from "~/registry"; +import { IWebsocketsResponseError } from "~/runner"; + +export interface IListConnectionsVariables { + where?: { + identityId?: string; + tenant?: string; + locale?: string; + }; +} + +export interface IListConnectionsResponse { + data: { + websockets: { + listConnections: { + data?: IWebsocketsConnectionRegistryData; + error?: IWebsocketsResponseError; + }; + }; + }; +} + +export const LIST_CONNECTIONS = /* GraphQL */ ` + query ListConnections($where: WebsocketsListConnectionsWhereInput) { + websockets { + listConnections(where: $where) { + data { + connectionId + domainName + stage + identity { + id + type + displayName + } + connectedOn + tenant + locale + } + error { + message + code + data + } + } + } + } +`; + +export interface IDisconnectIdentityConnectionsVariables { + identityId: string; +} + +export const DISCONNECT_IDENTITY_CONNECTIONS = /* GraphQL */ ` + mutation DisconnectIdentityConnections($identityId: String!) { + websockets { + disconnectIdentity(identityId: $identityId) { + data + error { + message + code + data + } + } + } + } +`; + +export interface IDisconnectTenantConnectionsVariables { + tenant: string; + locale?: string; +} + +export interface IDisconnectConnectionsResponse { + data: { + websockets: { + disconnectConnection: { + data?: boolean; + error?: IWebsocketsResponseError; + }; + }; + }; +} + +export const DISCONNECT_TENANT_CONNECTIONS = /* GraphQL */ ` + mutation DisconnectIdentityConnections($tenant: String!, $locale: String) { + websockets { + disconnectTenant(tenant: $tenant, locale: $locale) { + data + error { + message + code + data + } + } + } + } +`; + +export const DISCONNECT_ALL_CONNECTIONS = /* GraphQL */ ` + mutation DisconnectAllConnections { + websockets { + disconnectAll { + data + error { + message + code + data + } + } + } + } +`; diff --git a/packages/api-websockets/__tests__/helpers/helpers.ts b/packages/api-websockets/__tests__/helpers/helpers.ts new file mode 100644 index 00000000000..0b79d123751 --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/helpers.ts @@ -0,0 +1,76 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~tests/types"; + +export interface PermissionsArg { + name: string; + locales?: string[]; + rwd?: string; + pw?: string; + own?: boolean; +} + +export const identity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +const getSecurityIdentity = () => { + return identity; +}; + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "task.entry", + rwd: "rwd" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + }, + { + name: "*" + } + ]; +}; + +export const createIdentity = (identity?: SecurityIdentity) => { + if (!identity) { + return getSecurityIdentity(); + } + return identity; +}; + +export const createDummyLocales = () => { + const plugin = new ContextPlugin(async context => { + const { i18n, security } = context; + + await security.authenticate(""); + + await security.withoutAuthorization(async () => { + const [items] = await i18n.locales.listLocales({ + where: {} + }); + if (items.length > 0) { + return; + } + await i18n.locales.createLocale({ + code: "en-US", + default: true + }); + await i18n.locales.createLocale({ + code: "de-DE", + default: true + }); + }); + }); + + plugin.name = "testing.use-dummy-locales"; + + return plugin; +}; diff --git a/packages/api-websockets/__tests__/helpers/plugins.ts b/packages/api-websockets/__tests__/helpers/plugins.ts new file mode 100644 index 00000000000..4ddcf7d91f1 --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/plugins.ts @@ -0,0 +1,47 @@ +import { createWebsocketsRoutePlugins } from "~/runner/routes"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "~tests/helpers/tenancySecurity"; +import { createDummyLocales, createIdentity, createPermissions } from "~tests/helpers/helpers"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import { createWebsockets } from "~/index"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { createRawEventHandler } from "@webiny/handler-aws"; +import { PluginsContainer } from "@webiny/plugins"; +import { PluginCollection } from "@webiny/plugins/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; + +export const createPlugins = (plugins?: PluginCollection | PluginsContainer): PluginsContainer => { + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + const container = plugins instanceof PluginsContainer ? plugins : new PluginsContainer(); + container.register([ + createWebsocketsRoutePlugins(), + createWcpContext(), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: false, + permissions: createPermissions(), + identity: createIdentity() + }), + i18nContext(), + i18nStorage.storageOperations, + createWebsockets(), + createDummyLocales(), + mockLocalesPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + graphQLHandlerPlugins(), + + createRawEventHandler(async ({ context }) => { + return context; + }), + ...(plugins instanceof PluginsContainer ? [] : plugins || []) + ]); + return container; +}; diff --git a/packages/api-websockets/__tests__/helpers/tenancySecurity.ts b/packages/api-websockets/__tests__/helpers/tenancySecurity.ts new file mode 100644 index 00000000000..bb78363055e --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/tenancySecurity.ts @@ -0,0 +1,72 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy"; +import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security"; +import { + SecurityIdentity, + SecurityPermission, + SecurityStorageOperations +} from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import { Context } from "~tests/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; + +interface Config { + setupGraphQL?: boolean; + permissions: SecurityPermission[]; + identity?: SecurityIdentity | null; +} + +export const defaultIdentity: SecurityIdentity = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ + setupGraphQL, + permissions, + identity +}: Config): Plugin[] => { + const tenancyStorage = getStorageOps("tenancy"); + const securityStorage = getStorageOps("security"); + + const mockSecurityContextPlugin = new ContextPlugin(context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }); + mockSecurityContextPlugin.name = "testing.mock-security-context"; + + return [ + createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), + setupGraphQL ? createTenancyGraphQL() : null, + createSecurityContext({ storageOperations: securityStorage.storageOperations }), + setupGraphQL ? createSecurityGraphQL() : null, + mockSecurityContextPlugin, + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/api-websockets/__tests__/helpers/useGraphQLHandler.ts b/packages/api-websockets/__tests__/helpers/useGraphQLHandler.ts new file mode 100644 index 00000000000..df7d158e0bf --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/useGraphQLHandler.ts @@ -0,0 +1,102 @@ +import { PluginCollection } from "@webiny/plugins/types"; +import { createPlugins } from "./plugins"; +import { createHandler } from "@webiny/handler-aws/gateway"; +import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { + DISCONNECT_ALL_CONNECTIONS, + DISCONNECT_IDENTITY_CONNECTIONS, + DISCONNECT_TENANT_CONNECTIONS, + IDisconnectIdentityConnectionsVariables, + IDisconnectConnectionsResponse, + IDisconnectTenantConnectionsVariables, + IListConnectionsResponse, + IListConnectionsVariables, + LIST_CONNECTIONS +} from "./graphql/connections"; +import { getIntrospectionQuery } from "graphql"; +import { GenericRecord } from "@webiny/api/types"; + +export interface UseHandlerParams { + plugins?: PluginCollection; +} + +export interface InvokeParams { + httpMethod?: "POST"; + body: { + query: string; + variables?: V; + }; + headers?: Record; +} + +export const useGraphQLHandler = (params?: UseHandlerParams) => { + const { plugins = [] } = params || {}; + + const handler = createHandler({ + plugins: createPlugins(plugins) + }); + const invoke = async ({ + httpMethod = "POST", + body, + headers = {}, + ...rest + }: InvokeParams): Promise<[T, any]> => { + const response = await handler( + { + path: "/graphql", + httpMethod, + headers: { + ["x-tenant"]: "root", + ["Content-Type"]: "application/json", + ...headers + }, + body: JSON.stringify(body), + ...rest + } as unknown as APIGatewayEvent, + {} as LambdaContext + ); + + // The first element is the response body, and the second is the raw response. + return [JSON.parse(response.body) as unknown as T, response as any]; + }; + + return { + handler, + async introspect() { + return invoke({ body: { query: getIntrospectionQuery() } }); + }, + listConnections: async (variables?: IListConnectionsVariables) => { + return invoke({ + body: { query: LIST_CONNECTIONS, variables } + }); + }, + disconnectIdentity: async (identityId: string) => { + return invoke({ + body: { + query: DISCONNECT_IDENTITY_CONNECTIONS, + variables: { + identityId + } + } + }); + }, + disconnectTenant: async (tenant: string, locale?: string) => { + return invoke({ + body: { + query: DISCONNECT_TENANT_CONNECTIONS, + variables: { + tenant, + locale + } + } + }); + }, + disconnectAll: async () => { + return invoke({ + body: { + query: DISCONNECT_ALL_CONNECTIONS + } + }); + } + }; +}; diff --git a/packages/api-websockets/__tests__/helpers/useHandler.ts b/packages/api-websockets/__tests__/helpers/useHandler.ts new file mode 100644 index 00000000000..d4668dcc520 --- /dev/null +++ b/packages/api-websockets/__tests__/helpers/useHandler.ts @@ -0,0 +1,23 @@ +import { createRawHandler } from "@webiny/handler-aws"; +import { LambdaContext } from "@webiny/handler-aws/types"; +import { Context } from "~tests/types"; +import { PluginCollection } from "@webiny/plugins/types"; +import { createPlugins } from "./plugins"; + +export interface UseHandlerParams { + plugins?: PluginCollection; +} + +export const useHandler = (params?: UseHandlerParams) => { + const { plugins = [] } = params || {}; + + const handler = createRawHandler({ + plugins: createPlugins(plugins) + }); + + return { + handle: async () => { + return await handler({}, {} as LambdaContext); + } + }; +}; diff --git a/packages/api-websockets/__tests__/mocks/MockWebsocketsEventValidator.ts b/packages/api-websockets/__tests__/mocks/MockWebsocketsEventValidator.ts new file mode 100644 index 00000000000..aff05a4f3d6 --- /dev/null +++ b/packages/api-websockets/__tests__/mocks/MockWebsocketsEventValidator.ts @@ -0,0 +1,15 @@ +import { IWebsocketsEventValidator, IWebsocketsEventValidatorValidateParams } from "~/validator"; +import { IWebsocketsEvent, IWebsocketsEventData } from "~/handler/types"; + +export class MockWebsocketsEventValidator implements IWebsocketsEventValidator { + public async validate( + input: IWebsocketsEventValidatorValidateParams + ): Promise> { + return { + requestContext: { + ...(input.requestContext || {}) + }, + body: input.body || JSON.stringify({}) + } as unknown as IWebsocketsEvent; + } +} diff --git a/packages/api-websockets/__tests__/mocks/MockWebsocketsTransport.ts b/packages/api-websockets/__tests__/mocks/MockWebsocketsTransport.ts new file mode 100644 index 00000000000..8396eedf5e5 --- /dev/null +++ b/packages/api-websockets/__tests__/mocks/MockWebsocketsTransport.ts @@ -0,0 +1,19 @@ +import { + IWebsocketsTransport, + IWebsocketsTransportSendConnection, + IWebsocketsTransportSendData +} from "~/transport/abstractions/IWebsocketsTransport"; +import { GenericRecord } from "@webiny/api/types"; + +export class MockWebsocketsTransport implements IWebsocketsTransport { + public messages = new Map>(); + + public async send( + connections: IWebsocketsTransportSendConnection[], + data: IWebsocketsTransportSendData + ): Promise { + for (const connection of connections) { + this.messages.set(connection.connectionId, data); + } + } +} diff --git a/packages/api-websockets/__tests__/mocks/event.ts b/packages/api-websockets/__tests__/mocks/event.ts new file mode 100644 index 00000000000..ee5a7c1d615 --- /dev/null +++ b/packages/api-websockets/__tests__/mocks/event.ts @@ -0,0 +1,39 @@ +import { PartialDeep } from "type-fest"; +import { + IWebsocketsIncomingEvent, + WebsocketsEventRequestContextEventType, + WebsocketsEventRoute +} from "~/handler/types"; + +export interface CreateMockEventInput extends PartialDeep { + tenant?: string; + locale?: string; + token?: string; +} + +export const createMockEvent = (input: CreateMockEventInput = {}): IWebsocketsIncomingEvent => { + const { requestContext, body, tenant, locale, token } = input || {}; + return { + queryStringParameters: { + tenant: tenant || "root", + locale: locale || "en-US", + ...input.queryStringParameters + }, + requestContext: { + connectedAt: new Date().getTime(), + connectionId: "myConnectionId", + routeKey: WebsocketsEventRoute.default, + domainName: "https://webiny.com", + stage: "dev", + eventType: WebsocketsEventRequestContextEventType.message, + ...requestContext + }, + body: + body || + JSON.stringify({ + token: token || "aToken", + tenant: tenant || "root", + locale: locale || "en-US" + }) + }; +}; diff --git a/packages/api-websockets/__tests__/mocks/lambdaContext.ts b/packages/api-websockets/__tests__/mocks/lambdaContext.ts new file mode 100644 index 00000000000..ef244e5e8cf --- /dev/null +++ b/packages/api-websockets/__tests__/mocks/lambdaContext.ts @@ -0,0 +1,5 @@ +import { Context as LambdaContext } from "aws-lambda/handler"; + +export const createMockLambdaContext = () => { + return {} as LambdaContext; +}; diff --git a/packages/api-websockets/__tests__/registry/websocketsConnectionRegistry.test.ts b/packages/api-websockets/__tests__/registry/websocketsConnectionRegistry.test.ts new file mode 100644 index 00000000000..c81b639c989 --- /dev/null +++ b/packages/api-websockets/__tests__/registry/websocketsConnectionRegistry.test.ts @@ -0,0 +1,138 @@ +import { WebsocketsConnectionRegistry } from "~/registry/WebsocketsConnectionRegistry"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; + +describe("websockets connection registry", () => { + it("should register new connections", async () => { + const documentClient = getDocumentClient(); + const registry = new WebsocketsConnectionRegistry(documentClient); + + const result = await registry.register({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }); + + expect(result).toEqual({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: expect.any(String) + }); + + for (let i = 2; i <= 10; i++) { + await registry.register({ + connectionId: `connection-${i}`, + tenant: i % 2 ? "root" : "anotherTenant", + locale: "en-US", + identity: { + id: `id-${i}`, + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }); + } + + const connectionsViaIdentity1 = await registry.listViaIdentity("id-1"); + expect(connectionsViaIdentity1).toHaveLength(1); + + const connectionsViaIdentity2 = await registry.listViaIdentity("id-2"); + expect(connectionsViaIdentity2).toHaveLength(1); + + const connectionsViaIdentity3 = await registry.listViaIdentity("id-3"); + expect(connectionsViaIdentity3).toHaveLength(1); + + const connectionsViaIdentity4 = await registry.listViaIdentity("id-4"); + expect(connectionsViaIdentity4).toHaveLength(1); + + const connectionsViaIdentity5 = await registry.listViaIdentity("id-5"); + expect(connectionsViaIdentity5).toHaveLength(1); + + const connectionsViaIdentity6 = await registry.listViaIdentity("id-6"); + expect(connectionsViaIdentity6).toHaveLength(1); + + const connectionsViaIdentity7 = await registry.listViaIdentity("id-7"); + expect(connectionsViaIdentity7).toHaveLength(1); + + const connectionsViaIdentity8 = await registry.listViaIdentity("id-8"); + expect(connectionsViaIdentity8).toHaveLength(1); + + const connectionsViaIdentity9 = await registry.listViaIdentity("id-9"); + expect(connectionsViaIdentity9).toHaveLength(1); + + const connectionsViaIdentity10 = await registry.listViaIdentity("id-10"); + expect(connectionsViaIdentity10).toHaveLength(1); + + const connectionsViaRootTenant = await registry.listViaTenant("root"); + expect(connectionsViaRootTenant).toHaveLength(5); + + const connectionsViaAnotherTenant = await registry.listViaTenant("anotherTenant"); + expect(connectionsViaAnotherTenant).toHaveLength(5); + }); + + it("should unregister connections", async () => { + const documentClient = getDocumentClient(); + const registry = new WebsocketsConnectionRegistry(documentClient); + + const result = await registry.register({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: new Date().toISOString() + }); + + expect(result).toEqual({ + connectionId: "connection-1", + tenant: "root", + locale: "en-US", + identity: { + id: "id-1", + displayName: "John Doe", + type: "admin" + }, + domainName: "https://webiny.com", + stage: "dev", + connectedOn: expect.any(String) + }); + + const connectionsViaIdentity = await registry.listViaIdentity("id-1"); + expect(connectionsViaIdentity).toHaveLength(1); + + const connectionsViaRootTenant = await registry.listViaTenant("root"); + expect(connectionsViaRootTenant).toHaveLength(1); + + await registry.unregister({ + connectionId: "connection-1" + }); + + const connectionsViaIdentityAfterUnregister = await registry.listViaIdentity("id-1"); + expect(connectionsViaIdentityAfterUnregister).toHaveLength(0); + + const connectionsViaRootTenantAfterUnregister = await registry.listViaTenant("root"); + expect(connectionsViaRootTenantAfterUnregister).toHaveLength(0); + }); +}); diff --git a/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts b/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts new file mode 100644 index 00000000000..09d2970cade --- /dev/null +++ b/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts @@ -0,0 +1,343 @@ +import { WebsocketsRunner } from "~/runner"; +import { useHandler } from "~tests/helpers/useHandler"; +import { WebsocketsEventValidator } from "~/validator"; +import { MockWebsocketsEventValidator } from "~tests/mocks/MockWebsocketsEventValidator"; +import { WebsocketsContext } from "~/context"; +import { MockWebsocketsTransport } from "~tests/mocks/MockWebsocketsTransport"; +import { WebsocketsEventRoute } from "~/handler/types"; +import { createWebsocketsRoutePlugin } from "~/plugins"; +import { WebsocketsResponse } from "~/response"; + +describe("websockets runner", () => { + it("should run and fail the validation", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + /** + * We need to replace the context received from the handler with the one we create here. + */ + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + const validator = new WebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const resultRootLevel = await runner.run({}); + + expect(resultRootLevel).toEqual({ + statusCode: 200, + message: "Validation failed.", + error: { + message: "Validation failed.", + code: "VALIDATION_FAILED_INVALID_FIELDS", + data: { + invalidFields: { + requestContext: { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext"] + } + } + } + }, + stack: expect.any(String) + } + }); + + const resultRequestContext = await runner.run({ + requestContext: {} + }); + + expect(resultRequestContext).toEqual({ + error: { + code: "VALIDATION_FAILED_INVALID_FIELDS", + data: { + invalidFields: { + "requestContext.connectionId": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "connectionId"] + } + }, + "requestContext.connectedAt": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "connectedAt"] + } + }, + "requestContext.domainName": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "domainName"] + } + }, + "requestContext.eventType": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "eventType"] + } + }, + "requestContext.routeKey": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "routeKey"] + } + }, + "requestContext.stage": { + code: "invalid_type", + message: "Required", + data: { + path: ["requestContext", "stage"] + } + } + } + }, + message: "Validation failed.", + stack: expect.any(String) + }, + message: "Validation failed.", + statusCode: 200 + }); + }); + + it("should run and fail the route action - missing route", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const result = await runner.run({ + requestContext: { + // cast so we can trigger an error + routeKey: "aRouteKey" as unknown as WebsocketsEventRoute + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + error: { + code: "NO_ROUTE_PLUGINS", + data: { + route: "aRouteKey" + }, + message: "There are no plugins for the route: aRouteKey.", + stack: expect.any(String) + }, + message: 'Route "aRouteKey" action failed.', + statusCode: 200 + }); + }); + + it("should run and return good status - default route", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const result = await runner.run({ + requestContext: { + routeKey: WebsocketsEventRoute.default + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + statusCode: 200 + }); + }); + + it("should run and return good status - connect route", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const result = await runner.run({ + requestContext: { + connectionId: "myConnectionIdAbcdefg", + routeKey: WebsocketsEventRoute.connect + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + statusCode: 200 + }); + }); + + it("should run and return error status - disconnect route", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const result = await runner.run({ + requestContext: { + connectionId: "myConnectionIdAbcdefg", + routeKey: WebsocketsEventRoute.disconnect + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + error: { + code: "CONNECTION_NOT_FOUND", + data: { + PK: "WS#CONNECTIONS", + SK: "myConnectionIdAbcdefg" + }, + message: 'There is no connection with ID "myConnectionIdAbcdefg".', + stack: expect.any(String) + }, + message: 'Route "$disconnect" action failed.', + statusCode: 200 + }); + }); + + it("should run and return good status - disconnect route", async () => { + const handler = useHandler(); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const beforeConnectConnectionsViaTenant = await registry.listViaTenant("root"); + expect(beforeConnectConnectionsViaTenant).toHaveLength(0); + + const beforeConnectConnectionsViaIdentity = await registry.listViaIdentity("id-1"); + expect(beforeConnectConnectionsViaIdentity).toHaveLength(0); + + const connectResult = await runner.run({ + requestContext: { + connectionId: "myConnectionIdAbcdefg", + routeKey: WebsocketsEventRoute.connect + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(connectResult).toEqual({ + statusCode: 200 + }); + + const afterConnectConnectionsViaTenant = await registry.listViaTenant("root"); + expect(afterConnectConnectionsViaTenant).toHaveLength(1); + expect(afterConnectConnectionsViaTenant).toMatchObject([ + { + connectionId: "myConnectionIdAbcdefg" + } + ]); + + const afterConnectConnectionsViaIdentity = await registry.listViaIdentity("id-12345678"); + expect(afterConnectConnectionsViaIdentity).toHaveLength(1); + expect(afterConnectConnectionsViaIdentity).toMatchObject([ + { + connectionId: "myConnectionIdAbcdefg" + } + ]); + + const result = await runner.run({ + requestContext: { + connectionId: "myConnectionIdAbcdefg", + routeKey: WebsocketsEventRoute.disconnect + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + statusCode: 200 + }); + + const afterDisconnectConnectionsViaTenant = await registry.listViaTenant("root"); + expect(afterDisconnectConnectionsViaTenant).toHaveLength(0); + + const afterDisconnectConnectionsViaIdentity = await registry.listViaIdentity("id-1"); + expect(afterDisconnectConnectionsViaIdentity).toHaveLength(0); + }); + + it("should run and return good status - custom route", async () => { + const handler = useHandler({ + plugins: [ + createWebsocketsRoutePlugin("myCustomRouteKey", async ({ response }) => { + return response.ok(); + }) + ] + }); + + const context = await handler.handle(); + const registry = context.websockets.registry; + const validator = new MockWebsocketsEventValidator(); + const response = new WebsocketsResponse(); + + context.websockets = new WebsocketsContext(registry, new MockWebsocketsTransport()); + + const runner = new WebsocketsRunner(context, registry, validator, response); + + const result = await runner.run({ + requestContext: { + routeKey: "myCustomRouteKey" + }, + body: JSON.stringify({ + token: "aToken", + tenant: "root", + locale: "en-US" + }) + }); + expect(result).toEqual({ + statusCode: 200 + }); + }); +}); diff --git a/packages/api-websockets/__tests__/types.ts b/packages/api-websockets/__tests__/types.ts new file mode 100644 index 00000000000..02749883502 --- /dev/null +++ b/packages/api-websockets/__tests__/types.ts @@ -0,0 +1,5 @@ +import { Context as SocketsContext } from "~/types"; +import { SecurityContext } from "@webiny/api-security/types"; +import { I18NContext } from "@webiny/api-i18n/types"; + +export interface Context extends SocketsContext, SecurityContext, I18NContext {} diff --git a/packages/api-websockets/__tests__/utils/middleware.ts b/packages/api-websockets/__tests__/utils/middleware.ts new file mode 100644 index 00000000000..b5be9dd0731 --- /dev/null +++ b/packages/api-websockets/__tests__/utils/middleware.ts @@ -0,0 +1,100 @@ +import { middleware, MiddlewareCallable as BaseMiddlewareCallable } from "~/utils/middleware"; + +interface MiddlewareCallable extends BaseMiddlewareCallable { + calls: number; +} + +const createFunctions = () => { + const firstFunction: MiddlewareCallable = async (input, next) => { + const output = await next(); + firstFunction.calls++; + return { + ...output, + first: "yes" + }; + }; + + firstFunction.calls = 0; + + const secondFunction: MiddlewareCallable = async (input, next) => { + const output = await next(); + console.log("secondFunction", output); + secondFunction.calls++; + return { + ...output, + second: "yes" + }; + }; + secondFunction.calls = 0; + + const thirdFunction: MiddlewareCallable = async (input, next) => { + const output = await next(); + console.log("thirdFunction", output); + thirdFunction.calls++; + return { + ...output, + third: "yes" + }; + }; + thirdFunction.calls = 0; + + return { + firstFunction, + secondFunction, + thirdFunction + }; +}; + +describe("middleware", () => { + it("should execute a single function", async () => { + const { firstFunction } = createFunctions(); + + const exec = middleware([firstFunction]); + + const firstResult = await exec({}); + expect(firstResult).toEqual({ + first: "yes" + }); + expect(firstFunction.calls).toBe(1); + + const secondResult = await exec({}); + expect(secondResult).toEqual({ + first: "yes" + }); + expect(firstFunction.calls).toBe(2); + + const thirdResult = await exec({}); + expect(thirdResult).toEqual({ + first: "yes" + }); + expect(firstFunction.calls).toBe(3); + }); + + it("should execute all functions", async () => { + const { firstFunction, secondFunction, thirdFunction } = createFunctions(); + + const exec = middleware([firstFunction, secondFunction, thirdFunction]); + + const firstResult = await exec({}); + expect(firstResult).toEqual({ + first: "yes", + second: "yes", + third: "yes" + }); + + expect(firstFunction.calls).toBe(1); + expect(secondFunction.calls).toBe(1); + expect(thirdFunction.calls).toBe(1); + + const secondResult = await exec({}); + expect(secondResult).toEqual({ + first: "yes", + second: "yes", + third: "yes" + }); + + expect(firstFunction.calls).toBe(2); + expect(secondFunction.calls).toBe(2); + expect(thirdFunction.calls).toBe(2); + }); +}); diff --git a/packages/api-websockets/jest.setup.js b/packages/api-websockets/jest.setup.js new file mode 100644 index 00000000000..049095053ae --- /dev/null +++ b/packages/api-websockets/jest.setup.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const presets = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-i18n", "storage-operations"], + ["@webiny/api-security", "storage-operations"], + ["@webiny/api-tenancy", "storage-operations"] +); + +module.exports = { + ...base({ path: __dirname }, presets) +}; diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json new file mode 100644 index 00000000000..343f4418e7b --- /dev/null +++ b/packages/api-websockets/package.json @@ -0,0 +1,56 @@ +{ + "name": "@webiny/api-websockets", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Websockets API", + "contributors": [ + "Bruno Zorić " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@webiny/api": "0.0.0", + "@webiny/api-i18n": "0.0.0", + "@webiny/api-security": "0.0.0", + "@webiny/api-tenancy": "0.0.0", + "@webiny/aws-sdk": "0.0.0", + "@webiny/db-dynamodb": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/utils": "0.0.0", + "type-fest": "^2.19.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@babel/cli": "^7.23.9", + "@babel/core": "^7.23.9", + "@babel/preset-env": "^7.23.9", + "@babel/preset-typescript": "^7.23.3", + "@types/aws-lambda": "^8.10.131", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/api-wcp": "0.0.0", + "@webiny/cli": "0.0.0", + "@webiny/handler-db": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/project-utils": "0.0.0", + "aws-lambda": "^1.0.7", + "graphql": "^15.8.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.13", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/api-websockets/src/context/WebsocketsContext.ts b/packages/api-websockets/src/context/WebsocketsContext.ts new file mode 100644 index 00000000000..9da2a0a461c --- /dev/null +++ b/packages/api-websockets/src/context/WebsocketsContext.ts @@ -0,0 +1,88 @@ +import WebinyError from "@webiny/error"; +import { IWebsocketsConnectionRegistry } from "~/registry"; +import { + IWebsocketsContext, + IWebsocketsContextDisconnectParams, + IWebsocketsContextListConnectionsParams, + IWebsocketsContextListConnectionsResponse, + IWebsocketsIdentity +} from "./abstractions/IWebsocketsContext"; +import { + IWebsocketsTransport, + IWebsocketsTransportSendConnection, + IWebsocketsTransportSendData +} from "~/transport"; +import { GenericRecord } from "@webiny/api/types"; + +export class WebsocketsContext implements IWebsocketsContext { + public readonly registry: IWebsocketsConnectionRegistry; + private readonly transport: IWebsocketsTransport; + + constructor(registry: IWebsocketsConnectionRegistry, transport: IWebsocketsTransport) { + this.registry = registry; + this.transport = transport; + } + + public async send( + identity: IWebsocketsIdentity, + data: IWebsocketsTransportSendData + ): Promise { + const connections = await this.listConnections({ + where: { + identityId: identity.id + } + }); + return this.transport.send(connections, data); + } + + public async sendToConnections( + connections: IWebsocketsTransportSendConnection[], + data: IWebsocketsTransportSendData + ): Promise { + return this.transport.send(connections, data); + } + + public async listConnections( + params?: IWebsocketsContextListConnectionsParams + ): IWebsocketsContextListConnectionsResponse { + const where = params?.where || {}; + if (where.identityId) { + return await this.registry.listViaIdentity(where.identityId); + } else if (where.tenant) { + return await this.registry.listViaTenant(where.tenant, where.locale); + } + return await this.registry.listAll(); + } + + public async disconnect( + params?: IWebsocketsContextDisconnectParams, + notify = true + ): Promise { + const where = params?.where || {}; + + const connections = await this.listConnections({ where }); + for (const connection of connections) { + await this.registry.unregister(connection); + } + if (!notify) { + return true; + } + + try { + await this.transport.send(connections, { + action: "forcedDisconnect" + }); + } catch (ex) { + throw new WebinyError( + "Failed to notify the clients about the forced disconnect.", + "FORCED_DISCONNECT_ERROR", + { + connections, + error: ex + } + ); + } + + return true; + } +} diff --git a/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts b/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts new file mode 100644 index 00000000000..6df6ef3ad84 --- /dev/null +++ b/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts @@ -0,0 +1,48 @@ +import { IWebsocketsConnectionRegistry, IWebsocketsConnectionRegistryData } from "~/registry"; +import { IWebsocketsTransportSendConnection, IWebsocketsTransportSendData } from "~/transport"; +import { SecurityIdentity } from "@webiny/api-security/types"; +import { GenericRecord } from "@webiny/api/types"; + +export type IWebsocketsContextListConnectionsResponse = Promise< + IWebsocketsConnectionRegistryData[] +>; + +export type IWebsocketsIdentity = Pick; + +export interface IWebsocketsContextListConnectionsParamsWhere { + identityId?: string; + tenant?: string; + locale?: string; +} + +export interface IWebsocketsContextListConnectionsParams { + where?: IWebsocketsContextListConnectionsParamsWhere; +} + +export interface IWebsocketsContextDisconnectParamsWhere { + connectionId?: string; + identityId?: string; + tenant?: string; + locale?: string; +} + +export interface IWebsocketsContextDisconnectParams { + where?: IWebsocketsContextDisconnectParamsWhere; +} + +export interface IWebsocketsContext { + readonly registry: IWebsocketsConnectionRegistry; + + send( + identity: IWebsocketsIdentity, + data: IWebsocketsTransportSendData + ): Promise; + sendToConnections( + connections: IWebsocketsTransportSendConnection[], + data: IWebsocketsTransportSendData + ): Promise; + listConnections( + params?: IWebsocketsContextListConnectionsParams + ): IWebsocketsContextListConnectionsResponse; + disconnect(params?: IWebsocketsContextDisconnectParams, notify?: boolean): Promise; +} diff --git a/packages/api-websockets/src/context/index.ts b/packages/api-websockets/src/context/index.ts new file mode 100644 index 00000000000..084db80c4a4 --- /dev/null +++ b/packages/api-websockets/src/context/index.ts @@ -0,0 +1,25 @@ +import { ContextPlugin } from "@webiny/handler"; +import { Context } from "~/types"; +import { WebsocketsContext } from "./WebsocketsContext"; +import { WebsocketsConnectionRegistry } from "~/registry"; +import { WebsocketsTransport } from "~/transport"; + +export * from "./WebsocketsContext"; +export * from "./abstractions/IWebsocketsContext"; + +export const createWebsocketsContext = () => { + const plugin = new ContextPlugin(async context => { + /** + * TODO Find a better way to send the documentClient to the registry. + */ + // @ts-expect-error + const documentClient = context.db.driver.documentClient; + const registry = new WebsocketsConnectionRegistry(documentClient); + const transport = new WebsocketsTransport(); + context.websockets = new WebsocketsContext(registry, transport); + }); + + plugin.name = "websockets.context"; + + return plugin; +}; diff --git a/packages/api-websockets/src/graphql/createResolvers.ts b/packages/api-websockets/src/graphql/createResolvers.ts new file mode 100644 index 00000000000..71bfcc18a84 --- /dev/null +++ b/packages/api-websockets/src/graphql/createResolvers.ts @@ -0,0 +1,81 @@ +import { Resolvers } from "@webiny/handler-graphql/types"; +import { Context } from "~/types"; +import { emptyResolver, resolve } from "./utils"; +import { IWebsocketsContextListConnectionsParams } from "~/context"; + +export interface IWebsocketsMutationDisconnectConnectionArgs { + connectionId: string; +} + +export interface IWebsocketsMutationDisconnectIdentityArgs { + identityId: string; +} + +export interface IWebsocketsMutationDisconnectTenantArgs { + tenant: string; + locale?: string; +} + +export const createResolvers = (): Resolvers => { + return { + Query: { + websockets: emptyResolver + }, + Mutation: { + websockets: emptyResolver + }, + WebsocketsQuery: { + listConnections: async (_, args: IWebsocketsContextListConnectionsParams, context) => { + return resolve(async () => { + return await context.websockets.listConnections(args); + }); + } + }, + WebsocketsMutation: { + // @ts-expect-error + disconnectConnection: async ( + _, + args: IWebsocketsMutationDisconnectConnectionArgs, + context + ) => { + return resolve(async () => { + return await context.websockets.disconnect({ + where: { + connectionId: args.connectionId + } + }); + }); + }, + // @ts-expect-error + disconnectIdentity: async ( + _, + args: IWebsocketsMutationDisconnectIdentityArgs, + context + ) => { + return resolve(async () => { + return await context.websockets.disconnect({ + where: { + identityId: args.identityId + } + }); + }); + }, + // @ts-expect-error + disconnectTenant: async (_, args: IWebsocketsMutationDisconnectTenantArgs, context) => { + return resolve(async () => { + return await context.websockets.disconnect({ + where: { + tenant: args.tenant, + locale: args.locale + } + }); + }); + }, + disconnectAll: async (_, __, context) => { + return resolve(async () => { + return await context.websockets.disconnect(); + }); + } + } + }; +}; diff --git a/packages/api-websockets/src/graphql/createTypeDefs.ts b/packages/api-websockets/src/graphql/createTypeDefs.ts new file mode 100644 index 00000000000..f95de3e929c --- /dev/null +++ b/packages/api-websockets/src/graphql/createTypeDefs.ts @@ -0,0 +1,69 @@ +export const createTypeDefs = () => { + return /* GraphQL */ ` + type WebsocketsIdentity { + id: String! + type: String + displayName: String + } + type WebsocketsConnection { + connectionId: String! + domainName: String! + stage: String! + identity: WebsocketsIdentity! + connectedOn: DateTime! + tenant: String! + locale: String! + } + + type WebsocketsError { + message: String! + code: String! + data: JSON + } + + type WebsocketsListConnectionsResponse { + data: [WebsocketsConnection!] + error: WebsocketsError + } + + input WebsocketsListConnectionsWhereInput { + identityId: String + tenant: String + locale: String + } + + type WebsocketsDisconnectResponse { + data: Boolean + error: WebsocketsError + } + + type WebsocketsQuery { + _empty: String + } + + type WebsocketsMutation { + _empty: String + } + + extend type Query { + websockets: WebsocketsQuery + } + + extend type Mutation { + websockets: WebsocketsMutation + } + + extend type WebsocketsQuery { + listConnections( + where: WebsocketsListConnectionsWhereInput + ): WebsocketsListConnectionsResponse! + } + + extend type WebsocketsMutation { + disconnectConnection(connectionId: String!): WebsocketsDisconnectResponse! + disconnectIdentity(identityId: String!): WebsocketsDisconnectResponse! + disconnectTenant(tenant: String!, locale: String): WebsocketsDisconnectResponse! + disconnectAll: WebsocketsDisconnectResponse! + } + `; +}; diff --git a/packages/api-websockets/src/graphql/index.ts b/packages/api-websockets/src/graphql/index.ts new file mode 100644 index 00000000000..e8a809304f1 --- /dev/null +++ b/packages/api-websockets/src/graphql/index.ts @@ -0,0 +1,14 @@ +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { createTypeDefs } from "./createTypeDefs"; +import { createResolvers } from "./createResolvers"; + +export const createWebsocketsGraphQL = () => { + const plugin = new GraphQLSchemaPlugin({ + typeDefs: createTypeDefs(), + resolvers: createResolvers() + }); + + plugin.name = "websockets.graphql"; + + return plugin; +}; diff --git a/packages/api-websockets/src/graphql/utils.ts b/packages/api-websockets/src/graphql/utils.ts new file mode 100644 index 00000000000..5b96be6055a --- /dev/null +++ b/packages/api-websockets/src/graphql/utils.ts @@ -0,0 +1,15 @@ +import { ErrorResponse, Response } from "@webiny/handler-graphql"; + +export const emptyResolver = () => ({}); + +interface ResolveCallable { + (): Promise; +} + +export const resolve = async (fn: ResolveCallable) => { + try { + return new Response(await fn()); + } catch (ex) { + return new ErrorResponse(ex); + } +}; diff --git a/packages/api-websockets/src/handler/handler.ts b/packages/api-websockets/src/handler/handler.ts new file mode 100644 index 00000000000..896254d348f --- /dev/null +++ b/packages/api-websockets/src/handler/handler.ts @@ -0,0 +1,93 @@ +import WebinyError from "@webiny/error"; +import { createHandler as createBaseHandler } from "@webiny/handler"; +import { registerDefaultPlugins } from "@webiny/handler-aws/plugins"; +import { execute } from "@webiny/handler-aws/execute"; +import { PluginsContainer } from "@webiny/plugins"; +import { createWebsocketsRoutePlugins } from "~/runner/routes"; +import { WebsocketsEventValidator } from "~/validator"; +import { WebsocketsResponse } from "~/response"; +import { Context } from "~/types"; +import { WebsocketsRunner } from "~/runner"; +import { PluginCollection } from "@webiny/plugins/types"; +import { HandlerCallable, HandlerParams } from "./types"; +import { getEventValues } from "./headers"; + +const url = "/webiny-websockets"; + +const createPluginsContainer = ( + plugins?: PluginsContainer | PluginCollection +): PluginsContainer => { + if (plugins instanceof PluginsContainer) { + return plugins; + } + return new PluginsContainer(plugins || []); +}; + +export const createHandler = (params: HandlerParams): HandlerCallable => { + const plugins = createPluginsContainer(params.plugins); + plugins.register(...createWebsocketsRoutePlugins()); + + return async event => { + const app = createBaseHandler({ + ...params, + plugins, + options: { + logger: params.debug === true, + ...(params.options || {}) + } + }); + + registerDefaultPlugins(app.webiny); + + app.setErrorHandler(async (error, _, reply) => { + app.__webiny_raw_result = { + error: { + message: error.message, + code: error.code, + data: error.data + }, + statusCode: 200 + }; + return reply.send({}); + }); + + app.post(url, async (_, reply) => { + const { validator, response } = params; + const context = app.webiny as Context; + const handler = new WebsocketsRunner( + context, + context.websockets.registry, + validator || new WebsocketsEventValidator(), + response || new WebsocketsResponse() + ); + + const result = await handler.run(event); + + return reply + .status(result.statusCode) + .headers({ + "Sec-WebSocket-Protocol": "webiny-ws-v1" + }) + .send(result); + }); + + const { tenant, locale, endpoint, token } = getEventValues(event); + + const headers = { + Authorization: `Bearer ${token}`, + ["x-tenant"]: tenant, + ["x-webiny-cms-locale"]: locale, + ["x-webiny-cms-endpoint"]: endpoint, + ...event.headers + }; + + return execute({ + app, + url, + payload: { + ...event, + headers + } + }); + }; +}; diff --git a/packages/api-websockets/src/handler/headers.ts b/packages/api-websockets/src/handler/headers.ts new file mode 100644 index 00000000000..9844c8af475 --- /dev/null +++ b/packages/api-websockets/src/handler/headers.ts @@ -0,0 +1,44 @@ +import { IWebsocketsEventData, IWebsocketsIncomingEvent } from "~/handler/types"; + +const getEventBody = (event: IWebsocketsIncomingEvent): IWebsocketsEventData => { + if (!event.body) { + return {}; + } else if (typeof event.body === "object") { + return event.body; + } else if (typeof event.body === "string") { + try { + return JSON.parse(event.body); + } catch (ex) { + console.log(ex.message); + return {}; + } + } + console.log("Unexpected event.body type:", typeof event.body); + return {}; +}; + +const getToken = (body: IWebsocketsEventData, event: IWebsocketsIncomingEvent): string | null => { + return body?.token || event.queryStringParameters?.token || null; +}; + +const getTenant = (body: IWebsocketsEventData, event: IWebsocketsIncomingEvent): string => { + return body?.tenant || event.queryStringParameters?.tenant || "root"; +}; + +const getLocale = (body: IWebsocketsEventData, event: IWebsocketsIncomingEvent): string => { + return body?.locale || event.queryStringParameters?.locale || "en-US"; +}; + +export const getEventValues = (event: IWebsocketsIncomingEvent) => { + const body = getEventBody(event); + + const token = getToken(body, event); + const tenant = getTenant(body, event); + const locale = getLocale(body, event); + return { + tenant, + locale, + token, + endpoint: "manage" + }; +}; diff --git a/packages/api-websockets/src/handler/register.ts b/packages/api-websockets/src/handler/register.ts new file mode 100644 index 00000000000..c5347557bf7 --- /dev/null +++ b/packages/api-websockets/src/handler/register.ts @@ -0,0 +1,20 @@ +import { registry } from "@webiny/handler-aws/registry"; +import { createSourceHandler } from "@webiny/handler-aws"; +import { HandlerParams, IWebsocketsIncomingEvent } from "./types"; + +const handler = createSourceHandler({ + name: "handler-webiny-websockets", + canUse: event => { + const { routeKey, connectionId, eventType } = event.requestContext || {}; + return !!routeKey && !!connectionId && !!eventType; + }, + handle: async ({ params, event, context }) => { + const { createHandler } = await import( + /* webpackChunkName: "SocketsHandler" */ + "./handler" + ); + return createHandler(params)(event, context); + } +}); + +registry.register(handler); diff --git a/packages/api-websockets/src/handler/types.ts b/packages/api-websockets/src/handler/types.ts new file mode 100644 index 00000000000..0d48f788c3a --- /dev/null +++ b/packages/api-websockets/src/handler/types.ts @@ -0,0 +1,83 @@ +import { HandlerFactoryParams } from "@webiny/handler-aws/types"; +import { IWebsocketsEventValidator } from "~/validator"; +import { IWebsocketsResponse } from "~/response"; +import { APIGatewayProxyResult, Context as LambdaContext } from "aws-lambda"; +import { GenericRecord } from "@webiny/api/types"; +import { PartialDeep } from "type-fest"; + +export interface HandlerCallable { + (event: IWebsocketsIncomingEvent, context: LambdaContext): Promise; +} + +export interface HandlerParams extends HandlerFactoryParams { + validator?: IWebsocketsEventValidator; + response?: IWebsocketsResponse; +} + +export enum WebsocketsEventRoute { + "connect" = "$connect", + "disconnect" = "$disconnect", + "default" = "$default" +} + +export interface IWebsocketsEventData { + token?: string; + tenant?: string; + locale?: string; + messageId?: string; + action?: string; + data?: GenericRecord; +} + +export enum WebsocketsEventRequestContextEventType { + "message" = "MESSAGE", + "connect" = "CONNECT", + "disconnect" = "DISCONNECT" +} + +export interface IWebsocketsEventRequestContext { + connectionId: string; + connectedAt: number; + domainName: string; + eventType: WebsocketsEventRequestContextEventType; + routeKey: WebsocketsEventRoute | string; + stage: string; +} + +export interface IWebsocketsEventHeaders { + "Accept-Encoding"?: string; + "Accept-Language"?: string; + "Cache-Control"?: string; + Host?: string; + Origin?: string; + Pragma?: string; + "Sec-WebSocket-Extensions"?: string; + "Sec-WebSocket-Key"?: string; + "Sec-WebSocket-Version"?: string; + "Sec-WebSocket-Protocol"?: string; + "User-Agent"?: string; + "X-Amzn-Trace-Id"?: string; + "X-Forwarded-For"?: string; + "X-Forwarded-Port"?: `${number}`; + "X-Forwarded-Proto"?: "https" | "http"; + ["x-tenant"]?: string; + ["x-webiny-cms-locale"]?: string; + ["x-webiny-cms-endpoint"]?: string; +} + +export interface IWebsocketsEventQueryStringParameters { + tenant?: string; + locale?: string; + token?: string; +} + +export interface IWebsocketsEvent { + headers?: IWebsocketsEventHeaders; + queryStringParameters?: IWebsocketsEventQueryStringParameters; + requestContext: IWebsocketsEventRequestContext; + body?: T; +} + +export interface IWebsocketsIncomingEvent extends PartialDeep> { + body?: string | GenericRecord; +} diff --git a/packages/api-websockets/src/index.ts b/packages/api-websockets/src/index.ts new file mode 100644 index 00000000000..752889ff200 --- /dev/null +++ b/packages/api-websockets/src/index.ts @@ -0,0 +1,17 @@ +import "./handler/register"; +import { Plugin } from "@webiny/plugins/types"; +import { createWebsocketsContext } from "~/context"; +import { createWebsocketsGraphQL } from "~/graphql"; + +export const createWebsockets = (): Plugin[] => { + return [createWebsocketsContext(), createWebsocketsGraphQL()]; +}; + +export * from "./validator"; +export * from "./transport"; +export * from "./runner"; +export * from "./registry"; +export * from "./context"; + +export * from "./plugins"; +export * from "./types"; diff --git a/packages/api-websockets/src/plugins/WebsocketsActionPlugin.ts b/packages/api-websockets/src/plugins/WebsocketsActionPlugin.ts new file mode 100644 index 00000000000..43e2510f352 --- /dev/null +++ b/packages/api-websockets/src/plugins/WebsocketsActionPlugin.ts @@ -0,0 +1,33 @@ +import { Plugin } from "@webiny/plugins"; +import { GenericRecord } from "@webiny/api/types"; +import { + IWebsocketsActionPluginCallable, + IWebsocketsActionPluginCallableParams, + WebsocketsActionPluginCallableResponse +} from "./abstrations/IWebsocketsActionPlugin"; + +export class WebsocketsActionPlugin extends Plugin { + public static override readonly type: string = "websockets.route.action"; + + public readonly action: string; + private readonly cb: IWebsocketsActionPluginCallable; + + public constructor(action: string, cb: IWebsocketsActionPluginCallable) { + super(); + this.action = action; + this.cb = cb; + } + + public run( + params: IWebsocketsActionPluginCallableParams + ): Promise> { + return this.cb(params); + } +} + +export const createWebsocketsAction = ( + action: string, + cb: IWebsocketsActionPluginCallable +) => { + return new WebsocketsActionPlugin(action, cb); +}; diff --git a/packages/api-websockets/src/plugins/WebsocketsRoutePlugin.ts b/packages/api-websockets/src/plugins/WebsocketsRoutePlugin.ts new file mode 100644 index 00000000000..402bbc909dd --- /dev/null +++ b/packages/api-websockets/src/plugins/WebsocketsRoutePlugin.ts @@ -0,0 +1,65 @@ +import { Plugin } from "@webiny/plugins"; +import { IWebsocketsEvent, IWebsocketsEventData, WebsocketsEventRoute } from "~/handler/types"; +import { Context } from "~/types"; +import { IWebsocketsRunnerResponse } from "~/runner"; +import { IWebsocketsConnectionRegistry } from "~/registry"; +import { IWebsocketsResponse } from "~/response/abstractions/IWebsocketsResponse"; +import { IWebsocketsIdentity } from "~/context"; + +export interface IWebsocketsRoutePluginCallableParams< + C extends Context = Context, + R extends IWebsocketsRunnerResponse = IWebsocketsRunnerResponse, + T extends IWebsocketsEventData = IWebsocketsEventData +> { + event: IWebsocketsEvent; + registry: IWebsocketsConnectionRegistry; + context: C; + response: IWebsocketsResponse; + getTenant: () => string | null; + getLocale: () => string | null; + getIdentity: () => IWebsocketsIdentity | null; + next: () => Promise; +} + +export interface IWebsocketsRoutePluginCallable< + C extends Context = Context, + R extends IWebsocketsRunnerResponse = IWebsocketsRunnerResponse, + T extends IWebsocketsEventData = IWebsocketsEventData +> { + (params: IWebsocketsRoutePluginCallableParams): Promise; +} + +export class WebsocketsRoutePlugin< + C extends Context = Context, + R extends IWebsocketsRunnerResponse = IWebsocketsRunnerResponse, + T extends IWebsocketsEventData = IWebsocketsEventData +> extends Plugin { + public static override readonly type: string = "websockets.route"; + + public readonly route: WebsocketsEventRoute | string; + private readonly cb: IWebsocketsRoutePluginCallable; + + public constructor( + route: WebsocketsEventRoute | string, + cb: IWebsocketsRoutePluginCallable + ) { + super(); + this.route = route; + this.cb = cb; + } + + public async run(params: IWebsocketsRoutePluginCallableParams): Promise { + return this.cb(params); + } +} + +export const createWebsocketsRoutePlugin = < + C extends Context = Context, + R extends IWebsocketsRunnerResponse = IWebsocketsRunnerResponse, + T extends IWebsocketsEventData = IWebsocketsEventData +>( + route: WebsocketsEventRoute | string, + cb: IWebsocketsRoutePluginCallable +) => { + return new WebsocketsRoutePlugin(route, cb); +}; diff --git a/packages/api-websockets/src/plugins/abstrations/IWebsocketsActionPlugin.ts b/packages/api-websockets/src/plugins/abstrations/IWebsocketsActionPlugin.ts new file mode 100644 index 00000000000..42c84b36357 --- /dev/null +++ b/packages/api-websockets/src/plugins/abstrations/IWebsocketsActionPlugin.ts @@ -0,0 +1,64 @@ +import { GenericRecord } from "@webiny/api/types"; +import { Context } from "~/types"; + +export interface IWebsocketsActionPluginCallableParamsSend { + toConnection( + connectionId: string, + data: T + ): Promise; + toTenant(tenant: string, data: T): Promise; + toTenantAndLocale( + tenant: string, + locale: string, + data: T + ): Promise; + toIdentity(identity: string, data: T): Promise; +} + +export interface IWebsocketsActionPluginCallableParamsRespondError< + T extends GenericRecord = GenericRecord +> { + message: string; + code: string; + data: T; +} + +export interface IWebsocketsActionPluginCallableParamsRespondOkResponse< + T extends GenericRecord = GenericRecord +> { + data: T; + error?: never; +} + +export interface IWebsocketsActionPluginCallableParamsRespondErrorResponse< + T extends GenericRecord = GenericRecord +> { + error: IWebsocketsActionPluginCallableParamsRespondError; + data?: never; +} + +export interface IWebsocketsActionPluginCallableParamsRespond { + ok( + data: T + ): Promise>; + error( + error: IWebsocketsActionPluginCallableParamsRespondError + ): Promise>; +} + +export interface IWebsocketsActionPluginCallableParams { + context: C; + next(): Promise; + send: IWebsocketsActionPluginCallableParamsSend; + respond: IWebsocketsActionPluginCallableParamsRespond; +} + +export type WebsocketsActionPluginCallableResponse = + | IWebsocketsActionPluginCallableParamsRespondOkResponse + | IWebsocketsActionPluginCallableParamsRespondErrorResponse; + +export interface IWebsocketsActionPluginCallable { + (params: IWebsocketsActionPluginCallableParams): Promise< + WebsocketsActionPluginCallableResponse + >; +} diff --git a/packages/api-websockets/src/plugins/index.ts b/packages/api-websockets/src/plugins/index.ts new file mode 100644 index 00000000000..065141d7308 --- /dev/null +++ b/packages/api-websockets/src/plugins/index.ts @@ -0,0 +1,3 @@ +export * from "./WebsocketsRoutePlugin"; +export * from "./WebsocketsActionPlugin"; +export * from "./abstrations/IWebsocketsActionPlugin"; diff --git a/packages/api-websockets/src/registry/WebsocketsConnectionRegistry.ts b/packages/api-websockets/src/registry/WebsocketsConnectionRegistry.ts new file mode 100644 index 00000000000..3421eab917e --- /dev/null +++ b/packages/api-websockets/src/registry/WebsocketsConnectionRegistry.ts @@ -0,0 +1,165 @@ +import WebinyError from "@webiny/error"; +import { + IWebsocketsConnectionRegistry, + IWebsocketsConnectionRegistryData, + IWebsocketsConnectionRegistryRegisterParams, + IWebsocketsConnectionRegistryUnregisterParams +} from "./abstractions/IWebsocketsConnectionRegistry"; +import { createEntity } from "~/registry/entity"; +import { deleteItem, get, put, queryAll } from "@webiny/db-dynamodb"; +import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import { EntityQueryOptions } from "@webiny/db-dynamodb/toolbox"; + +const PK = `WS#CONNECTIONS`; +const GSI1_PK = "WS#CONNECTIONS#IDENTITY"; +const GSI2_PK = "WS#CONNECTIONS#TENANT#LOCALE"; + +interface IWebsocketsConnectionRegistryDbItem { + PK: string; + SK: string; + GSI1_PK: string; + GSI1_SK: string; + GSI2_PK: string; + GSI2_SK: string; + data: IWebsocketsConnectionRegistryData; +} + +export class WebsocketsConnectionRegistry implements IWebsocketsConnectionRegistry { + private readonly entity: ReturnType; + + public constructor(documentClient: DynamoDBDocument) { + this.entity = createEntity(documentClient); + } + + public async register( + params: IWebsocketsConnectionRegistryRegisterParams + ): Promise { + const { connectionId, tenant, locale, identity, domainName, stage, connectedOn } = params; + + const data: IWebsocketsConnectionRegistryData = { + connectionId, + identity, + tenant, + locale, + domainName, + stage, + connectedOn + }; + await this.store(data); + return data; + } + + public async unregister(params: IWebsocketsConnectionRegistryUnregisterParams): Promise { + const { connectionId } = params; + + const keys = { + PK, + SK: connectionId + }; + const original = await get({ + entity: this.entity, + keys + }); + if (!original) { + const message = `There is no connection with ID "${connectionId}".`; + throw new WebinyError(message, "CONNECTION_NOT_FOUND", keys); + } + + try { + await deleteItem({ + entity: this.entity, + keys + }); + } catch (ex) { + console.error( + `Could not remove connection from the database: ${original.data.connectionId}` + ); + throw new WebinyError(ex.message, ex.code, keys); + } + } + /** + * Uses GSI1 keys + */ + public async listViaIdentity(identity: string): Promise { + const items = await queryAll({ + entity: this.entity, + partitionKey: GSI1_PK, + options: { + index: "GSI1", + eq: identity + } + }); + return items.map(item => { + return item.data; + }); + } + + /** + * Uses GSI2 keys + */ + public async listViaTenant( + tenant: string, + locale?: string + ): Promise { + let options: Partial = { + beginsWith: `T#${tenant}#L#` + }; + if (locale) { + options = { + eq: `T#${tenant}#L#${locale}` + }; + } + const items = await queryAll({ + entity: this.entity, + partitionKey: GSI2_PK, + options: { + ...options, + index: "GSI2" + } + }); + return items.map(item => { + return item.data; + }); + } + + public async listAll(): Promise { + const items = await queryAll({ + entity: this.entity, + partitionKey: PK, + options: { + gte: " " + } + }); + return items.map(item => { + return item.data; + }); + } + + private async store(data: IWebsocketsConnectionRegistryData) { + const { connectionId, tenant, locale, identity } = data; + const item: IWebsocketsConnectionRegistryDbItem = { + // to find specific identity related to given connection + PK, + SK: connectionId, + // to find all connections related to given identity + GSI1_PK, + GSI1_SK: identity.id, + // to find all connections related to given tenant/locale combination + GSI2_PK, + GSI2_SK: `T#${tenant}#L#${locale}`, + data + }; + try { + return await put({ + entity: this.entity, + item + }); + } catch (err) { + throw WebinyError.from(err, { + message: "Could not store websockets connection data.", + code: "STORE_WEBSOCKETS_CONNECTION_DATA_ERROR", + data: item + }); + } + } +} diff --git a/packages/api-websockets/src/registry/abstractions/IWebsocketsConnectionRegistry.ts b/packages/api-websockets/src/registry/abstractions/IWebsocketsConnectionRegistry.ts new file mode 100644 index 00000000000..0ac7169e0db --- /dev/null +++ b/packages/api-websockets/src/registry/abstractions/IWebsocketsConnectionRegistry.ts @@ -0,0 +1,39 @@ +import { IWebsocketsIdentity } from "~/context/abstractions/IWebsocketsContext"; + +export interface IWebsocketsConnectionRegistryData { + connectionId: string; + identity: IWebsocketsIdentity; + tenant: string; + locale: string; + connectedOn: string; + domainName: string; + stage: string; +} + +export interface IWebsocketsConnectionRegistryRegisterParams { + connectionId: string; + tenant: string; + locale: string; + identity: IWebsocketsIdentity; + domainName: string; + stage: string; + /** + * A DateTime.toISOString() format. + */ + connectedOn: string; +} + +export interface IWebsocketsConnectionRegistryUnregisterParams { + connectionId: string; +} + +export interface IWebsocketsConnectionRegistry { + register( + event: IWebsocketsConnectionRegistryRegisterParams + ): Promise; + unregister(event: IWebsocketsConnectionRegistryUnregisterParams): Promise; + + listViaIdentity(identity: string): Promise; + listViaTenant(tenant: string, locale?: string): Promise; + listAll(): Promise; +} diff --git a/packages/api-websockets/src/registry/entity.ts b/packages/api-websockets/src/registry/entity.ts new file mode 100644 index 00000000000..0ebfb427925 --- /dev/null +++ b/packages/api-websockets/src/registry/entity.ts @@ -0,0 +1,57 @@ +import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; + +const name = "SocketsConnectionRegistry"; + +export const createEntity = (documentClient: DynamoDBDocument) => { + const table = new Table({ + name: String(process.env.DB_TABLE), + partitionKey: "PK", + sortKey: "SK", + DocumentClient: documentClient, + indexes: { + GSI1: { + partitionKey: "GSI1_PK", + sortKey: "GSI1_SK" + }, + GSI2: { + partitionKey: "GSI2_PK", + sortKey: "GSI2_SK" + } + }, + autoExecute: true, + autoParse: true + }); + + return new Entity({ + name, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + GSI1_PK: { + type: "string" + }, + GSI1_SK: { + type: "string" + }, + GSI2_PK: { + type: "string" + }, + GSI2_SK: { + type: "string" + }, + TYPE: { + type: "string", + default: name + }, + data: { + type: "map" + } + } + }); +}; diff --git a/packages/api-websockets/src/registry/index.ts b/packages/api-websockets/src/registry/index.ts new file mode 100644 index 00000000000..67e68fe3dfd --- /dev/null +++ b/packages/api-websockets/src/registry/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/IWebsocketsConnectionRegistry"; +export * from "./WebsocketsConnectionRegistry"; diff --git a/packages/api-websockets/src/response/WebsocketsResponse.ts b/packages/api-websockets/src/response/WebsocketsResponse.ts new file mode 100644 index 00000000000..3ee94f55ae4 --- /dev/null +++ b/packages/api-websockets/src/response/WebsocketsResponse.ts @@ -0,0 +1,29 @@ +import { + IWebsocketsResponse, + IWebsocketsResponseErrorParams, + IWebsocketsResponseErrorResult, + IWebsocketsResponseOkParams, + IWebsocketsResponseOkResult +} from "./abstractions/IWebsocketsResponse"; + +export class WebsocketsResponse implements IWebsocketsResponse { + public ok(params?: IWebsocketsResponseOkParams): IWebsocketsResponseOkResult { + return { + statusCode: 200, + ...params + }; + } + + public error(params: IWebsocketsResponseErrorParams): IWebsocketsResponseErrorResult { + return { + ...params, + statusCode: params.statusCode || 200, + error: { + ...params.error, + message: params.error?.message || params.message, + code: params.error?.code || "UNKNOWN_ERROR", + data: params.error?.data || {} + } + }; + } +} diff --git a/packages/api-websockets/src/response/abstractions/IWebsocketsResponse.ts b/packages/api-websockets/src/response/abstractions/IWebsocketsResponse.ts new file mode 100644 index 00000000000..834e667e006 --- /dev/null +++ b/packages/api-websockets/src/response/abstractions/IWebsocketsResponse.ts @@ -0,0 +1,36 @@ +import { GenericRecord } from "@webiny/api/types"; + +export interface IWebsocketsResponseOkParams { + message?: string; + data?: GenericRecord; +} + +export interface IWebsocketsResponseOkResult { + statusCode: number; + data?: GenericRecord; + message?: string; +} + +export interface IWebsocketsResponseErrorParams { + statusCode?: number; + error?: Omit & + Partial>; + message: string; +} + +export interface IWebsocketsResponseErrorResultError { + message: string; + code: string; + data: GenericRecord; + stack?: string; +} + +export interface IWebsocketsResponseErrorResult { + statusCode: number; + error: IWebsocketsResponseErrorResultError; +} + +export interface IWebsocketsResponse { + ok(params?: IWebsocketsResponseOkParams): IWebsocketsResponseOkResult; + error(params: IWebsocketsResponseErrorParams): IWebsocketsResponseErrorResult; +} diff --git a/packages/api-websockets/src/response/index.ts b/packages/api-websockets/src/response/index.ts new file mode 100644 index 00000000000..f55884357a0 --- /dev/null +++ b/packages/api-websockets/src/response/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/IWebsocketsResponse"; +export * from "./WebsocketsResponse"; diff --git a/packages/api-websockets/src/runner/WebsocketsRunner.ts b/packages/api-websockets/src/runner/WebsocketsRunner.ts new file mode 100644 index 00000000000..c3caa281ad7 --- /dev/null +++ b/packages/api-websockets/src/runner/WebsocketsRunner.ts @@ -0,0 +1,235 @@ +import WebinyError from "@webiny/error"; +import { + IWebsocketsEvent, + IWebsocketsEventData, + IWebsocketsEventRequestContext, + IWebsocketsIncomingEvent, + WebsocketsEventRequestContextEventType, + WebsocketsEventRoute +} from "~/handler/types"; +import { Context } from "~/types"; +import { IWebsocketsEventValidator } from "~/validator"; +import { IWebsocketsRunner, IWebsocketsRunnerResponse } from "./abstractions/IWebsocketsRunner"; +import { IWebsocketsRoutePluginCallableParams, WebsocketsRoutePlugin } from "~/plugins"; +import { middleware } from "~/utils/middleware"; +import { IWebsocketsConnectionRegistry } from "~/registry"; +import { + IWebsocketsResponse, + IWebsocketsResponseErrorResult, + IWebsocketsResponseOkResult +} from "~/response"; +import { IWebsocketsTransportSendConnection } from "~/transport"; +import { IWebsocketsIdentity } from "~/context"; + +type MiddlewareParams = Pick< + IWebsocketsRoutePluginCallableParams, + "context" | "event" | "registry" +>; + +interface IWebsocketsRunnerRespondParams + extends Pick< + IWebsocketsEventRequestContext, + "connectionId" | "domainName" | "stage" | "eventType" + > { + messageId?: string; + result: IWebsocketsResponseOkResult | IWebsocketsResponseErrorResult; +} + +export class WebsocketsRunner implements IWebsocketsRunner { + private readonly context: Context; + private readonly registry: IWebsocketsConnectionRegistry; + private readonly validator: IWebsocketsEventValidator; + private readonly response: IWebsocketsResponse; + + public constructor( + context: Context, + registry: IWebsocketsConnectionRegistry, + validator: IWebsocketsEventValidator, + response: IWebsocketsResponse + ) { + this.context = context; + this.registry = registry; + this.validator = validator; + this.response = response; + } + + public async run( + input: IWebsocketsIncomingEvent + ): Promise { + let event: IWebsocketsEvent | undefined; + try { + event = await this.validator.validate(input); + } catch (ex) { + const result = this.response.error({ + message: "Validation failed.", + error: { + message: ex.message, + code: ex.code, + data: ex.data, + stack: ex.stack + } + }); + + const { connectionId, domainName, stage, eventType } = input.requestContext || {}; + let messageId: string | undefined; + try { + const body = + typeof input.body === "string" ? input.body : JSON.stringify(input.body || {}); + const json = JSON.parse(body); + messageId = json.messageId; + } catch { + // Do nothing + } + if (!connectionId || !stage || !domainName || !eventType) { + return result; + } + + await this.respond({ + connectionId, + domainName, + stage, + eventType, + messageId, + result + }); + return result; + } + + let result: IWebsocketsResponseOkResult | IWebsocketsResponseErrorResult; + try { + result = await this.executeRoute(event); + } catch (ex) { + result = this.response.error({ + message: `Route "${event.requestContext.routeKey}" action failed.`, + error: { + message: ex.message, + code: ex.code, + data: ex.data, + stack: ex.stack + } + }); + } + try { + await this.respond({ + ...event.requestContext, + messageId: event.body?.messageId, + result + }); + return result; + } catch (ex) { + return this.response.error({ + message: "Failed to respond to the request.", + error: { + message: ex.message, + code: ex.code, + data: { + ...ex.data, + result + }, + stack: ex.stack + } + }); + } + } + + private getRoutePlugins(route: WebsocketsEventRoute | string): WebsocketsRoutePlugin[] { + const plugins = this.context.plugins + .byType(WebsocketsRoutePlugin.type) + .filter(plugin => { + return plugin.route === route; + }); + if (plugins.length === 0) { + throw new WebinyError( + `There are no plugins for the route: ${route}.`, + "NO_ROUTE_PLUGINS", + { + route + } + ); + } + return plugins; + } + + private async executeRoute(event: IWebsocketsEvent): Promise { + /** + * We will always fetch plugins in reverse order, so that users can override our default ones if necessary. + */ + const plugins = this.getRoutePlugins(event.requestContext.routeKey).reverse(); + + const getTenant = () => { + const tenant = this.context.tenancy.getCurrentTenant(); + return tenant?.id || null; + }; + const getLocale = (): string | null => { + const locale = this.context.i18n.getCurrentLocale("content"); + return locale?.code || null; + }; + + const getIdentity = (): IWebsocketsIdentity | null => { + const identity = this.context.security.getIdentity(); + return identity || null; + }; + + const action = middleware( + plugins.map(plugin => { + return async (params, next) => { + return plugin.run({ + registry: params.registry, + event: params.event, + context: params.context, + getTenant, + getLocale, + getIdentity, + response: this.response, + next + }); + }; + }) + ); + + const result = await action({ + event, + registry: this.registry, + context: this.context + }); + if (result) { + return result; + } + const message = "No response from the route action."; + return this.response.error({ + message, + error: { + message, + code: "NO_RESPONSE" + }, + statusCode: 404 + }); + } + + private async respond(params: IWebsocketsRunnerRespondParams): Promise { + const { connectionId, domainName, stage, eventType, result, messageId } = params; + if (eventType !== WebsocketsEventRequestContextEventType.message) { + return; + } else if (!connectionId || !domainName || !stage) { + const message = "No connectionId, domainName or stage."; + const data = { + connectionId, + domainName, + stage + }; + console.error(message, JSON.stringify(data)); + throw new WebinyError(message, "GENERAL_ERROR", data); + } + const connection: IWebsocketsTransportSendConnection = { + connectionId, + domainName, + stage + }; + + const dataToSend = { + ...result, + messageId + }; + await this.context.websockets.sendToConnections([connection], dataToSend); + } +} diff --git a/packages/api-websockets/src/runner/abstractions/IWebsocketsRunner.ts b/packages/api-websockets/src/runner/abstractions/IWebsocketsRunner.ts new file mode 100644 index 00000000000..051bd531140 --- /dev/null +++ b/packages/api-websockets/src/runner/abstractions/IWebsocketsRunner.ts @@ -0,0 +1,18 @@ +import { IWebsocketsIncomingEvent } from "~/handler/types"; +import { GenericRecord } from "@webiny/api/types"; + +export interface IWebsocketsResponseError { + message: string; + code: string; + data?: GenericRecord | null; + stack?: string; +} +export interface IWebsocketsRunnerResponse { + statusCode: number; + message?: string; + error?: IWebsocketsResponseError; +} + +export interface IWebsocketsRunner { + run(params: IWebsocketsIncomingEvent): Promise; +} diff --git a/packages/api-websockets/src/runner/index.ts b/packages/api-websockets/src/runner/index.ts new file mode 100644 index 00000000000..fd67c545a5a --- /dev/null +++ b/packages/api-websockets/src/runner/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/IWebsocketsRunner"; +export * from "./WebsocketsRunner"; diff --git a/packages/api-websockets/src/runner/routes/connect.ts b/packages/api-websockets/src/runner/routes/connect.ts new file mode 100644 index 00000000000..1ab54acefaa --- /dev/null +++ b/packages/api-websockets/src/runner/routes/connect.ts @@ -0,0 +1,46 @@ +import { WebsocketsEventRoute } from "~/handler/types"; +import { createWebsocketsRoutePlugin } from "~/plugins/WebsocketsRoutePlugin"; + +const getConnectedOn = (connectedAt?: number) => { + if (!connectedAt) { + return new Date().toISOString(); + } + return new Date(connectedAt).toISOString(); +}; + +export const createWebsocketsRouteConnectPlugin = () => { + const plugin = createWebsocketsRoutePlugin(WebsocketsEventRoute.connect, async params => { + const { registry, event, response, getTenant, getLocale, getIdentity } = params; + + const tenant = getTenant(); + const locale = getLocale(); + const identity = getIdentity(); + if (!tenant) { + return response.error({ + message: "Missing tenant." + }); + } else if (!locale) { + return response.error({ + message: "Missing locale." + }); + } else if (!identity) { + return response.error({ + message: "Missing identity." + }); + } + + await registry.register({ + identity, + connectionId: event.requestContext.connectionId, + tenant, + locale, + domainName: event.requestContext.domainName, + stage: event.requestContext.stage, + connectedOn: getConnectedOn(event.requestContext.connectedAt) + }); + + return response.ok(); + }); + plugin.name = "websockets.route.connect.default"; + return plugin; +}; diff --git a/packages/api-websockets/src/runner/routes/default.ts b/packages/api-websockets/src/runner/routes/default.ts new file mode 100644 index 00000000000..b21eb69a7e2 --- /dev/null +++ b/packages/api-websockets/src/runner/routes/default.ts @@ -0,0 +1,29 @@ +import { WebsocketsEventRoute } from "~/handler/types"; +import { createWebsocketsRoutePlugin } from "~/plugins/WebsocketsRoutePlugin"; + +export const createWebsocketsRouteDefaultPlugin = () => { + const plugin = createWebsocketsRoutePlugin(WebsocketsEventRoute.default, async params => { + const { response, getIdentity, getLocale, getTenant } = params; + const tenant = getTenant(); + const locale = getLocale(); + const identity = getIdentity(); + if (!tenant) { + return response.error({ + message: "Missing tenant." + }); + } else if (!locale) { + return response.error({ + message: "Missing locale." + }); + } else if (!identity) { + return response.error({ + message: "Missing identity." + }); + } + + return response.ok(); + }); + + plugin.name = "websockets.route.default.default"; + return plugin; +}; diff --git a/packages/api-websockets/src/runner/routes/disconnect.ts b/packages/api-websockets/src/runner/routes/disconnect.ts new file mode 100644 index 00000000000..e42674d5c75 --- /dev/null +++ b/packages/api-websockets/src/runner/routes/disconnect.ts @@ -0,0 +1,15 @@ +import { WebsocketsEventRoute } from "~/handler/types"; +import { createWebsocketsRoutePlugin } from "~/plugins/WebsocketsRoutePlugin"; + +export const createWebsocketsRouteDisconnectPlugin = () => { + const plugin = createWebsocketsRoutePlugin(WebsocketsEventRoute.disconnect, async params => { + const { registry, event, response } = params; + await registry.unregister({ + connectionId: event.requestContext.connectionId + }); + + return response.ok(); + }); + plugin.name = "websockets.route.disconnect.default"; + return plugin; +}; diff --git a/packages/api-websockets/src/runner/routes/index.ts b/packages/api-websockets/src/runner/routes/index.ts new file mode 100644 index 00000000000..97ff6c9a4a5 --- /dev/null +++ b/packages/api-websockets/src/runner/routes/index.ts @@ -0,0 +1,11 @@ +import { createWebsocketsRouteConnectPlugin } from "./connect"; +import { createWebsocketsRouteDefaultPlugin } from "./default"; +import { createWebsocketsRouteDisconnectPlugin } from "./disconnect"; + +export const createWebsocketsRoutePlugins = () => { + return [ + createWebsocketsRouteConnectPlugin(), + createWebsocketsRouteDisconnectPlugin(), + createWebsocketsRouteDefaultPlugin() + ]; +}; diff --git a/packages/api-websockets/src/transport/WebsocketsTransport.ts b/packages/api-websockets/src/transport/WebsocketsTransport.ts new file mode 100644 index 00000000000..79235d2a6d1 --- /dev/null +++ b/packages/api-websockets/src/transport/WebsocketsTransport.ts @@ -0,0 +1,51 @@ +import { + ApiGatewayManagementApiClient, + PostToConnectionCommand +} from "@webiny/aws-sdk/client-apigatewaymanagementapi"; +import { + IWebsocketsTransport, + IWebsocketsTransportSendConnection, + IWebsocketsTransportSendData +} from "./abstractions/IWebsocketsTransport"; +import { GenericRecord } from "@webiny/api/types"; + +export class WebsocketsTransport implements IWebsocketsTransport { + private readonly clients = new Map(); + + public async send( + connections: IWebsocketsTransportSendConnection[], + data: IWebsocketsTransportSendData + ): Promise { + for (const connection of connections) { + try { + const client = this.getClient(connection); + + const command = new PostToConnectionCommand({ + ConnectionId: connection.connectionId, + Data: JSON.stringify(data) + }); + await client.send(command); + } catch (ex) { + console.log( + `Failed to send message to connection "${connection.connectionId}". Check logs for more information.` + ); + console.log(ex); + } + } + } + + private getClient( + connection: IWebsocketsTransportSendConnection + ): ApiGatewayManagementApiClient { + const endpoint = `https://${connection.domainName}/${connection.stage}`; + const client = this.clients.get(endpoint); + if (client) { + return client; + } + const newClient = new ApiGatewayManagementApiClient({ + endpoint + }); + this.clients.set(endpoint, newClient); + return newClient; + } +} diff --git a/packages/api-websockets/src/transport/abstractions/IWebsocketsTransport.ts b/packages/api-websockets/src/transport/abstractions/IWebsocketsTransport.ts new file mode 100644 index 00000000000..36623194f99 --- /dev/null +++ b/packages/api-websockets/src/transport/abstractions/IWebsocketsTransport.ts @@ -0,0 +1,28 @@ +import { GenericRecord } from "@webiny/api/types"; +import { IWebsocketsConnectionRegistryData } from "~/registry"; + +export interface IWebsocketsTransportSendDataError { + message: string; + code: string; + data?: GenericRecord; + stack?: string; +} + +export interface IWebsocketsTransportSendData { + messageId?: string; + action?: string; + data?: T; + error?: IWebsocketsTransportSendDataError; +} + +export type IWebsocketsTransportSendConnection = Pick< + IWebsocketsConnectionRegistryData, + "connectionId" | "domainName" | "stage" +>; + +export interface IWebsocketsTransport { + send( + connections: IWebsocketsTransportSendConnection[], + data: IWebsocketsTransportSendData + ): Promise; +} diff --git a/packages/api-websockets/src/transport/index.ts b/packages/api-websockets/src/transport/index.ts new file mode 100644 index 00000000000..6d9b0fd6d4b --- /dev/null +++ b/packages/api-websockets/src/transport/index.ts @@ -0,0 +1,2 @@ +export * from "./WebsocketsTransport"; +export * from "./abstractions/IWebsocketsTransport"; diff --git a/packages/api-websockets/src/types.ts b/packages/api-websockets/src/types.ts new file mode 100644 index 00000000000..8c4b59b64b6 --- /dev/null +++ b/packages/api-websockets/src/types.ts @@ -0,0 +1,8 @@ +import { DbContext } from "@webiny/handler-db/types"; +import { IWebsocketsContext } from "./context/abstractions/IWebsocketsContext"; +import { SecurityContext } from "@webiny/api-security/types"; +import { I18NContext } from "@webiny/api-i18n/types"; + +export interface Context extends DbContext, SecurityContext, I18NContext { + websockets: IWebsocketsContext; +} diff --git a/packages/api-websockets/src/utils/middleware.ts b/packages/api-websockets/src/utils/middleware.ts new file mode 100644 index 00000000000..8ce1262d2d7 --- /dev/null +++ b/packages/api-websockets/src/utils/middleware.ts @@ -0,0 +1,30 @@ +import { GenericRecord } from "@webiny/api/types"; + +export interface MiddlewareCallable< + I extends GenericRecord = GenericRecord, + O extends GenericRecord = GenericRecord +> { + (input: I, next: () => Promise): Promise; +} + +export const middleware = < + I extends GenericRecord = GenericRecord, + O extends GenericRecord = GenericRecord +>( + functions: MiddlewareCallable[] +) => { + return async (input: I): Promise => { + const chain = Array.from(functions); + const exec = async (): Promise => { + const fn = chain.shift(); + if (!fn) { + return {} as O; + } + const next = async () => { + return exec(); + }; + return fn(input, next); + }; + return exec(); + }; +}; diff --git a/packages/api-websockets/src/validator/WebsocketsEventValidator.ts b/packages/api-websockets/src/validator/WebsocketsEventValidator.ts new file mode 100644 index 00000000000..4da320aea07 --- /dev/null +++ b/packages/api-websockets/src/validator/WebsocketsEventValidator.ts @@ -0,0 +1,73 @@ +import zod from "zod"; +import { + IWebsocketsEvent, + IWebsocketsEventData, + WebsocketsEventRequestContextEventType +} from "~/handler/types"; +import { + IWebsocketsEventValidator, + IWebsocketsEventValidatorValidateParams +} from "./abstractions/IWebsocketsEventValidator"; +import { createZodError } from "@webiny/utils"; + +const validation = zod.object({ + headers: zod.object({}).passthrough().optional(), + requestContext: zod.object({ + connectionId: zod.string(), + stage: zod.string(), + connectedAt: zod.number(), + domainName: zod.string(), + eventType: zod.enum([ + WebsocketsEventRequestContextEventType.connect, + WebsocketsEventRequestContextEventType.message, + WebsocketsEventRequestContextEventType.disconnect + ]), + routeKey: zod.string() + }), + body: zod + .string() + .transform(value => { + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch { + return value; + } + }) + .optional() +}); + +const bodyValidation = zod + .object({ + token: zod.string(), + tenant: zod.string(), + locale: zod.string(), + messageId: zod.string().nullish(), + action: zod.string(), + data: zod.object({}).passthrough().nullish() + }) + .passthrough() + .optional(); + +export class WebsocketsEventValidator implements IWebsocketsEventValidator { + public async validate( + input: IWebsocketsEventValidatorValidateParams + ): Promise> { + const result = await validation.safeParseAsync(input); + if (!result.success) { + throw createZodError(result.error); + } + const bodyResult = await bodyValidation.safeParseAsync(result.data.body); + if (!bodyResult.success) { + throw createZodError(bodyResult.error); + } + return { + ...result.data, + body: { + ...((bodyResult.data || {}) as T) + } + }; + } +} diff --git a/packages/api-websockets/src/validator/abstractions/IWebsocketsEventValidator.ts b/packages/api-websockets/src/validator/abstractions/IWebsocketsEventValidator.ts new file mode 100644 index 00000000000..73e3dafad63 --- /dev/null +++ b/packages/api-websockets/src/validator/abstractions/IWebsocketsEventValidator.ts @@ -0,0 +1,12 @@ +import { IWebsocketsEvent, IWebsocketsEventData, IWebsocketsIncomingEvent } from "~/handler/types"; + +export type IWebsocketsEventValidatorValidateParams = IWebsocketsIncomingEvent; + +export interface IWebsocketsEventValidator { + /** + * @throws {Error} + */ + validate( + params: IWebsocketsEventValidatorValidateParams + ): Promise>; +} diff --git a/packages/api-websockets/src/validator/index.ts b/packages/api-websockets/src/validator/index.ts new file mode 100644 index 00000000000..535856a2404 --- /dev/null +++ b/packages/api-websockets/src/validator/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/IWebsocketsEventValidator"; +export * from "./WebsocketsEventValidator"; diff --git a/packages/api-websockets/tsconfig.build.json b/packages/api-websockets/tsconfig.build.json new file mode 100644 index 00000000000..7d249370c2b --- /dev/null +++ b/packages/api-websockets/tsconfig.build.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api/tsconfig.build.json" }, + { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-security/tsconfig.build.json" }, + { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../aws-sdk/tsconfig.build.json" }, + { "path": "../db-dynamodb/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../api-wcp/tsconfig.build.json" }, + { "path": "../handler-db/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-websockets/tsconfig.json b/packages/api-websockets/tsconfig.json new file mode 100644 index 00000000000..b2ec93859a7 --- /dev/null +++ b/packages/api-websockets/tsconfig.json @@ -0,0 +1,61 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api" }, + { "path": "../api-i18n" }, + { "path": "../api-security" }, + { "path": "../api-tenancy" }, + { "path": "../aws-sdk" }, + { "path": "../db-dynamodb" }, + { "path": "../error" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../plugins" }, + { "path": "../utils" }, + { "path": "../api-headless-cms" }, + { "path": "../api-wcp" }, + { "path": "../handler-db" }, + { "path": "../handler-graphql" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], + "@webiny/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-security/*": ["../api-security/src/*"], + "@webiny/api-security": ["../api-security/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], + "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], + "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/api-wcp/*": ["../api-wcp/src/*"], + "@webiny/api-wcp": ["../api-wcp/src"], + "@webiny/handler-db/*": ["../handler-db/src/*"], + "@webiny/handler-db": ["../handler-db/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-websockets/webiny.config.js b/packages/api-websockets/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-websockets/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api/src/ServiceDiscovery.ts b/packages/api/src/ServiceDiscovery.ts index 0703fe78b5d..4c5231d721f 100644 --- a/packages/api/src/ServiceDiscovery.ts +++ b/packages/api/src/ServiceDiscovery.ts @@ -4,13 +4,14 @@ import { QueryCommand, unmarshall } from "@webiny/aws-sdk/client-dynamodb"; +import { GenericRecord } from "~/types"; interface ServiceManifest { name: string; manifest: Manifest; } -type Manifest = Record; +type Manifest = GenericRecord; class ServiceManifestLoader { private client: DynamoDBDocument | undefined; diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 75793cf2dca..ad905e5d1d7 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,8 +1,10 @@ import { PluginsContainer } from "@webiny/plugins"; -export interface BenchmarkRuns { - [key: string]: number; -} +export type GenericRecordKey = string | number | symbol; + +export type GenericRecord = Record; + +export type BenchmarkRuns = GenericRecord; export interface BenchmarkMeasurement { name: string; diff --git a/packages/app-i18n/src/index.ts b/packages/app-i18n/src/index.ts index e52f3a6ad88..bd9f25560cd 100644 --- a/packages/app-i18n/src/index.ts +++ b/packages/app-i18n/src/index.ts @@ -1 +1,2 @@ export * from "./I18N"; +export * from "./hooks/useI18N"; diff --git a/packages/app-serverless-cms/package.json b/packages/app-serverless-cms/package.json index f1d17f628a1..d0678529f79 100644 --- a/packages/app-serverless-cms/package.json +++ b/packages/app-serverless-cms/package.json @@ -30,6 +30,7 @@ "@webiny/app-security-access-management": "0.0.0", "@webiny/app-tenancy": "0.0.0", "@webiny/app-tenant-manager": "0.0.0", + "@webiny/app-websockets": "0.0.0", "@webiny/lexical-editor-actions": "0.0.0", "@webiny/lexical-editor-pb-element": "0.0.0", "@webiny/plugins": "0.0.0", diff --git a/packages/app-serverless-cms/src/Admin.tsx b/packages/app-serverless-cms/src/Admin.tsx index ca5d76514d1..8cbea87c4c5 100644 --- a/packages/app-serverless-cms/src/Admin.tsx +++ b/packages/app-serverless-cms/src/Admin.tsx @@ -29,6 +29,7 @@ import { LexicalEditorPlugin } from "@webiny/lexical-editor-pb-element"; import { LexicalEditorActions } from "@webiny/lexical-editor-actions"; import { Module as MailerSettings } from "@webiny/app-mailer"; import { Folders } from "@webiny/app-aco"; +import { Websockets } from "@webiny/app-websockets"; export interface AdminProps extends Omit { createApolloClient?: BaseAdminProps["createApolloClient"]; @@ -53,6 +54,7 @@ const App = (props: AdminProps) => { + diff --git a/packages/app-serverless-cms/tsconfig.build.json b/packages/app-serverless-cms/tsconfig.build.json index 97db76ad93e..9bbaf9bc7bb 100644 --- a/packages/app-serverless-cms/tsconfig.build.json +++ b/packages/app-serverless-cms/tsconfig.build.json @@ -21,6 +21,7 @@ { "path": "../app-security-access-management/tsconfig.build.json" }, { "path": "../app-tenancy/tsconfig.build.json" }, { "path": "../app-tenant-manager/tsconfig.build.json" }, + { "path": "../app-websockets/tsconfig.build.json" }, { "path": "../lexical-editor-actions/tsconfig.build.json" }, { "path": "../lexical-editor-pb-element/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" } diff --git a/packages/app-serverless-cms/tsconfig.json b/packages/app-serverless-cms/tsconfig.json index 2de5a5078c7..5526e481696 100644 --- a/packages/app-serverless-cms/tsconfig.json +++ b/packages/app-serverless-cms/tsconfig.json @@ -21,6 +21,7 @@ { "path": "../app-security-access-management" }, { "path": "../app-tenancy" }, { "path": "../app-tenant-manager" }, + { "path": "../app-websockets" }, { "path": "../lexical-editor-actions" }, { "path": "../lexical-editor-pb-element" }, { "path": "../plugins" } @@ -70,6 +71,8 @@ "@webiny/app-tenancy": ["../app-tenancy/src"], "@webiny/app-tenant-manager/*": ["../app-tenant-manager/src/*"], "@webiny/app-tenant-manager": ["../app-tenant-manager/src"], + "@webiny/app-websockets/*": ["../app-websockets/src/*"], + "@webiny/app-websockets": ["../app-websockets/src"], "@webiny/lexical-editor-actions/*": ["../lexical-editor-actions/src/*"], "@webiny/lexical-editor-actions": ["../lexical-editor-actions/src"], "@webiny/lexical-editor-pb-element/*": ["../lexical-editor-pb-element/src/*"], diff --git a/packages/app-websockets/.babelrc.js b/packages/app-websockets/.babelrc.js new file mode 100644 index 00000000000..bec58b263bd --- /dev/null +++ b/packages/app-websockets/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForReact({ path: __dirname }); diff --git a/packages/app-websockets/LICENSE b/packages/app-websockets/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/app-websockets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/app-websockets/README.md b/packages/app-websockets/README.md new file mode 100644 index 00000000000..49e15ea265b --- /dev/null +++ b/packages/app-websockets/README.md @@ -0,0 +1,12 @@ +# @webiny/app-websockets +[![](https://img.shields.io/npm/dw/@webiny/app-websockets.svg)](https://www.npmjs.com/package/@webiny/app-websockets) +[![](https://img.shields.io/npm/v/@webiny/app-websockets.svg)](https://www.npmjs.com/package/@webiny/app-websockets) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +Exposes a simple `SocketsProvider` React provider component and enables you to quickly send socket messages via the `useSockets` React hook. + +## Install +``` +yarn add @webiny/app-websockets +``` diff --git a/packages/app-websockets/package.json b/packages/app-websockets/package.json new file mode 100644 index 00000000000..30440c15c94 --- /dev/null +++ b/packages/app-websockets/package.json @@ -0,0 +1,45 @@ +{ + "name": "@webiny/app-websockets", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "contributors": [ + "Pavel Denisjuk ", + "Sven Al Hamad ", + "Adrian Smijulj " + ], + "license": "MIT", + "dependencies": { + "@aws-amplify/auth": "^5.1.9", + "@webiny/app": "0.0.0", + "@webiny/app-i18n": "0.0.0", + "@webiny/app-tenancy": "0.0.0", + "@webiny/utils": "0.0.0", + "react": "17.0.2", + "react-dom": "17.0.2" + }, + "devDependencies": { + "@babel/cli": "^7.23.9", + "@babel/core": "^7.23.9", + "@babel/preset-env": "^7.23.9", + "@babel/preset-react": "^7.23.3", + "@babel/preset-typescript": "^7.23.3", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.12", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f" +} diff --git a/packages/app-websockets/src/WebsocketsProvider.tsx b/packages/app-websockets/src/WebsocketsProvider.tsx new file mode 100644 index 00000000000..d00d807a628 --- /dev/null +++ b/packages/app-websockets/src/WebsocketsProvider.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTenancy } from "@webiny/app-tenancy"; +import { useI18N } from "@webiny/app-i18n"; +import { getToken } from "./utils/getToken"; +import { getUrl } from "./utils/getUrl"; +import { IncomingGenericData, IWebsocketsContext, IWebsocketsContextSendCallable } from "~/types"; +import { + createWebsocketsAction, + createWebsocketsActions, + createWebsocketsBlackHoleManager, + createWebsocketsConnection, + createWebsocketsManager, + createWebsocketsSubscriptionManager +} from "./domain"; +import { IGenericData, IWebsocketsManager } from "./domain/types"; + +export interface IWebsocketsProviderProps { + children: React.ReactNode; +} + +export const WebsocketsContext = React.createContext( + {} as unknown as IWebsocketsContext +); + +interface ICurrentData { + tenant?: string; + locale?: string; +} + +export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { + const { tenant } = useTenancy(); + const { getCurrentLocale } = useI18N(); + const locale = getCurrentLocale("default"); + + const socketsRef = useRef(createWebsocketsBlackHoleManager()); + + const [current, setCurrent] = useState({}); + + const subscriptionManager = useMemo(() => { + return createWebsocketsSubscriptionManager(); + }, []); + + useEffect(() => { + (async () => { + const token = await getToken(); + if (!token || !tenant || !locale) { + return; + } else if (current.tenant === tenant && current.locale === locale) { + return; + } else if (socketsRef.current) { + socketsRef.current.close(); + } + const url = getUrl({ tenant, locale, token }); + + if (!url) { + console.error("Not possible to connect to the websocket without a valid URL.", { + tenant, + locale, + token + }); + return; + } + + setCurrent({ + tenant, + locale + }); + + socketsRef.current = createWebsocketsManager( + createWebsocketsConnection({ + subscriptionManager, + url, + protocol: ["webiny-ws-v1"] + }) + ); + socketsRef.current.connect(); + })(); + }, [tenant, locale, subscriptionManager]); + + const websocketActions = useMemo(() => { + return createWebsocketsActions({ + manager: socketsRef.current, + tenant, + locale, + getToken + }); + }, [socketsRef.current, tenant, locale]); + + const send = useCallback( + async (action, data, timeout) => { + return websocketActions.run({ + action, + data, + timeout + }); + }, + [websocketActions] + ); + + const createAction = useCallback( + ( + name: string + ) => { + return createWebsocketsAction(websocketActions, name); + }, + [websocketActions] + ); + + const onMessage = useCallback( + ( + action: string, + cb: (data: T) => void + ) => { + return socketsRef.current.onMessage(async event => { + if (event.data.action !== action) { + return; + } + cb(event.data); + }); + }, + [socketsRef.current] + ); + + // TODO remove when finished with development + (window as any).webinySockets = socketsRef.current; + (window as any).send = send; + (window as any).createAction = createAction; + (window as any).onMessage = onMessage; + + const value: IWebsocketsContext = { + send, + createAction, + onMessage + }; + return ; +}; diff --git a/packages/app-websockets/src/domain/BlackHoleWebsocketsManager.ts b/packages/app-websockets/src/domain/BlackHoleWebsocketsManager.ts new file mode 100644 index 00000000000..dc172fdcde7 --- /dev/null +++ b/packages/app-websockets/src/domain/BlackHoleWebsocketsManager.ts @@ -0,0 +1,53 @@ +import { IWebsocketsManager, IWebsocketManagerSendData } from "./abstractions/IWebsocketsManager"; +import { IWebsocketsSubscription } from "./abstractions/IWebsocketsSubscriptionManager"; +import { + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, + IWebsocketsManagerMessageEvent, + IWebsocketsManagerOpenEvent +} from "./types"; +import { WebsocketsSubscriptionManager } from "./WebsocketsSubscriptionManager"; + +export class BlackHoleWebsocketsManager implements IWebsocketsManager { + private readonly subscriptions = new WebsocketsSubscriptionManager(); + + public connect(): void { + return; + } + + public close(): void { + return; + } + + public send(data: T): void { + console.log("BlackHoleWebsocketManager.send", data); + } + + public onOpen(): IWebsocketsSubscription { + return this.subscriptions.onOpen(async () => { + return; + }); + } + + public onClose(): IWebsocketsSubscription { + return this.subscriptions.onClose(async () => { + return; + }); + } + + public onError(): IWebsocketsSubscription { + return this.subscriptions.onError(async () => { + return; + }); + } + + public onMessage(): IWebsocketsSubscription { + return this.subscriptions.onMessage(async () => { + return; + }); + } +} + +export const createWebsocketsBlackHoleManager = (): IWebsocketsManager => { + return new BlackHoleWebsocketsManager(); +}; diff --git a/packages/app-websockets/src/domain/WebsocketsAction.ts b/packages/app-websockets/src/domain/WebsocketsAction.ts new file mode 100644 index 00000000000..820edc72cc7 --- /dev/null +++ b/packages/app-websockets/src/domain/WebsocketsAction.ts @@ -0,0 +1,45 @@ +import { + IGenericData, + IWebsocketsAction, + IWebsocketsActions, + IWebsocketsActionsTriggerParams +} from "./types"; + +export class WebsocketsAction< + T extends IGenericData = IGenericData, + R extends IGenericData = IGenericData +> implements IWebsocketsAction +{ + private readonly actions: IWebsocketsActions; + private readonly name: string; + + public constructor(actions: IWebsocketsActions, name: string) { + this.name = name; + this.actions = actions; + } + + public async trigger(params?: IWebsocketsActionsTriggerParams): Promise { + const { data, onResponse, timeout = 10000 } = params || {}; + const promise = this.actions.run({ + action: this.name, + data, + timeout: onResponse && timeout > 0 ? timeout : undefined + }); + if (!onResponse) { + return null; + } + const result = await promise; + + return onResponse(result); + } +} + +export const createWebsocketsAction = < + T extends IGenericData = IGenericData, + R extends IGenericData = IGenericData +>( + actions: IWebsocketsActions, + name: string +): IWebsocketsAction => { + return new WebsocketsAction(actions, name); +}; diff --git a/packages/app-websockets/src/domain/WebsocketsActions.ts b/packages/app-websockets/src/domain/WebsocketsActions.ts new file mode 100644 index 00000000000..30232b6da93 --- /dev/null +++ b/packages/app-websockets/src/domain/WebsocketsActions.ts @@ -0,0 +1,105 @@ +import { + IGenericData, + IWebsocketsActions, + IWebsocketsActionsRunParams, + IWebsocketsManager, + IWebsocketManagerSendData +} from "./types"; + +export interface IWebsocketActionsParams { + manager: IWebsocketsManager; + tenant: string | null; + locale: string | null; + getToken: () => Promise; +} + +export class WebsocketsActions implements IWebsocketsActions { + public readonly manager: IWebsocketsManager; + + private readonly getToken: () => Promise; + private readonly tenant: string | null; + private readonly locale: string | null; + + public constructor(params: IWebsocketActionsParams) { + this.manager = params.manager; + this.tenant = params.tenant; + this.locale = params.locale; + this.getToken = params.getToken; + } + + public async run( + params: IWebsocketsActionsRunParams + ): Promise { + const { action, timeout, data } = params; + const token = await this.getToken(); + if (!token) { + console.error("Token is not set - cannot send a websocket message."); + return null; + } else if (!this.tenant) { + console.error("Tenant is not set - cannot send a websocket message."); + return null; + } else if (!this.locale) { + console.error("Locale is not set - cannot send a websocket message."); + return null; + } + /** + * If no timeout was sent, we will just send the message and return null. + * No waiting for the response. + */ + if (!timeout || timeout < 0) { + this.manager.send>({ + /** + * It is ok to cast as we are checking the values a few lines above. + */ + token, + tenant: this.tenant as string, + locale: this.locale as string, + action, + data: data || ({} as T) + }); + return null; + } + /** + * In case of a timeout, we will send the message and wait for the response. + */ + return await new Promise((resolve, reject) => { + let promiseTimeout: NodeJS.Timeout | null = null; + const subscription = this.manager.onMessage(async event => { + if (event.data.messageId !== subscription.id) { + return; + } + resolve(event.data); + subscription.off(); + if (!promiseTimeout) { + return; + } + clearTimeout(promiseTimeout); + }); + + promiseTimeout = setTimeout(() => { + const message = `Websocket action "${action}" timeout.`; + subscription.off(); + reject(new Error(message)); + }, timeout); + + this.manager.send>({ + /** + * It is ok to cast as we are checking the values a few lines above. + */ + token, + tenant: this.tenant as string, + locale: this.locale as string, + messageId: subscription.id, + action, + data: data || ({} as T) + }); + }).catch(ex => { + console.error("Error while sending websocket message.", ex); + return null; + }); + } +} + +export const createWebsocketsActions = (params: IWebsocketActionsParams): IWebsocketsActions => { + return new WebsocketsActions(params); +}; diff --git a/packages/app-websockets/src/domain/WebsocketsConnection.ts b/packages/app-websockets/src/domain/WebsocketsConnection.ts new file mode 100644 index 00000000000..d175db26065 --- /dev/null +++ b/packages/app-websockets/src/domain/WebsocketsConnection.ts @@ -0,0 +1,103 @@ +import { + IGenericData, + IWebsocketsConnection, + IWebsocketsConnectionFactory, + IWebsocketsConnectProtocol, + IWebsocketsManagerMessageEvent, + IWebsocketsSubscriptionManager, + WebsocketsCloseCode, + WebsocketsReadyState +} from "./types"; + +const defaultFactory: IWebsocketsConnectionFactory = (url, protocol) => { + return new WebSocket(url, protocol); +}; + +export interface IWebsocketsConnectionParams { + url: string; + subscriptionManager: IWebsocketsSubscriptionManager; + protocol?: IWebsocketsConnectProtocol; + factory?: IWebsocketsConnectionFactory; +} + +export class WebsocketsConnection implements IWebsocketsConnection { + private connection: WebSocket | null = null; + private url: string; + private protocol: IWebsocketsConnectProtocol; + public readonly subscriptionManager: IWebsocketsSubscriptionManager; + private readonly factory: IWebsocketsConnectionFactory; + + public constructor(params: IWebsocketsConnectionParams) { + this.url = params.url; + this.protocol = params.protocol; + this.subscriptionManager = params.subscriptionManager; + this.factory = params.factory || defaultFactory; + } + + public init(): void { + this.connect(this.url, this.protocol); + } + + public connect(url: string, protocol?: IWebsocketsConnectProtocol): void { + if (this.connection && this.connection.readyState !== WebsocketsReadyState.CLOSED) { + return; + } + this.url = url; + this.protocol = protocol || this.protocol; + this.connection = this.factory(this.url, this.protocol); + + this.connection.addEventListener("open", event => { + console.info(`Opened the Websocket connection.`); + return this.subscriptionManager.triggerOnOpen(event); + }); + this.connection.addEventListener("close", event => { + console.info(`Closed the Websocket connection.`); + return this.subscriptionManager.triggerOnClose(event); + }); + this.connection.addEventListener("error", event => { + console.info(`Error in the Websocket connection.`); + return this.subscriptionManager.triggerOnError(event); + }); + this.connection.addEventListener( + "message", + (event: IWebsocketsManagerMessageEvent) => { + return this.subscriptionManager.triggerOnMessage(event); + } + ); + } + + public close(code?: WebsocketsCloseCode, reason?: string): boolean { + if (!this.connection || this.connection.readyState === WebsocketsReadyState.CLOSED) { + this.connection = null; + return true; + } else if (this.connection.readyState !== WebsocketsReadyState.OPEN) { + return false; + } + this.connection.close(code, reason); + this.connection = null; + return true; + } + + public reconnect(url?: string, protocol?: IWebsocketsConnectProtocol): void { + if (!this.close(WebsocketsCloseCode.RECONNECT, "Trying to reconnect.")) { + console.error("Failed to close the connection before reconnecting."); + return; + } + + this.connect(url || this.url, protocol || this.protocol); + } + + public send(data: T): void { + if (!this.connection || this.connection.readyState !== WebsocketsReadyState.OPEN) { + console.info("Websocket connection is not open, cannot send any data.", data); + return; + } + this.connection.send(JSON.stringify(data)); + } +} + +export const createWebsocketsConnection = ( + params: IWebsocketsConnectionParams +): IWebsocketsConnection => { + return new WebsocketsConnection(params); +}; diff --git a/packages/app-websockets/src/domain/WebsocketsManager.ts b/packages/app-websockets/src/domain/WebsocketsManager.ts new file mode 100644 index 00000000000..3fff667b9d5 --- /dev/null +++ b/packages/app-websockets/src/domain/WebsocketsManager.ts @@ -0,0 +1,61 @@ +import { + IGenericData, + IWebsocketsConnection, + IWebsocketsManager, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, + IWebsocketsManagerMessageEvent, + IWebsocketsManagerOpenEvent, + IWebsocketManagerSendData, + IWebsocketsSubscription, + IWebsocketsSubscriptionCallback, + WebsocketsCloseCode +} from "./types"; + +export class WebsocketsManager implements IWebsocketsManager { + public readonly connection: IWebsocketsConnection; + + public constructor(connection: IWebsocketsConnection) { + this.connection = connection; + } + + public onOpen( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + return this.connection.subscriptionManager.onOpen(cb); + } + + public onClose( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + return this.connection.subscriptionManager.onClose(cb); + } + + public onMessage( + cb: IWebsocketsSubscriptionCallback> + ): IWebsocketsSubscription> { + return this.connection.subscriptionManager.onMessage(cb); + } + + public onError( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + return this.connection.subscriptionManager.onError(cb); + } + + public connect(): void { + this.connection.reconnect(); + } + + public close(code?: WebsocketsCloseCode, reason?: string): void { + this.connection.close(code, reason); + } + + public send(data: T): void { + return this.connection.send(data); + } +} + +export const createWebsocketsManager = (connection: IWebsocketsConnection): IWebsocketsManager => { + return new WebsocketsManager(connection); +}; diff --git a/packages/app-websockets/src/domain/WebsocketsSubscriptionManager.ts b/packages/app-websockets/src/domain/WebsocketsSubscriptionManager.ts new file mode 100644 index 00000000000..1934d83d10e --- /dev/null +++ b/packages/app-websockets/src/domain/WebsocketsSubscriptionManager.ts @@ -0,0 +1,107 @@ +import { generateId } from "@webiny/utils/generateId"; +import { + IGenericData, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, + IWebsocketManagerEvent, + IWebsocketsManagerMessageEvent, + IWebsocketsManagerOpenEvent +} from "./types"; +import { + IWebsocketsSubscriptionManagerSubscriptions, + IWebsocketsSubscription, + IWebsocketsSubscriptionCallback, + IWebsocketsSubscriptionManager +} from "./abstractions/IWebsocketsSubscriptionManager"; + +export class WebsocketsSubscriptionManager implements IWebsocketsSubscriptionManager { + private subscriptions: IWebsocketsSubscriptionManagerSubscriptions = { + open: {}, + close: {}, + error: {}, + message: {} + }; + + public onOpen( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + const value = this.createSubscription("open", cb); + this.subscriptions.close[value.id] = value; + return value; + } + + public onClose( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + const value = this.createSubscription("close", cb); + this.subscriptions.close[value.id] = value; + return value; + } + + public onError( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + const value = this.createSubscription("error", cb); + this.subscriptions.error[value.id] = value; + return value; + } + + public onMessage( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + const value = this.createSubscription("message", cb); + this.subscriptions.message[value.id] = value; + return value; + } + + public async triggerOnOpen(event: Event): Promise { + for (const id in this.subscriptions.open) { + await this.subscriptions.open[id].cb(event); + } + } + + public async triggerOnClose(event: CloseEvent): Promise { + for (const id in this.subscriptions.close) { + await this.subscriptions.close[id].cb(event); + } + } + + public async triggerOnError(event: Event): Promise { + for (const id in this.subscriptions.error) { + await this.subscriptions.error[id].cb(event); + } + } + + public async triggerOnMessage(event: IWebsocketsManagerMessageEvent): Promise { + let data: IGenericData = {}; + try { + data = JSON.parse(event.data); + } catch (ex) { + console.error("Failed to parse the incoming message.", ex); + } + for (const id in this.subscriptions.message) { + await this.subscriptions.message[id].cb({ + ...event, + data: data || {} + }); + } + } + + private createSubscription( + type: IWebsocketManagerEvent, + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription { + const id = generateId(); + return { + cb, + id, + off: () => { + delete this.subscriptions[type][id]; + } + }; + } +} + +export const createWebsocketsSubscriptionManager = (): IWebsocketsSubscriptionManager => { + return new WebsocketsSubscriptionManager(); +}; diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsAction.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsAction.ts new file mode 100644 index 00000000000..df0370ec27b --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsAction.ts @@ -0,0 +1,34 @@ +import { IGenericData } from "./types"; + +export interface IWebsocketActionOnResponse { + (response: R | null): R | null; +} + +export interface IWebsocketsActionsTriggerParams< + T extends IGenericData = IGenericData, + R extends IGenericData = IGenericData +> { + data?: T; + /** + * Does this action expect some response from the server? + * If defined, the response will be passed to this function. + */ + onResponse?: IWebsocketActionOnResponse; + /** + * How long to wait for the response? + * In milliseconds. + */ + timeout?: number; +} + +export interface IWebsocketsAction< + T extends IGenericData = IGenericData, + R extends IGenericData = IGenericData +> { + /** + * Trigger the action - send data to the server via Websockets. + * If onResponse is defined the method will wait for the response. + * If onResponse is not defined, the method will return null immediately. + */ + trigger(params?: IWebsocketsActionsTriggerParams): Promise; +} diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsActions.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsActions.ts new file mode 100644 index 00000000000..7dce730f63b --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsActions.ts @@ -0,0 +1,15 @@ +import { IWebsocketsManager } from "./IWebsocketsManager"; +import { IGenericData } from "./types"; + +export interface IWebsocketsActionsRunParams { + action: string; + data?: T; + timeout?: number; +} + +export interface IWebsocketsActions { + manager: IWebsocketsManager; + run( + params: IWebsocketsActionsRunParams + ): Promise; +} diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsConnection.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsConnection.ts new file mode 100644 index 00000000000..211524f98ce --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsConnection.ts @@ -0,0 +1,24 @@ +import { IWebsocketsSubscriptionManager } from "./IWebsocketsSubscriptionManager"; +import { IGenericData, WebsocketsCloseCode } from "./types"; + +export type IWebsocketsConnectProtocol = string | string[] | undefined; + +export interface IWebsocketsConnectionFactory { + (url: string, protocol?: IWebsocketsConnectProtocol): WebSocket; +} + +export enum WebsocketsReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSING = 2, + CLOSED = 3 +} + +export interface IWebsocketsConnection { + readonly subscriptionManager: IWebsocketsSubscriptionManager; + + connect(url: string, protocol?: IWebsocketsConnectProtocol): void; + reconnect(url?: string, protocol?: IWebsocketsConnectProtocol): void; + close(code?: WebsocketsCloseCode, reason?: string): boolean; + send(data: T): void; +} diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsManager.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsManager.ts new file mode 100644 index 00000000000..65524b4827e --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsManager.ts @@ -0,0 +1,63 @@ +import { + IWebsocketsSubscription, + IWebsocketsSubscriptionCallback +} from "./IWebsocketsSubscriptionManager"; +import { + IGenericData, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, + IWebsocketsManagerMessageEvent, + IWebsocketsManagerOpenEvent, + WebsocketsCloseCode +} from "./types"; + +export interface IWebsocketManagerSendData + extends IGenericData { + /** + * A user token, which will identify the user sending the message. + */ + token: string; + /** + * Current tenant. + */ + tenant: string; + /** + * Current locale. + */ + locale: string; + /** + * A unique message ID - generated on the UI side. + * TODO implement waiting for the message response. + */ + messageId?: string; + /** + * Action being fired on the API side. + */ + action: string; + /** + * Data being sent to the API. Must be an object. + */ + data: T; +} + +export interface IWebsocketsManager { + connect(): void; + close(code?: WebsocketsCloseCode, reason?: string): void; + send(data: T): void; + + onOpen( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onClose( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onError( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onMessage( + cb: IWebsocketsSubscriptionCallback> + ): IWebsocketsSubscription>; +} diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts new file mode 100644 index 00000000000..99375c6afce --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts @@ -0,0 +1,55 @@ +import { GenericRecord } from "@webiny/app/types"; +import { + IGenericData, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, + IWebsocketsManagerMessageEvent, + IWebsocketsManagerOpenEvent +} from "./types"; + +export type IWebsocketManagerEvent = "open" | "close" | "error" | "message"; + +export interface IWebsocketsSubscriptionCallback { + (data: T): Promise; +} + +export interface IWebsocketsSubscription { + cb: IWebsocketsSubscriptionCallback; + id: string; + /** + * Remove the subscription on the message. + */ + off: () => void; +} + +export interface IWebsocketsSubscriptionManagerSubscriptions< + T extends IGenericData = IGenericData +> { + open: GenericRecord>; + close: GenericRecord>; + error: GenericRecord>; + message: GenericRecord>; +} + +export interface IWebsocketsSubscriptionManager { + onOpen( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onClose( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onError( + cb: IWebsocketsSubscriptionCallback + ): IWebsocketsSubscription; + + onMessage( + cb: IWebsocketsSubscriptionCallback> + ): IWebsocketsSubscription>; + + triggerOnOpen(event: IWebsocketsManagerOpenEvent): Promise; + triggerOnClose(event: IWebsocketsManagerCloseEvent): Promise; + triggerOnError(event: IWebsocketsManagerErrorEvent): Promise; + triggerOnMessage(event: IWebsocketsManagerMessageEvent): Promise; +} diff --git a/packages/app-websockets/src/domain/abstractions/types.ts b/packages/app-websockets/src/domain/abstractions/types.ts new file mode 100644 index 00000000000..265bf40be57 --- /dev/null +++ b/packages/app-websockets/src/domain/abstractions/types.ts @@ -0,0 +1,28 @@ +import { GenericRecord } from "@webiny/app/types"; + +export type IWebsocketsManagerMessageEvent = MessageEvent; +export type IWebsocketsManagerCloseEvent = CloseEvent; +export type IWebsocketsManagerOpenEvent = Event; +export type IWebsocketsManagerErrorEvent = Event; + +export type IGenericData = GenericRecord; + +export enum WebsocketsCloseCode { + RECONNECT = 1, + NORMAL = 1000, + GOING_AWAY = 1001, + PROTOCOL_ERROR = 1002, + CANNOT_ACCEPT = 1003, + RESERVED = 1004, + NO_STATUS = 1005, + ABNORMAL = 1006, + INVALID_DATA = 1007, + POLICY_VIOLATION = 1008, + TOO_BIG = 1009, + MISSING_EXTENSION = 1010, + SERVER_ERROR = 1011, + SERVICE_RESTART = 1012, + TRY_AGAIN_LATER = 1013, + BAD_GATEWAY = 1014, + TLS_HANDSHAKE = 1015 +} diff --git a/packages/app-websockets/src/domain/index.ts b/packages/app-websockets/src/domain/index.ts new file mode 100644 index 00000000000..6a4995308c8 --- /dev/null +++ b/packages/app-websockets/src/domain/index.ts @@ -0,0 +1,6 @@ +export * from "./BlackHoleWebsocketsManager"; +export * from "./WebsocketsAction"; +export * from "./WebsocketsActions"; +export * from "./WebsocketsConnection"; +export * from "./WebsocketsManager"; +export * from "./WebsocketsSubscriptionManager"; diff --git a/packages/app-websockets/src/domain/types.ts b/packages/app-websockets/src/domain/types.ts new file mode 100644 index 00000000000..9025dba8427 --- /dev/null +++ b/packages/app-websockets/src/domain/types.ts @@ -0,0 +1,7 @@ +export * from "./abstractions/IWebsocketsAction"; +export * from "./abstractions/IWebsocketsActions"; +export * from "./abstractions/IWebsocketsConnection"; +export * from "./abstractions/IWebsocketsManager"; +export * from "./abstractions/IWebsocketsSubscriptionManager"; + +export * from "./abstractions/types"; diff --git a/packages/app-websockets/src/hooks/index.ts b/packages/app-websockets/src/hooks/index.ts new file mode 100644 index 00000000000..9de77a7e3bb --- /dev/null +++ b/packages/app-websockets/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useWebsockets"; diff --git a/packages/app-websockets/src/hooks/useWebsockets.tsx b/packages/app-websockets/src/hooks/useWebsockets.tsx new file mode 100644 index 00000000000..f5e67e9ec5c --- /dev/null +++ b/packages/app-websockets/src/hooks/useWebsockets.tsx @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { WebsocketsContext } from "~/WebsocketsProvider"; +import { IWebsocketsContext } from "~/types"; + +export const useWebsockets = (): IWebsocketsContext => { + const context = useContext(WebsocketsContext); + if (!context) { + throw new Error("useWebsockets must be used within a SocketsProvider"); + } + return context; +}; diff --git a/packages/app-websockets/src/index.tsx b/packages/app-websockets/src/index.tsx new file mode 100644 index 00000000000..dcabda618dd --- /dev/null +++ b/packages/app-websockets/src/index.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Provider } from "@webiny/app"; +import { WebsocketsProvider as WebsocketsProviderComponent } from "~/WebsocketsProvider"; + +export interface WebsocketsProviderProps { + children: React.ReactNode; +} + +const WebsocketsHoc = (Component: React.ComponentType) => { + return function WebsocketsProvider({ children }: WebsocketsProviderProps) { + return ( + + {children} + + ); + }; +}; + +const WebsocketsExtension = () => { + return ( + <> + + + ); +}; + +export const Websockets = React.memo(WebsocketsExtension); + +export * from "./types"; +export * from "./hooks"; diff --git a/packages/app-websockets/src/types.ts b/packages/app-websockets/src/types.ts new file mode 100644 index 00000000000..8ac28db1f79 --- /dev/null +++ b/packages/app-websockets/src/types.ts @@ -0,0 +1,37 @@ +import { + IGenericData, + IWebsocketsAction, + IWebsocketsManagerMessageEvent, + IWebsocketsSubscription +} from "~/domain/types"; + +export * from "./domain/types"; + +export interface IWebsocketsContextSendCallable { + (action: string, data?: T, timeout?: number): void; +} + +export interface IWebsocketsContextCreateActionCallable< + T extends IGenericData = IGenericData, + R extends IGenericData = IGenericData +> { + (name: string): IWebsocketsAction; +} + +export interface ISocketsContextOnMessageCallable< + T extends IncomingGenericData = IncomingGenericData +> { + (action: string, cb: (data: T) => void): IWebsocketsSubscription< + IWebsocketsManagerMessageEvent + >; +} + +export interface IWebsocketsContext { + send: IWebsocketsContextSendCallable; + createAction: IWebsocketsContextCreateActionCallable; + onMessage: ISocketsContextOnMessageCallable; +} + +export interface IncomingGenericData extends IGenericData { + action: string; +} diff --git a/packages/app-websockets/src/utils/getToken.ts b/packages/app-websockets/src/utils/getToken.ts new file mode 100644 index 00000000000..10ae88f16f7 --- /dev/null +++ b/packages/app-websockets/src/utils/getToken.ts @@ -0,0 +1,13 @@ +import { Auth } from "@aws-amplify/auth"; + +export const getToken = async (): Promise => { + const user = await Auth.currentSession(); + if (!user) { + return null; + } + const token = user.getIdToken(); + if (!token) { + return null; + } + return token.getJwtToken(); +}; diff --git a/packages/app-websockets/src/utils/getUrl.ts b/packages/app-websockets/src/utils/getUrl.ts new file mode 100644 index 00000000000..0d30fb9b83d --- /dev/null +++ b/packages/app-websockets/src/utils/getUrl.ts @@ -0,0 +1,28 @@ +interface Params { + tenant: string; + locale: string; + token: string; +} + +export const getUrl = (params: Params): string | undefined => { + const { tenant, locale, token } = params; + if (!token) { + console.log("Missing a token to connect to the websocket."); + return; + } else if (!tenant) { + console.log("Missing a tenant to connect to the websocket."); + return; + } else if (!locale) { + console.log("Missing a locale to connect to the websocket."); + return; + } + const websocketApiUrl = process.env.REACT_APP_WEBSOCKET_URL; + + const url = !websocketApiUrl || websocketApiUrl === "undefined" ? undefined : websocketApiUrl; + if (!url) { + console.error("Missing REACT_APP_WEBSOCKET_URL environment variable."); + return; + } + + return `${url}?token=${token}&tenant=${tenant}&locale=${locale}`; +}; diff --git a/packages/app-websockets/tsconfig.build.json b/packages/app-websockets/tsconfig.build.json new file mode 100644 index 00000000000..dc0f42b8992 --- /dev/null +++ b/packages/app-websockets/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../app/tsconfig.build.json" }, + { "path": "../app-i18n/tsconfig.build.json" }, + { "path": "../app-tenancy/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/app-websockets/tsconfig.json b/packages/app-websockets/tsconfig.json new file mode 100644 index 00000000000..c2874200728 --- /dev/null +++ b/packages/app-websockets/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../app" }, + { "path": "../app-i18n" }, + { "path": "../app-tenancy" }, + { "path": "../utils" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/app/*": ["../app/src/*"], + "@webiny/app": ["../app/src"], + "@webiny/app-i18n/*": ["../app-i18n/src/*"], + "@webiny/app-i18n": ["../app-i18n/src"], + "@webiny/app-tenancy/*": ["../app-tenancy/src/*"], + "@webiny/app-tenancy": ["../app-tenancy/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/app-websockets/webiny.config.js b/packages/app-websockets/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/app-websockets/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 770db7d6750..d9b5c68cf44 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -3,6 +3,10 @@ import { Plugin } from "@webiny/plugins/types"; import { ApolloClient } from "apollo-client"; import { CSSProperties } from "react"; +export type GenericRecordKey = string | number | symbol; + +export type GenericRecord = Record; + export type UploadOptions = { apolloClient: ApolloClient; onProgress?: (params: { sent: number; total: number; percentage: number }) => void; diff --git a/packages/aws-sdk/package.json b/packages/aws-sdk/package.json index a8cea763e0e..92ff627551d 100644 --- a/packages/aws-sdk/package.json +++ b/packages/aws-sdk/package.json @@ -6,6 +6,7 @@ "license": "MIT", "author": "Webiny Ltd.", "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.425.0", "@aws-sdk/client-cloudfront": "^3.425.0", "@aws-sdk/client-cloudwatch-events": "^3.425.0", "@aws-sdk/client-cloudwatch-logs": "^3.425.0", diff --git a/packages/aws-sdk/src/client-apigatewaymanagementapi/index.ts b/packages/aws-sdk/src/client-apigatewaymanagementapi/index.ts new file mode 100644 index 00000000000..929dfc183bc --- /dev/null +++ b/packages/aws-sdk/src/client-apigatewaymanagementapi/index.ts @@ -0,0 +1,5 @@ +export { + ApiGatewayManagementApiClient, + PostToConnectionCommand, + PostToConnectionCommandInput +} from "@aws-sdk/client-apigatewaymanagementapi"; diff --git a/packages/cli-plugin-scaffold-ci/src/githubActions/index.ts b/packages/cli-plugin-scaffold-ci/src/githubActions/index.ts index f7a763d26d6..ccdd2a0ccad 100644 --- a/packages/cli-plugin-scaffold-ci/src/githubActions/index.ts +++ b/packages/cli-plugin-scaffold-ci/src/githubActions/index.ts @@ -255,10 +255,6 @@ const plugin: CliPluginsScaffoldCi = { text: `${chalk.green(newRepoName)} code repository created.` }); } else { - /** - * TODO @ts-refactor try to get the heads and tails of this. - */ - // @ts-expect-error repo = await octokit.rest.repos .get({ repo: existingRepo.name, diff --git a/packages/cwp-template-aws/template/common/types/env/index.d.ts b/packages/cwp-template-aws/template/common/types/env/index.d.ts index caf272dcd53..bc8f89fa94a 100644 --- a/packages/cwp-template-aws/template/common/types/env/index.d.ts +++ b/packages/cwp-template-aws/template/common/types/env/index.d.ts @@ -33,6 +33,7 @@ declare namespace NodeJS { REACT_APP_USER_POOL_ID?: string; REACT_APP_USER_POOL_WEB_CLIENT_ID?: string; REACT_APP_USER_POOL_PASSWORD_POLICY?: string; + REACT_APP_WEBSOCKET_URL?: string; REACT_APP_USER_POOL_DOMAIN?: string; REACT_APP_ADMIN_USER_CAN_CHANGE_EMAIL?: string; COGNITO_USER_POOL_ID?: string; diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json index af68b8e47f8..afc942eb797 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json @@ -37,6 +37,7 @@ "@webiny/api-tenancy-so-ddb": "latest", "@webiny/api-tenant-manager": "latest", "@webiny/api-wcp": "latest", + "@webiny/api-websockets": "latest", "@webiny/cli": "latest", "@webiny/db-dynamodb": "latest", "@webiny/handler-aws": "latest", diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts index 72aed63dfd3..517e047ab8b 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts @@ -34,11 +34,9 @@ import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; import { createBackgroundTasks } from "@webiny/api-background-tasks-es"; -/** - * APW - */ import { createApwGraphQL, createApwPageBuilderContext } from "@webiny/api-apw"; import { createStorageOperations as createApwSaStorageOperations } from "@webiny/api-apw-scheduler-so-ddb"; +import { createWebsockets } from "@webiny/api-websockets"; // Imports plugins created via scaffolding utilities. import scaffoldsPlugins from "./plugins/scaffolds"; @@ -67,6 +65,7 @@ export const handler = createHandler({ tenantManager(), i18nPlugins(), i18nDynamoDbStorageOperations(), + createWebsockets(), createHeadlessCmsContext({ storageOperations: createHeadlessCmsStorageOperations({ documentClient, diff --git a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json index 25986e06fb6..ca9a297c8ac 100644 --- a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json @@ -37,6 +37,7 @@ "@webiny/api-tenancy-so-ddb": "latest", "@webiny/api-tenant-manager": "latest", "@webiny/api-wcp": "latest", + "@webiny/api-websockets": "latest", "@webiny/cli": "latest", "@webiny/db-dynamodb": "latest", "@webiny/handler-aws": "latest", diff --git a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts index cc4c3f80a7e..df2b97afb03 100644 --- a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts @@ -34,11 +34,9 @@ import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; import { createBackgroundTasks } from "@webiny/api-background-tasks-os"; -/** - * APW - */ import { createApwGraphQL, createApwPageBuilderContext } from "@webiny/api-apw"; import { createStorageOperations as createApwSaStorageOperations } from "@webiny/api-apw-scheduler-so-ddb"; +import { createWebsockets } from "@webiny/api-websockets"; // Imports plugins created via scaffolding utilities. import scaffoldsPlugins from "./plugins/scaffolds"; @@ -67,6 +65,7 @@ export const handler = createHandler({ tenantManager(), i18nPlugins(), i18nDynamoDbStorageOperations(), + createWebsockets(), createHeadlessCmsContext({ storageOperations: createHeadlessCmsStorageOperations({ documentClient, diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json index 316231e6289..40be1f3e2a0 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json @@ -36,6 +36,7 @@ "@webiny/api-tenancy-so-ddb": "latest", "@webiny/api-tenant-manager": "latest", "@webiny/api-wcp": "latest", + "@webiny/api-websockets": "latest", "@webiny/cli": "latest", "@webiny/db-dynamodb": "latest", "@webiny/handler-aws": "latest", diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts index 6bd0118f512..74fb20ed071 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts @@ -30,11 +30,9 @@ import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; import { createBackgroundTasks } from "@webiny/api-background-tasks-ddb"; -/** - * APW - */ import { createApwGraphQL, createApwPageBuilderContext } from "@webiny/api-apw"; import { createStorageOperations as createApwSaStorageOperations } from "@webiny/api-apw-scheduler-so-ddb"; +import { createWebsockets } from "@webiny/api-websockets"; // Imports plugins created via scaffolding utilities. import scaffoldsPlugins from "./plugins/scaffolds"; @@ -58,6 +56,7 @@ export const handler = createHandler({ tenantManager(), i18nPlugins(), i18nDynamoDbStorageOperations(), + createWebsockets(), createHeadlessCmsContext({ storageOperations: createHeadlessCmsStorageOperations({ documentClient diff --git a/packages/handler/src/fastify.ts b/packages/handler/src/fastify.ts index dede0fc3288..45dd69ef1e9 100644 --- a/packages/handler/src/fastify.ts +++ b/packages/handler/src/fastify.ts @@ -4,7 +4,7 @@ import fastify, { FastifyServerOptions as ServerOptions, preSerializationAsyncHookHandler } from "fastify"; -import { getWebinyVersionHeaders } from "@webiny/utils"; +import { getWebinyVersionHeaders, middleware, MiddlewareCallable } from "@webiny/utils"; import { ContextRoutes, DefinedContextRoutes, @@ -19,7 +19,6 @@ import { RoutePlugin } from "./plugins/RoutePlugin"; import { createHandlerClient } from "@webiny/handler-client"; import fastifyCookie from "@fastify/cookie"; import fastifyCompress from "@fastify/compress"; -import { middleware, MiddlewareCallable } from "~/middleware"; import { ContextPlugin } from "@webiny/api"; import { BeforeHandlerPlugin } from "./plugins/BeforeHandlerPlugin"; import { HandlerResultPlugin } from "./plugins/HandlerResultPlugin"; diff --git a/packages/pulumi-aws/src/apps/api/ApiOutput.ts b/packages/pulumi-aws/src/apps/api/ApiOutput.ts index 85235ba83ed..b311f0d9fd7 100644 --- a/packages/pulumi-aws/src/apps/api/ApiOutput.ts +++ b/packages/pulumi-aws/src/apps/api/ApiOutput.ts @@ -33,7 +33,8 @@ export const ApiOutput = createAppModule({ cognitoUserPoolId: output["cognitoUserPoolId"] as string, cognitoUserPoolPasswordPolicy: output["cognitoUserPoolPasswordPolicy"] as string, dynamoDbTable: output["dynamoDbTable"] as string, - region: output["region"] as string + region: output["region"] as string, + websocketApiUrl: output["websocketApiUrl"] as string }; }); } diff --git a/packages/pulumi-aws/src/apps/api/ApiWebsocket.ts b/packages/pulumi-aws/src/apps/api/ApiWebsocket.ts new file mode 100644 index 00000000000..233ec63d6a1 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/ApiWebsocket.ts @@ -0,0 +1,200 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as aws from "@pulumi/aws"; +import { createAppModule, PulumiApp, PulumiAppModule } from "@webiny/pulumi"; +import { ApiGraphql } from "~/apps"; + +export type ApiWebsocket = PulumiAppModule; + +export const ApiWebsocket = createAppModule({ + name: "ApiWebsocket", + config(app: PulumiApp) { + const graphql = app.getModule(ApiGraphql); + + const websocketApi = app.addResource(aws.apigatewayv2.Api, { + name: "websocket-api", + config: { + protocolType: "WEBSOCKET", + routeSelectionExpression: "$request.body.action" + } + }); + + const websocketLambdaPermission = app.addResource(aws.lambda.Permission, { + name: `websocket-api-ws-to-lambda-permission`, + config: { + action: "lambda:InvokeFunction", + function: graphql.functions.graphql.output.name, + principal: "apigateway.amazonaws.com", + sourceArn: websocketApi.output.executionArn.apply(arn => `${arn}/*`) + } + }); + + const lambdaWebsocketPolicy = app.addResource(aws.iam.Policy, { + name: "websocket-api-lambda-policy", + config: { + policy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "execute-api:ManageConnections", + Resource: [ + websocketApi.output.arn, + websocketApi.output.executionArn, + websocketApi.output.arn.apply(arn => `${arn}/*`), + websocketApi.output.executionArn.apply(arn => `${arn}/*`) + ], + Sid: "PermissionForWebsocket" + } + ] + } + } + }); + + const lambdaWebsocketRolePolicyAttachment = app.addResource(aws.iam.RolePolicyAttachment, { + name: "websocket-api-lambda-role-policy-attachment", + config: { + policyArn: lambdaWebsocketPolicy.output.arn, + role: graphql.role.output + } + }); + + const websocketApiIntegration = app.addResource(aws.apigatewayv2.Integration, { + name: "websocket-api-integration", + config: { + apiId: websocketApi.output.id, + integrationType: "AWS_PROXY", + integrationUri: graphql.functions.graphql.output.invokeArn + } + }); + + const websocketApiDefaultRoute = app.addResource(aws.apigatewayv2.Route, { + name: "websocket-api-route-default", + config: { + apiId: websocketApi.output.id, + routeKey: "$default", + target: websocketApiIntegration.output.id.apply(value => `integrations/${value}`) + } + }); + + const websocketApiConnectRoute = app.addResource(aws.apigatewayv2.Route, { + name: "websocket-api-route-connect", + config: { + apiId: websocketApi.output.id, + routeKey: "$connect", + authorizationType: "NONE", + target: websocketApiIntegration.output.id.apply(value => `integrations/${value}`) + } + }); + + const websocketApiDisconnectRoute = app.addResource(aws.apigatewayv2.Route, { + name: "websocket-api-route-disconnect", + config: { + apiId: websocketApi.output.id, + routeKey: "$disconnect", + authorizationType: "NONE", + target: websocketApiIntegration.output.id.apply(value => `integrations/${value}`) + } + }); + + const deployment = app.addResource(aws.apigatewayv2.Deployment, { + name: "websocket-api-deployment", + config: { + apiId: websocketApi.output.id, + description: "WebSocket API Deployment" + }, + opts: { + dependsOn: [ + websocketApiDefaultRoute.output, + websocketApiConnectRoute.output, + websocketApiDisconnectRoute.output + ] + } + }); + + // TODO remove logging when stopped developing + + const apiGatewayLoggingRole = app.addResource(aws.iam.Role, { + name: "apiGatewayLoggingRole", + config: { + assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ + Service: "apigateway.amazonaws.com" + }) + } + }); + + app.addResource(aws.iam.RolePolicy, { + name: "apiGatewayLoggingPolicy", + config: { + role: apiGatewayLoggingRole.output.id, + policy: { + Version: "2012-10-17", + Statement: [ + { + Action: ["logs:*"], + Effect: "Allow", + Resource: "*" + } + ] + } + } + }); + + app.addResource(aws.apigateway.Account, { + name: "apiGatewayAccount", + config: { + cloudwatchRoleArn: apiGatewayLoggingRole.output.arn + } + }); + + // TODO remove when development is done + const logGroup = app.addResource(aws.cloudwatch.LogGroup, { + name: "websocket-api-log", + config: { + retentionInDays: 7 + } + }); + + const websocketApiStage = app.addResource(aws.apigatewayv2.Stage, { + name: "websocket-api-stage", + config: { + apiId: websocketApi.output.id, + deploymentId: deployment.output.id, + name: app.params.run.env, + defaultRouteSettings: { + loggingLevel: "INFO", + throttlingBurstLimit: 1000, + throttlingRateLimit: 500 + }, + // TODO remove when development is done + accessLogSettings: { + destinationArn: logGroup.output.arn, + format: JSON.stringify({ + requestId: "$context.requestId", + ip: "$context.identity.sourceIp", + caller: "$context.identity.caller", + user: "$context.identity.user", + requestTime: "$context.requestTime", + httpMethod: "$context.httpMethod", + resourcePath: "$context.resourcePath", + status: "$context.status", + protocol: "$context.protocol", + responseLength: "$context.responseLength" + }) + } + } + }); + + return { + websocketApi, + websocketApiStage, + websocketApiConnectRoute, + websocketApiDisconnectRoute, + websocketApiDefaultRoute, + websocketLambdaPermission, + lambdaWebsocketPolicy, + lambdaWebsocketRolePolicyAttachment, + deployment, + websocketApiUrl: pulumi.interpolate`${websocketApi.output.apiEndpoint}/${websocketApiStage.output.name}` + }; + } +}); diff --git a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts index da721784b2b..fced66b0007 100644 --- a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts @@ -9,6 +9,7 @@ import { ApiGraphql, ApiMigration, ApiPageBuilder, + ApiWebsocket, CoreOutput, CreateCorePulumiAppParams, VpcConfig @@ -187,9 +188,6 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = // TODO: move to okta plugin OKTA_ISSUER: process.env["OKTA_ISSUER"], WEBINY_LOGS_FORWARD_URL, - /** - * APW - */ APW_SCHEDULER_SCHEDULE_ACTION_HANDLER: apwScheduler.scheduleAction.lambda.output.arn }, @@ -197,6 +195,8 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = apwSchedulerEventTarget: apwScheduler.eventTarget.output }); + const websocket = app.addModule(ApiWebsocket); + const fileManager = app.addModule(ApiFileManager, { env: { DB_TABLE: core.primaryDynamodbTableName @@ -265,7 +265,9 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = migrationLambdaArn: migration.function.output.arn, graphqlLambdaName: graphql.functions.graphql.output.name, backgroundTaskLambdaArn: backgroundTask.backgroundTask.output.arn, - backgroundTaskStepFunctionArn: backgroundTask.stepFunction.output.arn + backgroundTaskStepFunctionArn: backgroundTask.stepFunction.output.arn, + websocketApiId: websocket.websocketApi.output.id, + websocketApiUrl: websocket.websocketApiUrl }); app.addHandler(() => { diff --git a/packages/pulumi-aws/src/apps/api/index.ts b/packages/pulumi-aws/src/apps/api/index.ts index 09987a078aa..2e3e108df83 100644 --- a/packages/pulumi-aws/src/apps/api/index.ts +++ b/packages/pulumi-aws/src/apps/api/index.ts @@ -8,3 +8,4 @@ export * from "./ApiMigration"; export * from "./ApiPageBuilder"; export * from "./createApiPulumiApp"; export * from "./ApiOutput"; +export * from "./ApiWebsocket"; diff --git a/packages/pulumi-aws/src/apps/core/CoreDynamo.ts b/packages/pulumi-aws/src/apps/core/CoreDynamo.ts index 6cddc5bbe79..99fe4424099 100644 --- a/packages/pulumi-aws/src/apps/core/CoreDynamo.ts +++ b/packages/pulumi-aws/src/apps/core/CoreDynamo.ts @@ -13,7 +13,9 @@ export const CoreDynamo = createAppModule({ { name: "PK", type: "S" }, { name: "SK", type: "S" }, { name: "GSI1_PK", type: "S" }, - { name: "GSI1_SK", type: "S" } + { name: "GSI1_SK", type: "S" }, + { name: "GSI2_PK", type: "S" }, + { name: "GSI2_SK", type: "S" } ], billingMode: "PAY_PER_REQUEST", hashKey: "PK", @@ -24,6 +26,12 @@ export const CoreDynamo = createAppModule({ hashKey: "GSI1_PK", rangeKey: "GSI1_SK", projectionType: "ALL" + }, + { + name: "GSI2", + hashKey: "GSI2_PK", + rangeKey: "GSI2_SK", + projectionType: "ALL" } ] }, diff --git a/packages/serverless-cms-aws/src/createAdminAppConfig.ts b/packages/serverless-cms-aws/src/createAdminAppConfig.ts index a56b43203c4..59de80df26a 100644 --- a/packages/serverless-cms-aws/src/createAdminAppConfig.ts +++ b/packages/serverless-cms-aws/src/createAdminAppConfig.ts @@ -17,7 +17,8 @@ export const createAdminAppConfig = (modifier?: ReactAppConfigModifier) => { REACT_APP_USER_POOL_WEB_CLIENT_ID: output.cognitoAppClientId, REACT_APP_USER_POOL_PASSWORD_POLICY: JSON.stringify( output.cognitoUserPoolPasswordPolicy - ) + ), + REACT_APP_WEBSOCKET_URL: output.websocketApiUrl }; }); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 29ade5f5d95..843f66f8f1c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,7 +12,9 @@ export * from "~/removeNullValues"; export * from "~/utcTimezones"; export * from "./cacheKey"; export * from "./getObjectProperties"; -import { composeAsync, AsyncProcessor, NextAsyncProcessor } from "~/compose"; +export * from "./middleware"; + +import { AsyncProcessor, composeAsync, NextAsyncProcessor } from "~/compose"; export { composeAsync }; export type { AsyncProcessor, NextAsyncProcessor }; diff --git a/packages/handler/src/middleware.ts b/packages/utils/src/middleware.ts similarity index 73% rename from packages/handler/src/middleware.ts rename to packages/utils/src/middleware.ts index 6ec2a9e19b3..8bb1562a4f8 100644 --- a/packages/handler/src/middleware.ts +++ b/packages/utils/src/middleware.ts @@ -1,18 +1,26 @@ export interface MiddlewareCallable { (...args: any[]): Promise; } + +export interface MiddlewareResolve { + (...args: any[]): void; +} + +export interface MiddlewareReject { + (error: Error): void; +} /** * Compose a single middleware from the array of middleware functions */ -export const middleware = (functions: MiddlewareCallable[] = []) => { - return (...args: any[]): Promise => { +export const middleware = (functions: MiddlewareCallable[] = []) => { + return (...args: Params[]): Promise => { if (!functions.length) { - return Promise.resolve(); + return Promise.resolve(undefined); } // Create a clone of function chain to prevent modifying the original array with `shift()` const chain = [...functions]; - return new Promise((parentResolve: any, parentReject) => { + return new Promise((parentResolve: MiddlewareResolve, parentReject: MiddlewareReject) => { const next = async (): Promise => { const fn = chain.shift(); if (!fn) { diff --git a/yarn.lock b/yarn.lock index de2f554aecc..59b9f720c41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -319,6 +319,54 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-apigatewaymanagementapi@npm:^3.425.0": + version: 3.515.0 + resolution: "@aws-sdk/client-apigatewaymanagementapi@npm:3.515.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.515.0 + "@aws-sdk/core": 3.513.0 + "@aws-sdk/credential-provider-node": 3.515.0 + "@aws-sdk/middleware-host-header": 3.515.0 + "@aws-sdk/middleware-logger": 3.515.0 + "@aws-sdk/middleware-recursion-detection": 3.515.0 + "@aws-sdk/middleware-user-agent": 3.515.0 + "@aws-sdk/region-config-resolver": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@aws-sdk/util-endpoints": 3.515.0 + "@aws-sdk/util-user-agent-browser": 3.515.0 + "@aws-sdk/util-user-agent-node": 3.515.0 + "@smithy/config-resolver": ^2.1.1 + "@smithy/core": ^1.3.2 + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/hash-node": ^2.1.1 + "@smithy/invalid-dependency": ^2.1.1 + "@smithy/middleware-content-length": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-retry": ^2.1.1 + "@smithy/middleware-serde": ^2.1.1 + "@smithy/middleware-stack": ^2.1.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + "@smithy/util-base64": ^2.1.1 + "@smithy/util-body-length-browser": ^2.1.1 + "@smithy/util-body-length-node": ^2.2.1 + "@smithy/util-defaults-mode-browser": ^2.1.1 + "@smithy/util-defaults-mode-node": ^2.2.0 + "@smithy/util-endpoints": ^1.1.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-retry": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + checksum: 92f7f2d5959b3ec41828ee2db7aabbb10ae6e0a34e4634b181ee901ac8593d2ea51d3717dbffab5c764e5549a8052a52d3a07aad4fc8d3d0d9e59a71749c102c + languageName: node + linkType: hard + "@aws-sdk/client-cloudfront@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/client-cloudfront@npm:3.425.0" @@ -1023,6 +1071,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso-oidc@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/client-sso-oidc@npm:3.515.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.515.0 + "@aws-sdk/core": 3.513.0 + "@aws-sdk/middleware-host-header": 3.515.0 + "@aws-sdk/middleware-logger": 3.515.0 + "@aws-sdk/middleware-recursion-detection": 3.515.0 + "@aws-sdk/middleware-user-agent": 3.515.0 + "@aws-sdk/region-config-resolver": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@aws-sdk/util-endpoints": 3.515.0 + "@aws-sdk/util-user-agent-browser": 3.515.0 + "@aws-sdk/util-user-agent-node": 3.515.0 + "@smithy/config-resolver": ^2.1.1 + "@smithy/core": ^1.3.2 + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/hash-node": ^2.1.1 + "@smithy/invalid-dependency": ^2.1.1 + "@smithy/middleware-content-length": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-retry": ^2.1.1 + "@smithy/middleware-serde": ^2.1.1 + "@smithy/middleware-stack": ^2.1.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + "@smithy/util-base64": ^2.1.1 + "@smithy/util-body-length-browser": ^2.1.1 + "@smithy/util-body-length-node": ^2.2.1 + "@smithy/util-defaults-mode-browser": ^2.1.1 + "@smithy/util-defaults-mode-node": ^2.2.0 + "@smithy/util-endpoints": ^1.1.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-retry": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + peerDependencies: + "@aws-sdk/credential-provider-node": ^3.515.0 + checksum: f220a9ba8542460b2aa91ad060302fb9e68bdf096ecca2ec1d6e525f4df1036b330cb85d20bac3e8399276c0d8d8d388b3f2191b58804919c68369afac0be37b + languageName: node + linkType: hard + "@aws-sdk/client-sso@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/client-sso@npm:3.425.0" @@ -1110,6 +1207,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/client-sso@npm:3.515.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.513.0 + "@aws-sdk/middleware-host-header": 3.515.0 + "@aws-sdk/middleware-logger": 3.515.0 + "@aws-sdk/middleware-recursion-detection": 3.515.0 + "@aws-sdk/middleware-user-agent": 3.515.0 + "@aws-sdk/region-config-resolver": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@aws-sdk/util-endpoints": 3.515.0 + "@aws-sdk/util-user-agent-browser": 3.515.0 + "@aws-sdk/util-user-agent-node": 3.515.0 + "@smithy/config-resolver": ^2.1.1 + "@smithy/core": ^1.3.2 + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/hash-node": ^2.1.1 + "@smithy/invalid-dependency": ^2.1.1 + "@smithy/middleware-content-length": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-retry": ^2.1.1 + "@smithy/middleware-serde": ^2.1.1 + "@smithy/middleware-stack": ^2.1.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + "@smithy/util-base64": ^2.1.1 + "@smithy/util-body-length-browser": ^2.1.1 + "@smithy/util-body-length-node": ^2.2.1 + "@smithy/util-defaults-mode-browser": ^2.1.1 + "@smithy/util-defaults-mode-node": ^2.2.0 + "@smithy/util-endpoints": ^1.1.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-retry": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + checksum: 12287dfa469fb2c6b5bedd3cbd37f7416f8234669b5ed0ff38cb1217d746ba6a5e6ff227b091a7751d1c20489a6b8bd93bcbed8f394cb4c51b6bebb9a9f79108 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.425.0, @aws-sdk/client-sts@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/client-sts@npm:3.425.0" @@ -1204,6 +1347,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/client-sts@npm:3.515.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.513.0 + "@aws-sdk/middleware-host-header": 3.515.0 + "@aws-sdk/middleware-logger": 3.515.0 + "@aws-sdk/middleware-recursion-detection": 3.515.0 + "@aws-sdk/middleware-user-agent": 3.515.0 + "@aws-sdk/region-config-resolver": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@aws-sdk/util-endpoints": 3.515.0 + "@aws-sdk/util-user-agent-browser": 3.515.0 + "@aws-sdk/util-user-agent-node": 3.515.0 + "@smithy/config-resolver": ^2.1.1 + "@smithy/core": ^1.3.2 + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/hash-node": ^2.1.1 + "@smithy/invalid-dependency": ^2.1.1 + "@smithy/middleware-content-length": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-retry": ^2.1.1 + "@smithy/middleware-serde": ^2.1.1 + "@smithy/middleware-stack": ^2.1.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + "@smithy/util-base64": ^2.1.1 + "@smithy/util-body-length-browser": ^2.1.1 + "@smithy/util-body-length-node": ^2.2.1 + "@smithy/util-defaults-mode-browser": ^2.1.1 + "@smithy/util-defaults-mode-node": ^2.2.0 + "@smithy/util-endpoints": ^1.1.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-retry": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + fast-xml-parser: 4.2.5 + tslib: ^2.5.0 + peerDependencies: + "@aws-sdk/credential-provider-node": ^3.515.0 + checksum: 9af6a2484909e88a83c411551d55ad149c80a8f449c2e54c499769535243602a6283cd71f6a0cf975b295a321a74e90eb95f6659bba93bae3d12e2186e7545f4 + languageName: node + linkType: hard + "@aws-sdk/config-resolver@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/config-resolver@npm:3.6.1" @@ -1229,6 +1421,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.513.0": + version: 3.513.0 + resolution: "@aws-sdk/core@npm:3.513.0" + dependencies: + "@smithy/core": ^1.3.2 + "@smithy/protocol-http": ^3.1.1 + "@smithy/signature-v4": ^2.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 94a41263e5d0c754f4d6d603572704822b570d5fc5ed450c8eb461b989198b625d2c115a470b087defe2c6c45b9442527062382c9bb1ca32842332317300b2fe + languageName: node + linkType: hard + "@aws-sdk/credential-provider-cognito-identity@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.425.0" @@ -1278,6 +1484,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/property-provider": ^2.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 3573bc3f1aa89bc8eedb9eb39c8c1d501a68aec5eb059364a1091c8bf10dfda9cfbd78ee49d3ad25ec1012f765a4464363c4cd70997e94005fd21245871a4229 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-env@npm:3.6.1" @@ -1304,6 +1522,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/property-provider": ^2.1.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/util-stream": ^2.1.1 + tslib: ^2.5.0 + checksum: d13943dc7a83c9c129dd03a8b337b7753c791441d65a894085e00146d807738b420b9127f570a32497665be4f6e1cc8eeefd7cb1b013a04789d874c8b23b829f + languageName: node + linkType: hard + "@aws-sdk/credential-provider-imds@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-imds@npm:3.6.1" @@ -1351,6 +1586,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.515.0" + dependencies: + "@aws-sdk/client-sts": 3.515.0 + "@aws-sdk/credential-provider-env": 3.515.0 + "@aws-sdk/credential-provider-process": 3.515.0 + "@aws-sdk/credential-provider-sso": 3.515.0 + "@aws-sdk/credential-provider-web-identity": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@smithy/credential-provider-imds": ^2.2.1 + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: c136d4257460be8331d7645854b7e3c91205a1eb12efd3dcbcd84501c787085292d1ccc4578c4776308d91748bde5af2c6eaa873b56aed83ab472a878a2d8883 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-ini@npm:3.6.1" @@ -1401,6 +1655,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.515.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.515.0 + "@aws-sdk/credential-provider-http": 3.515.0 + "@aws-sdk/credential-provider-ini": 3.515.0 + "@aws-sdk/credential-provider-process": 3.515.0 + "@aws-sdk/credential-provider-sso": 3.515.0 + "@aws-sdk/credential-provider-web-identity": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@smithy/credential-provider-imds": ^2.2.1 + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: c51f267c61de0d82afe47d97cdf58971e3b8eec6e7364fe28d3867addd65e156358b96c39a335fad521e9a6925b349a967ae3eed1aa6e9cb4bcc1f0931e3ed50 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-node@npm:3.6.1" @@ -1443,6 +1717,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 11159b4c9502218ec6cba9a46ddc120e53aec7f04507c14d4e99a186073cfd363af438c623705890a2e8f6cc792475ebd14c82766dad82dabf7f20d9708f7faf + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-process@npm:3.6.1" @@ -1486,6 +1773,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.515.0" + dependencies: + "@aws-sdk/client-sso": 3.515.0 + "@aws-sdk/token-providers": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: fbe1eebc50e9bd3715bca4e6ee1a2922cf1ea383953730a6bd3b88b12f23a95cb3ff0edaf8578c81156ef65648eb0295f5c39ed6ae2c8e16b6ce9d4c46f207e9 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.425.0" @@ -1510,6 +1812,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.515.0" + dependencies: + "@aws-sdk/client-sts": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@smithy/property-provider": ^2.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: f0a7e9855f78849143c3139c613cc1531a88272f3ec039d23ac9366b94495a3e07212ade9541e56c93560ad9f58600376e9de38706a4cd97aaf5f400911361cd + languageName: node + linkType: hard + "@aws-sdk/credential-providers@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-providers@npm:3.425.0" @@ -1708,6 +2023,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-host-header@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/protocol-http": ^3.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: ff066cf47b0ba2c64bd70efdec795ac2da8bad7ba8dd44913c98f42b153ca6e753b13b6c1ef7075499590279a5cc49b5a60511dae4512dcdb11a62a0e67fa061 + languageName: node + linkType: hard + "@aws-sdk/middleware-host-header@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-host-header@npm:3.6.1" @@ -1752,6 +2079,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-logger@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/middleware-logger@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 32d251e77f43593ffdd192a4d0628f33773e29c14a3001a4c6519553e94958edbd0fb8e6954a65d1180b0caa16cafe9fc9b362d1ab663db1d1eac84b15667645 + languageName: node + linkType: hard + "@aws-sdk/middleware-logger@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-logger@npm:3.6.1" @@ -1786,6 +2124,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-recursion-detection@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/protocol-http": ^3.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 23c4a1e4d7de86196acfcfbc84bea84c8c3211c4831fdc7c975a6388022037bd5baa4e5809dca188631f06726b51fcf85af358f9ef526e255dc494b368d0da0c + languageName: node + linkType: hard + "@aws-sdk/middleware-retry@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-retry@npm:3.6.1" @@ -1937,6 +2287,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@aws-sdk/util-endpoints": 3.515.0 + "@smithy/protocol-http": ^3.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: fd601cb0367d42e38b71494c773d82bde8970f9aafbdbf18d7cadc25732ecc2b787f0cfef2110755d0cef73d6aa3ce2ca77dc5353854bedaf392a69019b39ac2 + languageName: node + linkType: hard + "@aws-sdk/middleware-user-agent@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-user-agent@npm:3.6.1" @@ -2040,6 +2403,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/region-config-resolver@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/types": ^2.9.1 + "@smithy/util-config-provider": ^2.2.1 + "@smithy/util-middleware": ^2.1.1 + tslib: ^2.5.0 + checksum: 0ed7fbd6390baebdf511b30877236fa8be8716e0162e2c9e0138c9b41ebda7d99a6f3d6cf66cb4af24761631c2c29102ecfe7a5f08894e1de3c98ca6a135fa74 + languageName: node + linkType: hard + "@aws-sdk/s3-presigned-post@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/s3-presigned-post@npm:3.425.0" @@ -2214,6 +2591,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/token-providers@npm:3.515.0" + dependencies: + "@aws-sdk/client-sso-oidc": 3.515.0 + "@aws-sdk/types": 3.515.0 + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: ab51c440da9772d0ee58948241b975705c171395cf1bad81a4ffd8f11f34106af54dcba930e360fb19489beedc1106ac14432bd27abdfe4b7536dc2113841027 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/types@npm:3.425.0" @@ -2234,6 +2625,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/types@npm:3.515.0" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 0874f1814b58eae6e7115c3d08c2bc56e558e73d1ff8c5f833a73b4a0f76a42743c83c36a4b2759177e41b1feff065e85450f7bc235a087b94e67db12f87d298 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/types@npm:3.6.1" @@ -2361,6 +2762,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-endpoints@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/util-endpoints@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/types": ^2.9.1 + "@smithy/util-endpoints": ^1.1.1 + tslib: ^2.5.0 + checksum: 1ab8fcd3054dc0366f10813a01130d05f4ba33f1488c1a168f44881cb24f3fbc2393111b7b0fd4dc06c852e4c9a5bbe8a82717b72229b0977cdba8a631ddeee1 + languageName: node + linkType: hard + "@aws-sdk/util-format-url@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/util-format-url@npm:3.425.0" @@ -2424,6 +2837,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-browser@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/types": ^2.9.1 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: 40f518006cb7e76d06d83dcf05222b0b0ff47c10b63149cd5db2c0c1db79c8eff34bd582e89c748897bc11697b7b357bdca77d569f57ad0b2081c088752d601f + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-browser@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/util-user-agent-browser@npm:3.6.1" @@ -2469,6 +2894,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:3.515.0": + version: 3.515.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.515.0" + dependencies: + "@aws-sdk/types": 3.515.0 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 4e91d9cd5bbe4aa8321417ea1bd9caf3229416ee624b7f67b5206b284a539116692412ca41d60dcb5759b841fed7d9fb570915566d1f7e620578657f89548a23 + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-node@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/util-user-agent-node@npm:3.6.1" @@ -10520,6 +10962,16 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/abort-controller@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 4bfad0d6b3a75bd1e6f997aa41cc9d8ba8bfdf548cfe703553ad7b42f0bf3e06b595d584be7b9ab90d2e3b22aacad94c02c32e21bea96e46933443f09c59523a + languageName: node + linkType: hard + "@smithy/chunked-blob-reader-native@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/chunked-blob-reader-native@npm:2.0.0" @@ -10565,6 +11017,19 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/config-resolver@npm:2.1.1" + dependencies: + "@smithy/node-config-provider": ^2.2.1 + "@smithy/types": ^2.9.1 + "@smithy/util-config-provider": ^2.2.1 + "@smithy/util-middleware": ^2.1.1 + tslib: ^2.5.0 + checksum: 18c8af60cbc528887a82dc0eabaf0b398d868511dc6b10fa01f41c77ea9c2679ab2137feaee51aa9060dbc5c46fc33325a659f4bd54549c203f64e15dbacbc0a + languageName: node + linkType: hard + "@smithy/core@npm:^1.2.1": version: 1.2.2 resolution: "@smithy/core@npm:1.2.2" @@ -10581,6 +11046,22 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^1.3.2": + version: 1.3.2 + resolution: "@smithy/core@npm:1.3.2" + dependencies: + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-retry": ^2.1.1 + "@smithy/middleware-serde": ^2.1.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/util-middleware": ^2.1.1 + tslib: ^2.5.0 + checksum: 5c716b170aa8fb6485b7c98d2d59c44a7333566345727472fb9fabbe86473b33f090fa7a3e08de6ca10829a048c5f20bd238da7da471214789171c7e0a4460a9 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^2.0.0": version: 2.0.12 resolution: "@smithy/credential-provider-imds@npm:2.0.12" @@ -10620,6 +11101,19 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/credential-provider-imds@npm:2.2.1" + dependencies: + "@smithy/node-config-provider": ^2.2.1 + "@smithy/property-provider": ^2.1.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + tslib: ^2.5.0 + checksum: a4e693719384440718728772ea2126be133bbc83fa7bfcefd236942ccb28d1390f1b32fe3262bf330ba4c8e600d01ac73a57110eb42462ec1eb6bbd51e2676a6 + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/eventstream-codec@npm:2.0.10" @@ -10644,6 +11138,18 @@ __metadata: languageName: node linkType: hard +"@smithy/eventstream-codec@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/eventstream-codec@npm:2.1.1" + dependencies: + "@aws-crypto/crc32": 3.0.0 + "@smithy/types": ^2.9.1 + "@smithy/util-hex-encoding": ^2.1.1 + tslib: ^2.5.0 + checksum: 7e59028a69e669d1ca1a0fef788f9892a427fad32f33ded731cbfa3bde0163acbc1e7d207e0ce3eae2d3b53f48dce7a99ded092122cdf78e4f392cffd762bfe3 + languageName: node + linkType: hard + "@smithy/eventstream-serde-browser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/eventstream-serde-browser@npm:2.0.10" @@ -10726,6 +11232,19 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^2.4.1": + version: 2.4.1 + resolution: "@smithy/fetch-http-handler@npm:2.4.1" + dependencies: + "@smithy/protocol-http": ^3.1.1 + "@smithy/querystring-builder": ^2.1.1 + "@smithy/types": ^2.9.1 + "@smithy/util-base64": ^2.1.1 + tslib: ^2.5.0 + checksum: c23701d45bca6842b5206939ccd587e3482ace9f656ae3dca92ff0bad3fefb846cc33683dff41a19186f2a5662ca6cd66c8aefda4664b7dfd95f9a616055a1c1 + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/hash-blob-browser@npm:2.0.10" @@ -10762,6 +11281,18 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-node@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/hash-node@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + "@smithy/util-buffer-from": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + checksum: 5d5aae69b94dcb8abaf9f6a5b53ee320c9e126445c4540fcf2169e8ea7ebd953acff7fd77ba514614f6ebbb0baf412e878eebcc3427a5b9b6f8ee39abbc59230 + languageName: node + linkType: hard + "@smithy/hash-stream-node@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/hash-stream-node@npm:2.0.10" @@ -10793,6 +11324,16 @@ __metadata: languageName: node linkType: hard +"@smithy/invalid-dependency@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/invalid-dependency@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: f95ecd9acd337a408b6608a3f451b24a61e26149878f61fc7855c724888f0d28abf0b798d16990dadb7eafc8027098f934c0cd44e75d01d31617bd1fbfd93935 + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/is-array-buffer@npm:2.0.0" @@ -10802,6 +11343,15 @@ __metadata: languageName: node linkType: hard +"@smithy/is-array-buffer@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/is-array-buffer@npm:2.1.1" + dependencies: + tslib: ^2.5.0 + checksum: 5dbf9ed59715c871321d0624e3842340c1d662d2e8b78313d1658d39eb793b3ac5c379d573eba0a2ca3add9b313848d4d93fd04bb8565c75fbab749928b239a6 + languageName: node + linkType: hard + "@smithy/md5-js@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/md5-js@npm:2.0.10" @@ -10835,6 +11385,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-content-length@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/middleware-content-length@npm:2.1.1" + dependencies: + "@smithy/protocol-http": ^3.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: cb0ea801f72a1a01f5956b3526df930fc19762b07d43a3871ff29815f621603410753d37710d72675d9761b93da32a38cfd5195582de8b6a47e299b1f073be25 + languageName: node + linkType: hard + "@smithy/middleware-endpoint@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/middleware-endpoint@npm:2.0.10" @@ -10878,6 +11439,21 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^2.4.1": + version: 2.4.1 + resolution: "@smithy/middleware-endpoint@npm:2.4.1" + dependencies: + "@smithy/middleware-serde": ^2.1.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/url-parser": ^2.1.1 + "@smithy/util-middleware": ^2.1.1 + tslib: ^2.5.0 + checksum: 685f74c76cba205bdb20ad7bda449b73e498ae2e9074a026d48b38c7b4456d8a0cfb4fdb48625b65f93f3a75e92eaf7951db28f8e9f44e50ce18fd59a7b325af + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^2.0.13": version: 2.0.13 resolution: "@smithy/middleware-retry@npm:2.0.13" @@ -10911,6 +11487,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/middleware-retry@npm:2.1.1" + dependencies: + "@smithy/node-config-provider": ^2.2.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/service-error-classification": ^2.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-retry": ^2.1.1 + tslib: ^2.5.0 + uuid: ^8.3.2 + checksum: a4bc59d2ff8f65367aeb93391a2aafc7caf8031d8b2dfb32ee35748cdc46e06d5182c37bee90d7a107e890959bd40e6a7f4041bc1b0b36a99d14919b1cc78812 + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/middleware-serde@npm:2.0.10" @@ -10941,6 +11534,16 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/middleware-serde@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: ed77b80ac6b68640ee4bf8310bc4d9f5aa13de2741333f6f03a4983e897fa66e0de057d178e78d9ba095d5686d3e4531437c9dd2583366efe948bd75b2aa8581 + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^2.0.10, @smithy/middleware-stack@npm:^2.0.9": version: 2.0.10 resolution: "@smithy/middleware-stack@npm:2.0.10" @@ -10971,6 +11574,16 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-stack@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/middleware-stack@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 0d7c1051c96fcf19f7d5e96bc59484ce13df4e570c1da3eda74d23a7911b41eb61d6c378aad0aa21f7e9c72934148bdf39f9767c57abd4845aa4417a84e3f6e4 + languageName: node + linkType: hard + "@smithy/node-config-provider@npm:^2.0.12": version: 2.0.12 resolution: "@smithy/node-config-provider@npm:2.0.12" @@ -11019,6 +11632,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-config-provider@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/node-config-provider@npm:2.2.1" + dependencies: + "@smithy/property-provider": ^2.1.1 + "@smithy/shared-ini-file-loader": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 62ed3124d888a10cac633a250fbe12d6c5b8aa75ea691889abebce227cbaf155f3db00fa6beb453fbd6147e667e70819d043da1750980669451281a28eafd285 + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^2.1.6": version: 2.1.6 resolution: "@smithy/node-http-handler@npm:2.1.6" @@ -11058,6 +11683,19 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^2.3.1": + version: 2.3.1 + resolution: "@smithy/node-http-handler@npm:2.3.1" + dependencies: + "@smithy/abort-controller": ^2.1.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/querystring-builder": ^2.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: e6a514098f44cfc962318b15df79bb5e9de7fffe883fe073965879b2cf2436726709b5be14262871794104272e8506f793f8e77b8bf5b36398714a3a51512516 + languageName: node + linkType: hard + "@smithy/property-provider@npm:^2.0.0, @smithy/property-provider@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/property-provider@npm:2.0.10" @@ -11098,6 +11736,16 @@ __metadata: languageName: node linkType: hard +"@smithy/property-provider@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/property-provider@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: e87d70c4efe07e830cfb2094b046af89175b87b13259fba37641aa7bfc2ab0c7bf2397797ac48b92e1feb11bf6129b82b350519172093efd7ac4d3a4a98bbe2f + languageName: node + linkType: hard + "@smithy/protocol-http@npm:^3.0.11, @smithy/protocol-http@npm:^3.0.12": version: 3.0.12 resolution: "@smithy/protocol-http@npm:3.0.12" @@ -11128,6 +11776,16 @@ __metadata: languageName: node linkType: hard +"@smithy/protocol-http@npm:^3.1.1": + version: 3.1.1 + resolution: "@smithy/protocol-http@npm:3.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: a5be1c5b5bff18c5a35c23870e1ffa38da33e56f93bdd8f26c615f4c0d2d3e1effffe441e756c0b0ba3aad2dd0845332f634702bf8455ed865a04eebfef1329b + languageName: node + linkType: hard + "@smithy/querystring-builder@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/querystring-builder@npm:2.0.10" @@ -11161,6 +11819,17 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-builder@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/querystring-builder@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + "@smithy/util-uri-escape": ^2.1.1 + tslib: ^2.5.0 + checksum: b8623c7ef6d19fb21c41bfda29cce9c673ac501914085b39642ff5a72cf5742b19cd9de1a1851d13f2e1bbfc2e9522070b5ca32ed906aacf93f732a56e76098a + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/querystring-parser@npm:2.0.10" @@ -11201,6 +11870,16 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-parser@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/querystring-parser@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: bfac40793b0e42f4e25137db4e7d866debfa32557359cc41e02a23174a6fd8e0132f098cef5669a3ddf5211e477c9c97d4aa9039b35c7b4a29f2207236da236e + languageName: node + linkType: hard + "@smithy/service-error-classification@npm:^2.0.3": version: 2.0.3 resolution: "@smithy/service-error-classification@npm:2.0.3" @@ -11219,6 +11898,15 @@ __metadata: languageName: node linkType: hard +"@smithy/service-error-classification@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/service-error-classification@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + checksum: 59a5e3cb0fb42d70fc2d85814124abbff60e28cc9aa45d87fde3370e25943abaf4b6baf62cc40e496c3687e9fa9161156a055ad29a4f7ce8dd7d937bbf49f9a7 + languageName: node + linkType: hard + "@smithy/shared-ini-file-loader@npm:^2.0.11, @smithy/shared-ini-file-loader@npm:^2.0.6": version: 2.0.11 resolution: "@smithy/shared-ini-file-loader@npm:2.0.11" @@ -11259,6 +11947,16 @@ __metadata: languageName: node linkType: hard +"@smithy/shared-ini-file-loader@npm:^2.3.1": + version: 2.3.1 + resolution: "@smithy/shared-ini-file-loader@npm:2.3.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 89b0dfb65faab917fcb4a6a8f34a85d668a759ccbfd6c4dc3d6311e59a8f1b78baab1d97402c333d2207da810cb00de9d5b4379f114bde82135f9aa0d0069cab + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^2.0.0": version: 2.0.9 resolution: "@smithy/signature-v4@npm:2.0.9" @@ -11275,6 +11973,22 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/signature-v4@npm:2.1.1" + dependencies: + "@smithy/eventstream-codec": ^2.1.1 + "@smithy/is-array-buffer": ^2.1.1 + "@smithy/types": ^2.9.1 + "@smithy/util-hex-encoding": ^2.1.1 + "@smithy/util-middleware": ^2.1.1 + "@smithy/util-uri-escape": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + checksum: fa3d4728b0bcf98e606a6e13a47f91efc6eb9edb6925ba48c3b9cecdf8170adf27e28a0684dabe385e8a7379d0743f52b19cd9a1a01884cd0f75c048c4324fd2 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^2.1.15": version: 2.1.15 resolution: "@smithy/smithy-client@npm:2.1.15" @@ -11313,6 +12027,20 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^2.3.1": + version: 2.3.1 + resolution: "@smithy/smithy-client@npm:2.3.1" + dependencies: + "@smithy/middleware-endpoint": ^2.4.1 + "@smithy/middleware-stack": ^2.1.1 + "@smithy/protocol-http": ^3.1.1 + "@smithy/types": ^2.9.1 + "@smithy/util-stream": ^2.1.1 + tslib: ^2.5.0 + checksum: 9b13c361528b3120b1a1db17cd60521d04c72f664c2709be20934cea12756117441d2a33d0464ff3099be11ccb12946c62ece1126b9532eb8f6243a35d6fd171 + languageName: node + linkType: hard + "@smithy/types@npm:^2.3.3": version: 2.3.3 resolution: "@smithy/types@npm:2.3.3" @@ -11349,6 +12077,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^2.9.1": + version: 2.9.1 + resolution: "@smithy/types@npm:2.9.1" + dependencies: + tslib: ^2.5.0 + checksum: 8570affb4abb5d0ead57293977fc915d44be481120defcabb87a3fb1c7b5d2501b117835eca357b5d54ea4bbee08032f9dc3d909ecbf0abb0cec2ca9678ae7bd + languageName: node + linkType: hard + "@smithy/url-parser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/url-parser@npm:2.0.10" @@ -11393,6 +12130,17 @@ __metadata: languageName: node linkType: hard +"@smithy/url-parser@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/url-parser@npm:2.1.1" + dependencies: + "@smithy/querystring-parser": ^2.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 5c939f3ff9c53a0b7a0c5a1ac7641f229598d2bf9499e1abf4d33c1c1cd13bd5f7fcfffd00c366ca9f8092d28979a4a958b80f9bbc91e817e4d1940451e93489 + languageName: node + linkType: hard + "@smithy/util-base64@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-base64@npm:2.0.0" @@ -11413,6 +12161,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-base64@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-base64@npm:2.1.1" + dependencies: + "@smithy/util-buffer-from": ^2.1.1 + tslib: ^2.5.0 + checksum: 6dbb93b8745798d56476d37c99dc9f53fe5fc29329b8161fc9e5c55c5a3062916b3e5e4dd596541b248979eefa550d8da7fbb6ab254bf069cb4c920aea6c3590 + languageName: node + linkType: hard + "@smithy/util-body-length-browser@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-body-length-browser@npm:2.0.0" @@ -11431,6 +12189,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-browser@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-body-length-browser@npm:2.1.1" + dependencies: + tslib: ^2.5.0 + checksum: 6f7808a41b57a5ab1334f0d036ecec6809a959bcfe6a200f985f35e0c96e72f34fdcb6154873f795835d1d927098055e2dec31ebfb5e5382d1c4c612c80a37c0 + languageName: node + linkType: hard + "@smithy/util-body-length-node@npm:^2.1.0": version: 2.1.0 resolution: "@smithy/util-body-length-node@npm:2.1.0" @@ -11440,6 +12207,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-node@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/util-body-length-node@npm:2.2.1" + dependencies: + tslib: ^2.5.0 + checksum: 6bddc6fac7c9875ae7baaf6088d91192fbe4405bc5c1b69100d52aa1bfebabcc194f5f1b159d8f6f3ade3b54e416f185781970c30a97d4b0a7cec6d02fc490c4 + languageName: node + linkType: hard + "@smithy/util-buffer-from@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-buffer-from@npm:2.0.0" @@ -11450,6 +12226,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-buffer-from@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-buffer-from@npm:2.1.1" + dependencies: + "@smithy/is-array-buffer": ^2.1.1 + tslib: ^2.5.0 + checksum: 8dc7f9afaa356696f14a80cd983a750cbad8eba7c46498ed74fb8ec0cb307f14df64fb10ceb30b2d4792395bb8b216c89155a93dee0f2b3e5cab94fef459a195 + languageName: node + linkType: hard + "@smithy/util-config-provider@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-config-provider@npm:2.0.0" @@ -11468,6 +12254,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-config-provider@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/util-config-provider@npm:2.2.1" + dependencies: + tslib: ^2.5.0 + checksum: f5b34bcf6ef944779f20d7639070e87a521e1a5620e5a91f2d2dbd764824985930a68b71b0b2bde12e1eaac947155789b73a8c09c1aa7ab923f08e42a4173ef4 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-browser@npm:^2.0.13": version: 2.0.13 resolution: "@smithy/util-defaults-mode-browser@npm:2.0.13" @@ -11494,6 +12289,19 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-defaults-mode-browser@npm:2.1.1" + dependencies: + "@smithy/property-provider": ^2.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: 5d3b11be1768410e24ad9829dc70bed9b50419f85a8ca934c6296e21e278d87f665cfdb603241ef749f80d154a2c4be26cd29338daecc625d31b30af8bd9c139 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^2.0.15": version: 2.0.15 resolution: "@smithy/util-defaults-mode-node@npm:2.0.15" @@ -11524,6 +12332,21 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-defaults-mode-node@npm:2.2.0" + dependencies: + "@smithy/config-resolver": ^2.1.1 + "@smithy/credential-provider-imds": ^2.2.1 + "@smithy/node-config-provider": ^2.2.1 + "@smithy/property-provider": ^2.1.1 + "@smithy/smithy-client": ^2.3.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: c4a69b73bc46c3bb5ff4149b80bdfa79f4c25b82253d9c7168c9920066e12830e1bea324dce09414b09791fd0379bdc05c39117155d5b37a229d226962a95d5f + languageName: node + linkType: hard + "@smithy/util-endpoints@npm:^1.0.7": version: 1.0.8 resolution: "@smithy/util-endpoints@npm:1.0.8" @@ -11535,6 +12358,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-endpoints@npm:^1.1.1": + version: 1.1.1 + resolution: "@smithy/util-endpoints@npm:1.1.1" + dependencies: + "@smithy/node-config-provider": ^2.2.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 40619bf739c1fc959486946cb49319f34c9c4c5c19f46cdefc7ff8e7331b84f6ad7a4aeb8a0268f6d77d266ff5ec9df8d2244094dd79ae469983e9c07e43766a + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-hex-encoding@npm:2.0.0" @@ -11544,6 +12378,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-hex-encoding@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-hex-encoding@npm:2.1.1" + dependencies: + tslib: ^2.5.0 + checksum: eae5c94fd4d57dccbae5ad4d7684787b1e9b1df944cf9fcb497cbefaed6aec49c0a777cc1ea4d10fa7002b82f0b73b8830ae2efe98ed35a62dcf3c4f7d08a4cd + languageName: node + linkType: hard + "@smithy/util-middleware@npm:^2.0.2": version: 2.0.2 resolution: "@smithy/util-middleware@npm:2.0.2" @@ -11584,6 +12427,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-middleware@npm:2.1.1" + dependencies: + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 404bb944202df70ba0ff8bb6ea105ead0a6b365d5ef7bfafbfc919df228823563818f0ee36f0f1e20462200da2fb8c8961e20b237e4e1bd9f77c38dd701f39ab + languageName: node + linkType: hard + "@smithy/util-retry@npm:^2.0.3": version: 2.0.3 resolution: "@smithy/util-retry@npm:2.0.3" @@ -11606,6 +12459,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-retry@npm:2.1.1" + dependencies: + "@smithy/service-error-classification": ^2.1.1 + "@smithy/types": ^2.9.1 + tslib: ^2.5.0 + checksum: 1747c75f55a208f16104483cd76ec45200dedaa924868e84d4882b88f8b4a8d3a4422834359fd9bfba242e0e96a474349ac0a6f5d804fb15b15e8b639b6d2ad0 + languageName: node + linkType: hard + "@smithy/util-stream@npm:^2.0.14": version: 2.0.14 resolution: "@smithy/util-stream@npm:2.0.14" @@ -11654,6 +12518,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-stream@npm:2.1.1" + dependencies: + "@smithy/fetch-http-handler": ^2.4.1 + "@smithy/node-http-handler": ^2.3.1 + "@smithy/types": ^2.9.1 + "@smithy/util-base64": ^2.1.1 + "@smithy/util-buffer-from": ^2.1.1 + "@smithy/util-hex-encoding": ^2.1.1 + "@smithy/util-utf8": ^2.1.1 + tslib: ^2.5.0 + checksum: 3a060226b8a506e722d0d8c1c4b7a2989241f7946c8acc892a8a70d92d9952cc8619b14bf686c9c822115d99159c6c16534bad2d72ecc2809a56f865224e82a6 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-uri-escape@npm:2.0.0" @@ -11663,6 +12543,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-uri-escape@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-uri-escape@npm:2.1.1" + dependencies: + tslib: ^2.5.0 + checksum: 822ed7390e28d5c7b8dab5e5c5a8de998e0778220137962a71b47b2d8900289d48a3a2c9945e68e1cac921d43f61660045e7fdffe8df9e63004575fcf2aa99b2 + languageName: node + linkType: hard + "@smithy/util-utf8@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-utf8@npm:2.0.0" @@ -11683,6 +12572,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-utf8@npm:^2.1.1": + version: 2.1.1 + resolution: "@smithy/util-utf8@npm:2.1.1" + dependencies: + "@smithy/util-buffer-from": ^2.1.1 + tslib: ^2.5.0 + checksum: 286ce5cba3f45a8abd3d6c28e40b3204dd64300340818d77e42c1cbb0c2f6ad0c42f0e47ffaf38d74d0895b0dfd1750c5b55222ab4d205a3b39da4325971d303 + languageName: node + linkType: hard + "@smithy/util-waiter@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/util-waiter@npm:2.0.10" @@ -13049,13 +13948,6 @@ __metadata: languageName: node linkType: hard -"@types/ungap__structured-clone@npm:^0.3.0": - version: 0.3.0 - resolution: "@types/ungap__structured-clone@npm:0.3.0" - checksum: 1502276e64645c2157e7a6d0dc6b572787b99f5a4e64d11711fcc05edb9c5be5d0a7827939d269746a29f24ae96c9581b16f12d00604dacfbfdc6b62ba6025c5 - languageName: node - linkType: hard - "@types/uniqid@npm:^5.3.2": version: 5.3.2 resolution: "@types/uniqid@npm:5.3.2" @@ -13665,7 +14557,6 @@ __metadata: "@babel/preset-env": ^7.23.9 "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.23.9 - "@types/ungap__structured-clone": ^0.3.0 "@webiny/api": 0.0.0 "@webiny/api-admin-users": 0.0.0 "@webiny/api-authentication": 0.0.0 @@ -15128,6 +16019,43 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-websockets@0.0.0, @webiny/api-websockets@workspace:packages/api-websockets": + version: 0.0.0-use.local + resolution: "@webiny/api-websockets@workspace:packages/api-websockets" + dependencies: + "@babel/cli": ^7.23.9 + "@babel/core": ^7.23.9 + "@babel/preset-env": ^7.23.9 + "@babel/preset-typescript": ^7.23.3 + "@babel/runtime": ^7.23.9 + "@types/aws-lambda": ^8.10.131 + "@webiny/api": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-i18n": 0.0.0 + "@webiny/api-security": 0.0.0 + "@webiny/api-tenancy": 0.0.0 + "@webiny/api-wcp": 0.0.0 + "@webiny/aws-sdk": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/db-dynamodb": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 + "@webiny/handler-aws": 0.0.0 + "@webiny/handler-db": 0.0.0 + "@webiny/handler-graphql": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/utils": 0.0.0 + aws-lambda: ^1.0.7 + graphql: ^15.8.0 + rimraf: ^5.0.5 + ttypescript: ^1.5.13 + type-fest: ^2.19.0 + typescript: 4.7.4 + zod: ^3.22.4 + languageName: unknown + linkType: soft + "@webiny/api@0.0.0, @webiny/api@workspace:packages/api": version: 0.0.0-use.local resolution: "@webiny/api@workspace:packages/api" @@ -16284,6 +17212,7 @@ __metadata: "@webiny/app-security-access-management": 0.0.0 "@webiny/app-tenancy": 0.0.0 "@webiny/app-tenant-manager": 0.0.0 + "@webiny/app-websockets": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/lexical-editor-actions": 0.0.0 "@webiny/lexical-editor-pb-element": 0.0.0 @@ -16507,6 +17436,30 @@ __metadata: languageName: unknown linkType: soft +"@webiny/app-websockets@0.0.0, @webiny/app-websockets@workspace:packages/app-websockets": + version: 0.0.0-use.local + resolution: "@webiny/app-websockets@workspace:packages/app-websockets" + dependencies: + "@aws-amplify/auth": ^5.1.9 + "@babel/cli": ^7.23.9 + "@babel/core": ^7.23.9 + "@babel/preset-env": ^7.23.9 + "@babel/preset-react": ^7.23.3 + "@babel/preset-typescript": ^7.23.3 + "@webiny/app": 0.0.0 + "@webiny/app-i18n": 0.0.0 + "@webiny/app-tenancy": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/utils": 0.0.0 + react: 17.0.2 + react-dom: 17.0.2 + rimraf: ^5.0.5 + ttypescript: ^1.5.12 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/app@0.0.0, @webiny/app@workspace:packages/app": version: 0.0.0-use.local resolution: "@webiny/app@workspace:packages/app" @@ -16575,6 +17528,7 @@ __metadata: version: 0.0.0-use.local resolution: "@webiny/aws-sdk@workspace:packages/aws-sdk" dependencies: + "@aws-sdk/client-apigatewaymanagementapi": ^3.425.0 "@aws-sdk/client-cloudfront": ^3.425.0 "@aws-sdk/client-cloudwatch-events": ^3.425.0 "@aws-sdk/client-cloudwatch-logs": ^3.425.0 @@ -18736,6 +19690,7 @@ __metadata: "@webiny/api-tenancy-so-ddb": 0.0.0 "@webiny/api-tenant-manager": 0.0.0 "@webiny/api-wcp": 0.0.0 + "@webiny/api-websockets": 0.0.0 "@webiny/aws-sdk": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/cli-plugin-deploy-pulumi": 0.0.0 @@ -19439,6 +20394,20 @@ __metadata: languageName: node linkType: hard +"aws-lambda@npm:^1.0.7": + version: 1.0.7 + resolution: "aws-lambda@npm:1.0.7" + dependencies: + aws-sdk: ^2.814.0 + commander: ^3.0.2 + js-yaml: ^3.14.1 + watchpack: ^2.0.0-beta.10 + bin: + lambda: bin/lambda + checksum: 11316e87b5c4fc36e6bd0495742a3c0ed13befc9527a7b251a58180d141d9afd68b684f37aeb3b53d117d3c2f96747eace31826b683543f1edddc03f392865fd + languageName: node + linkType: hard + "aws-sdk@npm:^2.0.0": version: 2.1310.0 resolution: "aws-sdk@npm:2.1310.0" @@ -19457,6 +20426,24 @@ __metadata: languageName: node linkType: hard +"aws-sdk@npm:^2.814.0": + version: 2.1567.0 + resolution: "aws-sdk@npm:2.1567.0" + dependencies: + buffer: 4.9.2 + events: 1.1.1 + ieee754: 1.1.13 + jmespath: 0.16.0 + querystring: 0.2.0 + sax: 1.2.1 + url: 0.10.3 + util: ^0.12.4 + uuid: 8.0.0 + xml2js: 0.6.2 + checksum: 57e7525049708e88a5fc2492db15239d2de5e5bd0f00b59dce615d68fa78afabeba3784ee9165d11d88c21ae228b8546697859046ac6c596311734bc478aebda + languageName: node + linkType: hard + "aws-sign2@npm:~0.7.0": version: 0.7.0 resolution: "aws-sign2@npm:0.7.0" @@ -21139,6 +22126,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^3.0.2": + version: 3.0.2 + resolution: "commander@npm:3.0.2" + checksum: 6d14ad030d1904428139487ed31febcb04c1604db2b8d9fae711f60ee6718828dc0e11602249e91c8a97b0e721e9c6d53edbc166bad3cde1596851d59a8f824d + languageName: node + linkType: hard + "commander@npm:^4.0.1": version: 4.1.1 resolution: "commander@npm:4.1.1" @@ -28608,7 +29602,7 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:3.14.1, js-yaml@npm:^3.10.0, js-yaml@npm:^3.13.1, js-yaml@npm:^3.14.0": +"js-yaml@npm:3.14.1, js-yaml@npm:^3.10.0, js-yaml@npm:^3.13.1, js-yaml@npm:^3.14.0, js-yaml@npm:^3.14.1": version: 3.14.1 resolution: "js-yaml@npm:3.14.1" dependencies: @@ -39240,7 +40234,7 @@ __metadata: languageName: node linkType: hard -"watchpack@npm:^2.4.0": +"watchpack@npm:^2.0.0-beta.10, watchpack@npm:^2.4.0": version: 2.4.0 resolution: "watchpack@npm:2.4.0" dependencies: