├── .prettierignore ├── .prettierrc.yaml ├── src ├── utils │ ├── promise.ts │ ├── date.ts │ ├── index.ts │ ├── isAsyncIterable.ts │ ├── aws.ts │ └── graphql.ts ├── messages │ ├── index.ts │ ├── types.ts │ ├── ping.ts │ ├── pong.ts │ ├── connection_init.ts │ ├── complete.ts │ ├── disconnect.ts │ └── subscribe.ts ├── model │ ├── index.ts │ ├── Connection.ts │ └── Subscription.ts ├── stateMachineHandler.ts ├── index.ts ├── pubsub │ ├── subscribe.ts │ └── publish.ts ├── gateway.ts └── types.ts ├── .gitignore ├── example ├── tsconfig.json ├── terraform │ ├── main.tf │ ├── cloudwatch.tf │ ├── machine.tf │ ├── dynamodb.tf │ ├── lambda.tf │ ├── apigateway.tf │ └── iam.tf ├── README.md ├── package.json ├── src │ ├── schema.ts │ └── handler.ts └── serverless.yml ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: es5 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /src/utils/promise.ts: -------------------------------------------------------------------------------- 1 | export const promisify = async (arg: T) => await arg(); 2 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export const addHours = (date: Date, hours: number) => 2 | new Date(date.valueOf() + hours * 1000 * 60 * 60); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | example/.serverless 4 | example/package-lock.json 5 | example/terraform/.* 6 | *.tfstate* 7 | .build 8 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aws'; 2 | export * from './date'; 3 | export * from './isAsyncIterable'; 4 | export * from './promise'; 5 | export * from './graphql'; 6 | -------------------------------------------------------------------------------- /src/messages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './complete'; 2 | export * from './connection_init'; 3 | export * from './disconnect'; 4 | export * from './ping'; 5 | export * from './subscribe'; 6 | -------------------------------------------------------------------------------- /src/utils/isAsyncIterable.ts: -------------------------------------------------------------------------------- 1 | export const isAsyncIterable = (arg: any): arg is AsyncIterable => 2 | arg !== null && 3 | typeof arg == 'object' && 4 | typeof arg[Symbol.asyncIterator] === 'function'; 5 | -------------------------------------------------------------------------------- /src/messages/types.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEvent } from 'aws-lambda'; 2 | import { ServerClosure } from '../types'; 3 | 4 | export type MessageHandler = ( 5 | c: ServerClosure 6 | ) => (arg: { event: APIGatewayEvent; message: T }) => void; 7 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "sourceMap": true, 5 | "target": "es5", 6 | "outDir": ".build", 7 | "moduleResolution": "node", 8 | "lib": ["es2015"], 9 | "rootDir": "./" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/terraform/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 3.49.0" 6 | } 7 | archive = { 8 | source = "hashicorp/archive" 9 | version = "~> 2.0" 10 | } 11 | } 12 | } 13 | 14 | provider "aws" { 15 | region = "us-east-1" 16 | } 17 | -------------------------------------------------------------------------------- /example/terraform/cloudwatch.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "gateway_handler" { 2 | name = "/aws/lambda/${aws_lambda_function.gateway_handler.function_name}" 3 | retention_in_days = 14 4 | } 5 | 6 | resource "aws_cloudwatch_log_group" "websocket_api" { 7 | name = "/aws/apigateway/websocket-api" 8 | retention_in_days = 14 9 | } 10 | -------------------------------------------------------------------------------- /src/model/index.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDbTable } from '@aws/dynamodb-data-mapper'; 2 | import { Class } from '../types'; 3 | 4 | export * from './Connection'; 5 | export * from './Subscription'; 6 | 7 | export const assign: (model: T, properties: Partial) => T = Object.assign; 8 | 9 | export const createModel = ({ 10 | model, 11 | table, 12 | }: { 13 | table: string; 14 | model: T; 15 | }) => { 16 | Object.defineProperties(model.prototype, { 17 | [DynamoDbTable]: { value: table }, 18 | }); 19 | return model; 20 | }; 21 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Example usage of [subscriptionless](https://github.com/andyrichardson/subscriptionless). 4 | 5 | ## Serverless users 6 | 7 | Install dependencies 8 | 9 | ```sh 10 | npm ci 11 | ``` 12 | 13 | Deploy service 14 | 15 | ```sh 16 | $(npm bin)/sls deploy 17 | ``` 18 | 19 | ## Terraform users 20 | 21 | Install dependencies 22 | 23 | ```sh 24 | npm ci 25 | ``` 26 | 27 | Build assets 28 | 29 | ```sh 30 | npm run build 31 | ``` 32 | 33 | Navigate to terraform directory 34 | 35 | ```sh 36 | cd terraform 37 | ``` 38 | 39 | Init terraform 40 | 41 | ```sh 42 | terraform init 43 | ``` 44 | 45 | Apply deployment 46 | 47 | ``` 48 | terraform apply 49 | ``` 50 | -------------------------------------------------------------------------------- /src/messages/ping.ts: -------------------------------------------------------------------------------- 1 | import { PingMessage, MessageType } from 'graphql-ws'; 2 | import { sendMessage, deleteConnection, promisify } from '../utils'; 3 | import { MessageHandler } from './types'; 4 | 5 | /** Handler function for 'ping' message. */ 6 | export const ping: MessageHandler = 7 | (c) => 8 | async ({ event, message }) => { 9 | try { 10 | await promisify(() => c.onPing?.({ event, message })); 11 | return sendMessage({ 12 | ...event.requestContext, 13 | message: { type: MessageType.Pong }, 14 | }); 15 | } catch (err) { 16 | await promisify(() => c.onError?.(err, { event, message })); 17 | await deleteConnection(event.requestContext); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "deploy": "sls deploy", 8 | "build": "microbundle src/handler.ts -f cjs --target node -o dist --external aws-sdk" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "microbundle": "^0.13.3", 14 | "serverless": "^2.51.2", 15 | "serverless-plugin-typescript": "^1.1.9", 16 | "serverless-step-functions": "^2.32.0", 17 | "typescript": "^4.3.5" 18 | }, 19 | "dependencies": { 20 | "@graphql-tools/schema": "^7.1.5", 21 | "aws-sdk": "^2.946.0", 22 | "graphql": "^15.5.1", 23 | "graphql-tag": "^2.12.5", 24 | "subscriptionless": "latest" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/messages/pong.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, PongMessage } from 'graphql-ws'; 2 | import { assign } from '../model'; 3 | import { sendMessage, deleteConnection, promisify } from '../utils'; 4 | import { MessageHandler } from './types'; 5 | 6 | /** Handler function for 'pong' message. */ 7 | export const pong: MessageHandler = 8 | (c) => 9 | async ({ event, message }) => { 10 | try { 11 | await promisify(() => c.onPong?.({ event, message })); 12 | await c.mapper.update( 13 | assign(new c.model.Connection(), { 14 | id: event.requestContext.connectionId!, 15 | hasPonged: true, 16 | }), 17 | { 18 | onMissing: 'skip', 19 | } 20 | ); 21 | } catch (err) { 22 | await promisify(() => c.onError?.(err, { event, message })); 23 | await deleteConnection(event.requestContext); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /example/terraform/machine.tf: -------------------------------------------------------------------------------- 1 | resource "aws_sfn_state_machine" "ping_state_machine" { 2 | name = "ping-state-machine" 3 | role_arn = aws_iam_role.state_machine.arn 4 | definition = jsonencode({ 5 | StartAt = "Wait" 6 | States = { 7 | Wait = { 8 | Type = "Wait" 9 | SecondsPath = "$.seconds" 10 | Next = "Eval" 11 | } 12 | Eval = { 13 | Type = "Task" 14 | Resource = aws_lambda_function.machine.arn 15 | Next = "Choose" 16 | } 17 | Choose = { 18 | Type = "Choice" 19 | Choices = [{ 20 | Not = { 21 | Variable = "$.state" 22 | StringEquals = "ABORT" 23 | } 24 | Next = "Wait" 25 | }] 26 | Default = "End" 27 | } 28 | End = { 29 | Type = "Pass" 30 | End = true 31 | } 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /example/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { subscribe } from 'subscriptionless'; 2 | import gql from 'graphql-tag'; 3 | 4 | export const typeDefs = gql` 5 | type Article { 6 | id: ID! 7 | title: String! 8 | content: String! 9 | } 10 | 11 | type Query { 12 | articles: [Article!]! 13 | } 14 | 15 | type Mutation { 16 | publishArticle(title: String!, content: String!): Article! 17 | } 18 | 19 | type Subscription { 20 | newArticles: [Article!]! 21 | } 22 | `; 23 | 24 | export const resolvers = { 25 | Query: { 26 | articles: () => [], 27 | }, 28 | Mutation: { 29 | publishArticle: () => ({}), 30 | }, 31 | Subscription: { 32 | newArticles: { 33 | resolve: (event, args) => [event.payload], 34 | subscribe: subscribe('NEW_ARTICLE'), 35 | onSubscribe: () => console.log('SUBSCRIBE!'), 36 | onComplete: () => console.log('COMPLETE!'), 37 | }, 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/model/Connection.ts: -------------------------------------------------------------------------------- 1 | import { attribute, hashKey } from '@aws/dynamodb-data-mapper-annotations'; 2 | import { APIGatewayEventRequestContext } from 'aws-lambda'; 3 | import { addHours } from '../utils'; 4 | 5 | /** 6 | * Connection established with `connection_init` 7 | */ 8 | export class Connection { 9 | /* ConnectionID */ 10 | @hashKey({ type: 'String' }) 11 | id: string; 12 | 13 | /** Time of creation */ 14 | @attribute({ defaultProvider: () => new Date() }) 15 | createdAt: Date; 16 | 17 | /** Request context from $connect event */ 18 | @attribute() 19 | requestContext: APIGatewayEventRequestContext; 20 | 21 | /** connection_init payload (post-parse) */ 22 | @attribute() 23 | payload: Record; 24 | 25 | @attribute({ defaultProvider: () => addHours(new Date(), 3) }) 26 | ttl: Date; 27 | 28 | /** has a pong been returned */ 29 | @attribute({ defaultProvider: () => false }) 30 | hasPonged: boolean; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andy Richardson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /example/src/handler.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema'; 2 | import { createInstance, prepareResolvers } from 'subscriptionless'; 3 | import { DynamoDB } from 'aws-sdk'; 4 | import { typeDefs, resolvers } from './schema'; 5 | 6 | const schema = makeExecutableSchema({ 7 | typeDefs, 8 | resolvers: prepareResolvers(resolvers), 9 | }); 10 | 11 | const instance = createInstance({ 12 | dynamodb: new DynamoDB({ 13 | logger: console, 14 | }), 15 | pingpong: { 16 | machine: process.env.PING_STATE_MACHINE_ARN!, 17 | delay: 10, 18 | timeout: 30, 19 | }, 20 | tableNames: { 21 | connections: process.env.CONNECTIONS_TABLE, 22 | subscriptions: process.env.SUBSCRIPTIONS_TABLE, 23 | }, 24 | schema, 25 | onConnectionInit: () => ({}), 26 | onError: console.error, 27 | }); 28 | 29 | export const gatewayHandler = instance.gatewayHandler; 30 | 31 | export const snsHandler = (event) => 32 | Promise.all( 33 | event.Records.map((r) => 34 | instance.publish({ 35 | topic: r.Sns.TopicArn.substring(r.Sns.TopicArn.lastIndexOf(':') + 1), // Get topic name 36 | payload: JSON.parse(r.Sns.Message), 37 | }) 38 | ) 39 | ); 40 | 41 | export const stateMachineHandler = instance.stateMachineHandler; 42 | -------------------------------------------------------------------------------- /src/utils/aws.ts: -------------------------------------------------------------------------------- 1 | import { ApiGatewayManagementApi } from 'aws-sdk'; 2 | import { APIGatewayEventRequestContext } from 'aws-lambda'; 3 | import { 4 | ConnectionAckMessage, 5 | NextMessage, 6 | CompleteMessage, 7 | ErrorMessage, 8 | PingMessage, 9 | PongMessage, 10 | } from 'graphql-ws'; 11 | 12 | export const sendMessage = ( 13 | a: { 14 | message: 15 | | ConnectionAckMessage 16 | | NextMessage 17 | | CompleteMessage 18 | | ErrorMessage 19 | | PingMessage 20 | | PongMessage; 21 | } & Pick< 22 | APIGatewayEventRequestContext, 23 | 'connectionId' | 'domainName' | 'stage' 24 | > 25 | ) => 26 | new ApiGatewayManagementApi({ 27 | apiVersion: 'latest', 28 | endpoint: `${a.domainName}/${a.stage}`, 29 | }) 30 | .postToConnection({ 31 | ConnectionId: a.connectionId!, 32 | Data: JSON.stringify(a.message), 33 | }) 34 | .promise(); 35 | 36 | export const deleteConnection = ( 37 | a: Pick< 38 | APIGatewayEventRequestContext, 39 | 'connectionId' | 'domainName' | 'stage' 40 | > 41 | ) => 42 | new ApiGatewayManagementApi({ 43 | apiVersion: 'latest', 44 | endpoint: `${a.domainName}/${a.stage}`, 45 | }) 46 | .deleteConnection({ ConnectionId: a.connectionId! }) 47 | .promise(); 48 | -------------------------------------------------------------------------------- /src/stateMachineHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from 'graphql-ws'; 2 | import { assign } from './model'; 3 | import { ServerClosure, StateFunctionInput } from './types'; 4 | import { sendMessage, deleteConnection } from './utils'; 5 | 6 | export const handleStateMachineEvent = 7 | (c: ServerClosure) => 8 | async (input: StateFunctionInput): Promise => { 9 | const connection = assign(new c.model.Connection(), { 10 | id: input.connectionId, 11 | }); 12 | 13 | // Initial state - send ping message 14 | if (input.state === 'PING') { 15 | await sendMessage({ ...input, message: { type: MessageType.Ping } }); 16 | await c.mapper.update(assign(connection, { hasPonged: false }), { 17 | onMissing: 'skip', 18 | }); 19 | return { 20 | ...input, 21 | state: 'REVIEW', 22 | seconds: c.ping!.timeout, 23 | }; 24 | } 25 | 26 | // Follow up state - check if pong was returned 27 | const conn = await c.mapper.get(connection); 28 | if (conn.hasPonged) { 29 | return { 30 | ...input, 31 | state: 'PING', 32 | seconds: c.ping?.interval! - c.ping!.timeout, 33 | }; 34 | } 35 | 36 | await deleteConnection({ ...input }); 37 | return { 38 | ...input, 39 | state: 'ABORT', 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | pull_request: 4 | jobs: 5 | install: 6 | runs-on: ubuntu-latest 7 | container: 'node:14' 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Cache node modules 11 | id: cache 12 | uses: actions/cache@v2 13 | with: 14 | path: ./node_modules 15 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 16 | - if: steps.cache.outputs.cache-hit != 'true' 17 | run: npm ci 18 | build: 19 | needs: install 20 | runs-on: ubuntu-latest 21 | container: 'node:14' 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Restore node modules 25 | uses: actions/cache@v2 26 | with: 27 | path: ./node_modules 28 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 29 | - run: npm run build 30 | check-formatting: 31 | name: check formatting 32 | needs: install 33 | runs-on: ubuntu-latest 34 | container: 'node:14' 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Restore node modules 38 | uses: actions/cache@v2 39 | with: 40 | path: ./node_modules 41 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 42 | - run: npm run prettier:check 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | import { DataMapper } from '@aws/dynamodb-data-mapper'; 3 | import { handleGatewayEvent } from './gateway'; 4 | import { createModel, Connection, Subscription } from './model'; 5 | import { publish } from './pubsub/publish'; 6 | import { handleStateMachineEvent } from './stateMachineHandler'; 7 | import { ServerArgs } from './types'; 8 | 9 | export const createInstance = (opts: ServerArgs) => { 10 | if (opts.ping && opts.ping.interval <= opts.ping.timeout) { 11 | throw Error('Ping interval value must be larger than ping timeout.'); 12 | } 13 | 14 | const dynamodb = opts.dynamodb || new DynamoDB(); 15 | const closure = { 16 | ...opts, 17 | model: { 18 | Subscription: createModel({ 19 | model: Subscription, 20 | table: 21 | opts.tableNames?.subscriptions || 'subscriptionless_subscriptions', 22 | }), 23 | Connection: createModel({ 24 | model: Connection, 25 | table: opts.tableNames?.connections || 'subscriptionless_connections', 26 | }), 27 | }, 28 | mapper: new DataMapper({ client: dynamodb }), 29 | } as const; 30 | 31 | return { 32 | gatewayHandler: handleGatewayEvent(closure), 33 | stateMachineHandler: handleStateMachineEvent(closure), 34 | publish: publish(closure), 35 | }; 36 | }; 37 | 38 | export { prepareResolvers } from './utils'; 39 | export * from './pubsub/subscribe'; 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "CommonJS" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "ESNext" 11 | ] /* Specify library files to be included in the compilation. */, 12 | "strict": true /* Enable all strict type-checking options. */, 13 | "strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */, 14 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 15 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 16 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 17 | "skipLibCheck": true /* Skip type checking of declaration files. */, 18 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /example/terraform/dynamodb.tf: -------------------------------------------------------------------------------- 1 | # Connections state 2 | resource "aws_dynamodb_table" "connections" { 3 | name = "subscriptionless_connections" 4 | billing_mode = "PROVISIONED" 5 | read_capacity = 1 6 | write_capacity = 1 7 | hash_key = "id" 8 | 9 | attribute { 10 | name = "id" 11 | type = "S" 12 | } 13 | 14 | ttl { 15 | attribute_name = "ttl" 16 | enabled = true 17 | } 18 | } 19 | 20 | # Subscriptions state 21 | resource "aws_dynamodb_table" "subscriptions" { 22 | name = "subscriptionless_subscriptions" 23 | billing_mode = "PROVISIONED" 24 | read_capacity = 1 25 | write_capacity = 1 26 | hash_key = "id" 27 | range_key = "topic" 28 | 29 | attribute { 30 | name = "id" 31 | type = "S" 32 | } 33 | 34 | attribute { 35 | name = "topic" 36 | type = "S" 37 | } 38 | 39 | attribute { 40 | name = "connectionId" 41 | type = "S" 42 | } 43 | 44 | global_secondary_index { 45 | name = "ConnectionIndex" 46 | hash_key = "connectionId" 47 | write_capacity = 1 48 | read_capacity = 1 49 | projection_type = "ALL" 50 | } 51 | 52 | global_secondary_index { 53 | name = "TopicIndex" 54 | hash_key = "topic" 55 | write_capacity = 1 56 | read_capacity = 1 57 | projection_type = "ALL" 58 | } 59 | 60 | ttl { 61 | attribute_name = "ttl" 62 | enabled = true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/model/Subscription.ts: -------------------------------------------------------------------------------- 1 | import { 2 | attribute, 3 | hashKey, 4 | rangeKey, 5 | } from '@aws/dynamodb-data-mapper-annotations'; 6 | import { APIGatewayEventRequestContext } from 'aws-lambda'; 7 | import { addHours } from '../utils'; 8 | 9 | /** 10 | * Active subscriptions 11 | */ 12 | export class Subscription { 13 | /* 14 | * connectionId|subscriptionId 15 | */ 16 | @hashKey({ type: 'String' }) 17 | id: string; 18 | 19 | @rangeKey({ 20 | type: 'String', 21 | indexKeyConfigurations: { TopicIndex: 'HASH' }, 22 | }) 23 | topic: string; 24 | 25 | @attribute() 26 | filter: object; 27 | 28 | @attribute({ 29 | type: 'String', 30 | indexKeyConfigurations: { ConnectionIndex: 'HASH' }, 31 | }) 32 | connectionId: string; 33 | 34 | @attribute({ type: 'String' }) 35 | subscriptionId: string; 36 | 37 | @attribute({ defaultProvider: () => new Date() }) 38 | createdAt: Date; 39 | 40 | /** Redundant copy of connection_init payload */ 41 | @attribute() 42 | connectionParams: object; 43 | 44 | @attribute() 45 | requestContext: APIGatewayEventRequestContext; 46 | 47 | @attribute() 48 | subscription: { 49 | query: string; 50 | /** Actual value of variables for given field */ 51 | variables?: any; 52 | /** Value of variables for user provided subscription */ 53 | variableValues?: any; 54 | operationName?: string | null; 55 | }; 56 | 57 | @attribute({ defaultProvider: () => addHours(new Date(), 3) }) 58 | ttl: Date; 59 | } 60 | -------------------------------------------------------------------------------- /src/pubsub/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscribeArgs, 3 | SubscribeHandler, 4 | SubscribePsuedoIterable, 5 | SubscriptionDefinition, 6 | } from '../types'; 7 | 8 | /** Creates subscribe handler */ 9 | export const subscribe = 10 | (topic: string) => 11 | (...args: SubscribeArgs) => 12 | createHandler({ definitions: [{ topic }] }); 13 | 14 | /** Add filter to subscribe handler */ 15 | export const withFilter = 16 | ( 17 | handler: SubscribeHandler, 18 | filter: object | ((...args: SubscribeArgs) => object) 19 | ) => 20 | (...args: SubscribeArgs) => { 21 | const iterable = handler(...args); 22 | if (iterable.definitions.length !== 1) { 23 | throw Error("Cannot call 'withFilter' on invalid type"); 24 | } 25 | 26 | return createHandler({ 27 | definitions: [ 28 | { 29 | ...iterable.definitions[0], 30 | filter: typeof filter === 'function' ? filter(...args) : filter, 31 | }, 32 | ], 33 | }); 34 | }; 35 | 36 | /** Merge multiple subscribe handlers */ 37 | export const concat = 38 | (...handlers: SubscribeHandler[]) => 39 | (...args: SubscribeArgs) => 40 | createHandler({ 41 | definitions: handlers.map((h) => h(...args).definitions).flat(), 42 | }); 43 | 44 | const createHandler = (arg: { definitions: SubscriptionDefinition[] }) => { 45 | const h: SubscribePsuedoIterable = (() => { 46 | throw Error('Subscription handler should not have been called'); 47 | }) as any; 48 | h.definitions = arg.definitions; 49 | return h; 50 | }; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subscriptionless", 3 | "version": "1.0.0-beta.3", 4 | "description": "GraphQL subscriptions using AWS Lambda and API Gateway Websockets", 5 | "source": "src/index.ts", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "sideEffects": false, 9 | "keywords": [ 10 | "graphql", 11 | "subscription", 12 | "lambda", 13 | "API Gateway", 14 | "serverless", 15 | "AWS" 16 | ], 17 | "files": [ 18 | "LICENSE", 19 | "README.md", 20 | "dist", 21 | "tsconfig.json" 22 | ], 23 | "browserslist": [ 24 | "node 12" 25 | ], 26 | "scripts": { 27 | "start": "microbundle --target node -f cjs watch", 28 | "build": "microbundle --target node -f cjs", 29 | "prepack": "rm -rf dist && npm run build", 30 | "prettier:check": "prettier -c ." 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/andyrichardson/subscriptionless.git" 35 | }, 36 | "author": "Andy Richardson (andyrichardson)", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/andyrichardson/subscriptionless/issues" 40 | }, 41 | "homepage": "https://github.com/andyrichardson/subscriptionless#readme", 42 | "devDependencies": { 43 | "@types/aws-lambda": "^8.10.81", 44 | "aws-sdk": "^2.844.0", 45 | "graphql": "^15.5.0", 46 | "graphql-ws": "^5.3.0", 47 | "microbundle": "^0.13.3", 48 | "prettier": "^2.3.2", 49 | "typescript": "^4.3.5" 50 | }, 51 | "dependencies": { 52 | "@aws/dynamodb-data-mapper": "^0.7.3", 53 | "@aws/dynamodb-data-mapper-annotations": "^0.7.3", 54 | "@aws/dynamodb-expressions": "^0.7.3" 55 | }, 56 | "peerDependencies": { 57 | "aws-sdk": ">= 2.844.0", 58 | "graphql": ">= 14.0.0", 59 | "graphql-ws": ">= 5.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/messages/connection_init.ts: -------------------------------------------------------------------------------- 1 | import { StepFunctions } from 'aws-sdk'; 2 | import { ConnectionInitMessage, MessageType } from 'graphql-ws'; 3 | import { assign } from '../model'; 4 | import { StateFunctionInput } from '../types'; 5 | import { sendMessage, deleteConnection, promisify } from '../utils'; 6 | import { MessageHandler } from './types'; 7 | 8 | /** Handler function for 'connection_init' message. */ 9 | export const connection_init: MessageHandler = 10 | (c) => 11 | async ({ event, message }) => { 12 | try { 13 | const res = c.onConnectionInit 14 | ? await promisify(() => c.onConnectionInit!({ event, message })) 15 | : message.payload; 16 | 17 | if (c.ping) { 18 | await new StepFunctions() 19 | .startExecution({ 20 | stateMachineArn: c.ping.machineArn, 21 | name: event.requestContext.connectionId!, 22 | input: JSON.stringify({ 23 | connectionId: event.requestContext.connectionId!, 24 | domainName: event.requestContext.domainName!, 25 | stage: event.requestContext.stage, 26 | state: 'PING', 27 | choice: 'WAIT', 28 | seconds: c.ping.interval - c.ping.timeout, 29 | } as StateFunctionInput), 30 | }) 31 | .promise(); 32 | } 33 | 34 | // Write to persistence 35 | const connection = assign(new c.model.Connection(), { 36 | id: event.requestContext.connectionId!, 37 | requestContext: event.requestContext, 38 | payload: res, 39 | }); 40 | await c.mapper.put(connection); 41 | return sendMessage({ 42 | ...event.requestContext, 43 | message: { type: MessageType.ConnectionAck }, 44 | }); 45 | } catch (err) { 46 | await promisify(() => c.onError?.(err, { event, message })); 47 | await deleteConnection(event.requestContext); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /example/terraform/lambda.tf: -------------------------------------------------------------------------------- 1 | data "archive_file" "handler" { 2 | type = "zip" 3 | source_file = "${path.module}/../dist/example.js" 4 | output_path = "${path.module}/.assets/handler.zip" 5 | } 6 | 7 | # Lambda for handling websocket events 8 | resource "aws_lambda_function" "gateway_handler" { 9 | function_name = "subscriptionless_gateway_event_handler" 10 | runtime = "nodejs14.x" 11 | filename = data.archive_file.handler.output_path 12 | source_code_hash = data.archive_file.handler.output_base64sha256 13 | handler = "example.gatewayHandler" 14 | role = aws_iam_role.gateway_handler.arn 15 | 16 | environment { 17 | variables = { 18 | CONNECTIONS_TABLE = aws_dynamodb_table.connections.id 19 | SUBSCRIPTIONS_TABLE = aws_dynamodb_table.subscriptions.id 20 | PING_STATE_MACHINE_ARN = aws_sfn_state_machine.ping_state_machine.arn 21 | } 22 | } 23 | } 24 | 25 | # Lambda for execution by ping/pong machine 26 | resource "aws_lambda_function" "machine" { 27 | function_name = "machine" 28 | runtime = "nodejs14.x" 29 | filename = data.archive_file.handler.output_path 30 | source_code_hash = data.archive_file.handler.output_base64sha256 31 | handler = "example.stateMachineHandler" 32 | role = aws_iam_role.state_machine_function.arn 33 | 34 | environment { 35 | variables = { 36 | CONNECTIONS_TABLE = aws_dynamodb_table.connections.id 37 | SUBSCRIPTIONS_TABLE = aws_dynamodb_table.subscriptions.id 38 | } 39 | } 40 | } 41 | 42 | # Lambda for handling SNS events (optional) 43 | resource "aws_lambda_function" "snsHandler" { 44 | function_name = "snsHandler" 45 | runtime = "nodejs14.x" 46 | filename = data.archive_file.handler.output_path 47 | source_code_hash = data.archive_file.handler.output_base64sha256 48 | handler = "example.snsHandler" 49 | role = aws_iam_role.snsHandler.arn 50 | 51 | environment { 52 | variables = { 53 | CONNECTIONS_TABLE = aws_dynamodb_table.connections.id 54 | SUBSCRIPTIONS_TABLE = aws_dynamodb_table.subscriptions.id 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/messages/complete.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { CompleteMessage } from 'graphql-ws'; 3 | import { buildExecutionContext } from 'graphql/execution/execute'; 4 | import { 5 | constructContext, 6 | deleteConnection, 7 | getResolverAndArgs, 8 | promisify, 9 | } from '../utils'; 10 | import { MessageHandler } from './types'; 11 | 12 | /** Handler function for 'complete' message. */ 13 | export const complete: MessageHandler = 14 | (c) => 15 | async ({ event, message }) => { 16 | try { 17 | await promisify(() => c.onComplete?.({ event, message })); 18 | 19 | const topicSubscriptions = await c.mapper.query(c.model.Subscription, { 20 | id: `${event.requestContext.connectionId!}|${message.id}`, 21 | }); 22 | 23 | let deletions = [] as Promise[]; 24 | for await (const entity of topicSubscriptions) { 25 | deletions = [ 26 | ...deletions, 27 | (async () => { 28 | // only call onComplete per subscription 29 | if (deletions.length === 0) { 30 | const execContext = buildExecutionContext( 31 | c.schema, 32 | parse(entity.subscription.query), 33 | undefined, 34 | await constructContext(c)(entity), 35 | entity.subscription.variables, 36 | entity.subscription.operationName, 37 | undefined 38 | ); 39 | 40 | if (!('operation' in execContext)) { 41 | throw execContext; 42 | } 43 | 44 | const [field, root, args, context, info] = 45 | getResolverAndArgs(c)(execContext); 46 | 47 | const onComplete = field.resolve.onComplete; 48 | if (onComplete) { 49 | await onComplete(root, args, context, info); 50 | } 51 | } 52 | 53 | await c.mapper.delete(entity); 54 | })(), 55 | ]; 56 | } 57 | 58 | await Promise.all(deletions); 59 | } catch (err) { 60 | await promisify(() => c.onError?.(err, { event, message })); 61 | await deleteConnection(event.requestContext); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/gateway.ts: -------------------------------------------------------------------------------- 1 | import { Handler, APIGatewayEvent } from 'aws-lambda'; 2 | import { GRAPHQL_TRANSPORT_WS_PROTOCOL, MessageType } from 'graphql-ws'; 3 | import { ServerClosure, WebsocketResponse } from './types'; 4 | import { 5 | complete, 6 | connection_init, 7 | subscribe, 8 | disconnect, 9 | ping, 10 | } from './messages'; 11 | import { pong } from './messages/pong'; 12 | 13 | export const handleGatewayEvent = 14 | (c: ServerClosure): Handler => 15 | async (event) => { 16 | if (!event.requestContext) { 17 | return { 18 | statusCode: 200, 19 | body: '', 20 | }; 21 | } 22 | 23 | if (event.requestContext.eventType === 'CONNECT') { 24 | await c.onConnect?.({ event }); 25 | return { 26 | statusCode: 200, 27 | headers: { 28 | 'Sec-WebSocket-Protocol': GRAPHQL_TRANSPORT_WS_PROTOCOL, 29 | }, 30 | body: '', 31 | }; 32 | } 33 | 34 | if (event.requestContext.eventType === 'MESSAGE') { 35 | const message = JSON.parse(event.body!); 36 | 37 | if (message.type === MessageType.ConnectionInit) { 38 | await connection_init(c)({ event, message }); 39 | return { 40 | statusCode: 200, 41 | body: '', 42 | }; 43 | } 44 | 45 | if (message.type === MessageType.Subscribe) { 46 | await subscribe(c)({ event, message }); 47 | return { 48 | statusCode: 200, 49 | body: '', 50 | }; 51 | } 52 | 53 | if (message.type === MessageType.Complete) { 54 | await complete(c)({ event, message }); 55 | return { 56 | statusCode: 200, 57 | body: '', 58 | }; 59 | } 60 | 61 | if (message.type === MessageType.Ping) { 62 | await ping(c)({ event, message }); 63 | return { 64 | statusCode: 200, 65 | body: '', 66 | }; 67 | } 68 | 69 | if (message.type === MessageType.Pong) { 70 | await pong(c)({ event, message }); 71 | return { 72 | statusCode: 200, 73 | body: '', 74 | }; 75 | } 76 | } 77 | 78 | if (event.requestContext.eventType === 'DISCONNECT') { 79 | await disconnect(c)({ event, message: null }); 80 | return { 81 | statusCode: 200, 82 | body: '', 83 | }; 84 | } 85 | 86 | return { 87 | statusCode: 200, 88 | body: '', 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/messages/disconnect.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { equals } from '@aws/dynamodb-expressions'; 3 | import { buildExecutionContext } from 'graphql/execution/execute'; 4 | import { constructContext, getResolverAndArgs, promisify } from '../utils'; 5 | import { MessageHandler } from './types'; 6 | import { assign } from '../model'; 7 | 8 | /** Handler function for 'disconnect' message. */ 9 | export const disconnect: MessageHandler = 10 | (c) => 11 | async ({ event }) => { 12 | try { 13 | await promisify(() => c.onDisconnect?.({ event })); 14 | 15 | const entities = await c.mapper.query( 16 | c.model.Subscription, 17 | { 18 | connectionId: equals(event.requestContext.connectionId), 19 | }, 20 | { indexName: 'ConnectionIndex' } 21 | ); 22 | 23 | const completed = {} as Record; 24 | let deletions = [] as Promise[]; 25 | for await (const entity of entities) { 26 | deletions = [ 27 | ...deletions, 28 | (async () => { 29 | // only call onComplete per subscription 30 | if (!completed[entity.subscriptionId]) { 31 | completed[entity.subscriptionId] = true; 32 | 33 | const execContext = buildExecutionContext( 34 | c.schema, 35 | parse(entity.subscription.query), 36 | undefined, 37 | await constructContext(c)(entity), 38 | entity.subscription.variables, 39 | entity.subscription.operationName, 40 | undefined 41 | ); 42 | 43 | if (!('operation' in execContext)) { 44 | throw execContext; 45 | } 46 | 47 | const [field, root, args, context, info] = 48 | getResolverAndArgs(c)(execContext); 49 | 50 | const onComplete = field.resolve.onComplete; 51 | if (onComplete) { 52 | await onComplete(root, args, context, info); 53 | } 54 | } 55 | 56 | await c.mapper.delete(entity); 57 | })(), 58 | ]; 59 | } 60 | 61 | await Promise.all([ 62 | // Delete subscriptions 63 | ...deletions, 64 | // Delete connection 65 | c.mapper.delete( 66 | assign(new c.model.Connection(), { 67 | id: event.requestContext.connectionId!, 68 | }) 69 | ), 70 | ]); 71 | } catch (err) { 72 | await promisify(() => c.onError?.(err, { event })); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils/graphql.ts: -------------------------------------------------------------------------------- 1 | import { getOperationRootType } from 'graphql'; 2 | import { 3 | buildResolveInfo, 4 | collectFields, 5 | ExecutionContext, 6 | getFieldDef, 7 | } from 'graphql/execution/execute'; 8 | import { addPath } from 'graphql/jsutils/Path'; 9 | import { ServerClosure } from '../types'; 10 | 11 | export const constructContext = 12 | (c: ServerClosure) => 13 | ({ connectionParams }: { connectionParams: object }) => 14 | typeof c.context === 'function' 15 | ? c.context({ connectionParams }) 16 | : { ...c.context, connectionParams }; 17 | 18 | export const getResolverAndArgs = 19 | (c: Omit) => (execContext: ExecutionContext) => { 20 | // Taken from graphql js - https://github.com/graphql/graphql-js/blob/main/src/subscription/subscribe.js#L190 21 | const type = getOperationRootType(c.schema, execContext.operation); 22 | const fields = collectFields( 23 | execContext, 24 | type, 25 | execContext.operation.selectionSet, 26 | Object.create(null), 27 | Object.create(null) 28 | ); 29 | const responseNames = Object.keys(fields); 30 | const responseName = responseNames[0]; 31 | const fieldNodes = fields[responseName]; 32 | const fieldNode = fieldNodes[0]; 33 | const fieldName = fieldNode.name.value; 34 | const fieldDef = getFieldDef(c.schema, type, fieldName); 35 | const path = addPath(undefined, responseName, type.name); 36 | const info = buildResolveInfo( 37 | execContext, 38 | fieldDef!, 39 | fieldNodes, 40 | type, 41 | path 42 | ); 43 | 44 | return [ 45 | fieldDef, 46 | null, 47 | execContext.variableValues, 48 | execContext.contextValue, 49 | info, 50 | ]; 51 | }; 52 | 53 | const prepareResolver = (r: T) => { 54 | visit(r, (node) => { 55 | if (!('resolve' in node)) { 56 | return; 57 | } 58 | 59 | // Add event handlers to resolver fn so they can be accessed later 60 | ['onSubscribe', 'onComplete'].forEach( 61 | (key) => (node.resolve[key] = node[key]) 62 | ); 63 | return false; 64 | }); 65 | return r; 66 | }; 67 | 68 | export const prepareResolvers = (arg: T) => 69 | Array.isArray(arg) ? (arg.map(prepareResolver) as T[]) : prepareResolver(arg); 70 | 71 | const visit = (node: T, handler: (node: T) => any) => 72 | Object.values(node).forEach((value) => { 73 | if (typeof value !== 'object') { 74 | return; 75 | } 76 | 77 | // Don't traverse deeper 78 | if (handler(value) === false) { 79 | return; 80 | } 81 | 82 | visit(value, handler); 83 | }); 84 | -------------------------------------------------------------------------------- /src/pubsub/publish.ts: -------------------------------------------------------------------------------- 1 | import { 2 | attributeNotExists, 3 | equals, 4 | ConditionExpression, 5 | } from '@aws/dynamodb-expressions'; 6 | import { parse, execute, ExecutionResult } from 'graphql'; 7 | import { MessageType } from 'graphql-ws'; 8 | import { Subscription } from '../model'; 9 | import { ServerClosure } from '../types'; 10 | import { constructContext, isAsyncIterable, sendMessage } from '../utils'; 11 | 12 | type PubSubEvent = { 13 | topic: string; 14 | payload: any; 15 | }; 16 | 17 | export const publish = (c: ServerClosure) => async (event: PubSubEvent) => { 18 | const subscriptions = await getFilteredSubs(c)(event); 19 | const iters = subscriptions.map(async (sub) => { 20 | const result = execute({ 21 | schema: c.schema, 22 | document: parse(sub.subscription.query), 23 | rootValue: event, 24 | contextValue: await constructContext(c)(sub), 25 | variableValues: sub.subscription.variables, 26 | operationName: sub.subscription.operationName, 27 | }); 28 | 29 | // Support for @defer and @stream directives 30 | const parts = isAsyncIterable(result) ? result : [result]; 31 | for await (let part of parts) { 32 | await sendMessage({ 33 | ...sub.requestContext, 34 | message: { 35 | id: sub.subscriptionId, 36 | type: MessageType.Next, 37 | payload: part, 38 | }, 39 | }); 40 | } 41 | }); 42 | return await Promise.all(iters); 43 | }; 44 | 45 | const getFilteredSubs = 46 | (c: Omit) => 47 | async (event: PubSubEvent): Promise => { 48 | const flattenPayload = flatten(event.payload); 49 | const iterator = c.mapper.query( 50 | c.model.Subscription, 51 | { topic: equals(event.topic) }, 52 | { 53 | filter: { 54 | type: 'And', 55 | conditions: Object.entries(flattenPayload).reduce( 56 | (p, [key, value]) => [ 57 | ...p, 58 | { 59 | type: 'Or', 60 | conditions: [ 61 | { 62 | ...attributeNotExists(), 63 | subject: `filter.${key}`, 64 | }, 65 | { 66 | ...equals(value), 67 | subject: `filter.${key}`, 68 | }, 69 | ], 70 | }, 71 | ], 72 | [] as ConditionExpression[] 73 | ), 74 | }, 75 | indexName: 'TopicIndex', 76 | } 77 | ); 78 | 79 | // Aggregate all targets 80 | const subs: Subscription[] = []; 81 | for await (const sub of iterator) { 82 | subs.push(sub); 83 | } 84 | 85 | return subs; 86 | }; 87 | 88 | export const flatten = ( 89 | obj: object 90 | ): Record => 91 | Object.entries(obj).reduce((p, [k1, v1]) => { 92 | if (v1 && typeof v1 === 'object') { 93 | const next = Object.entries(v1).reduce( 94 | (prev, [k2, v2]) => ({ 95 | ...prev, 96 | [`${k1}.${k2}`]: v2, 97 | }), 98 | {} 99 | ); 100 | return { 101 | ...p, 102 | ...flatten(next), 103 | }; 104 | } 105 | 106 | if ( 107 | typeof v1 === 'string' || 108 | typeof v1 === 'number' || 109 | typeof v1 === 'boolean' 110 | ) { 111 | return { ...p, [k1]: v1 }; 112 | } 113 | 114 | return p; 115 | }, {}); 116 | -------------------------------------------------------------------------------- /example/terraform/apigateway.tf: -------------------------------------------------------------------------------- 1 | # Account-level throttle burst quota. Quota codes are identical across regions. 2 | # `aws service-quotas list-service-quotas --service apigateway` 3 | data "aws_servicequotas_service_quota" "throttling_burst_limit" { 4 | service_code = "apigateway" 5 | quota_code = "L-CDF5615A" 6 | } 7 | # Account-level throttle rate quota. Quota codes are identical across regions. 8 | # `aws service-quotas list-service-quotas --service apigateway` 9 | data "aws_servicequotas_service_quota" "throttling_rate_limit" { 10 | service_code = "apigateway" 11 | quota_code = "L-8A5B8E43" 12 | } 13 | 14 | resource "aws_apigatewayv2_api" "ws" { 15 | name = "websocket-api" 16 | protocol_type = "WEBSOCKET" 17 | route_selection_expression = "$request.body.action" 18 | } 19 | 20 | resource "aws_apigatewayv2_route" "default_route" { 21 | api_id = aws_apigatewayv2_api.ws.id 22 | route_key = "$default" 23 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 24 | } 25 | 26 | resource "aws_apigatewayv2_route" "connect_route" { 27 | api_id = aws_apigatewayv2_api.ws.id 28 | route_key = "$connect" 29 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 30 | } 31 | 32 | resource "aws_apigatewayv2_route" "disconnect_route" { 33 | api_id = aws_apigatewayv2_api.ws.id 34 | route_key = "$disconnect" 35 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 36 | } 37 | 38 | resource "aws_apigatewayv2_integration" "default_integration" { 39 | api_id = aws_apigatewayv2_api.ws.id 40 | integration_type = "AWS_PROXY" 41 | integration_uri = aws_lambda_function.gateway_handler.invoke_arn 42 | } 43 | 44 | resource "aws_lambda_permission" "apigateway_invoke_lambda" { 45 | action = "lambda:InvokeFunction" 46 | function_name = aws_lambda_function.gateway_handler.function_name 47 | principal = "apigateway.amazonaws.com" 48 | } 49 | 50 | resource "aws_apigatewayv2_deployment" "ws" { 51 | api_id = aws_apigatewayv2_api.ws.id 52 | 53 | triggers = { 54 | redeployment = sha1(join(",", tolist([ 55 | jsonencode(aws_apigatewayv2_integration.default_integration), 56 | jsonencode(aws_apigatewayv2_route.default_route), 57 | jsonencode(aws_apigatewayv2_route.connect_route), 58 | jsonencode(aws_apigatewayv2_route.disconnect_route), 59 | ]))) 60 | } 61 | 62 | depends_on = [ 63 | aws_apigatewayv2_route.default_route, 64 | aws_apigatewayv2_route.connect_route, 65 | aws_apigatewayv2_route.disconnect_route 66 | ] 67 | } 68 | 69 | resource "aws_apigatewayv2_stage" "ws" { 70 | api_id = aws_apigatewayv2_api.ws.id 71 | name = "example" 72 | deployment_id = aws_apigatewayv2_deployment.ws.id 73 | 74 | default_route_settings { 75 | logging_level = "INFO" 76 | detailed_metrics_enabled = true 77 | throttling_burst_limit = coalesce(1000, data.aws_servicequotas_service_quota.throttling_burst_limit.value) 78 | throttling_rate_limit = coalesce(5000, data.aws_servicequotas_service_quota.throttling_rate_limit.value) 79 | } 80 | 81 | access_log_settings { 82 | destination_arn = aws_cloudwatch_log_group.websocket_api.arn 83 | format = jsonencode({ 84 | requestId = "$context.requestId" 85 | ip = "$context.identity.sourceIp" 86 | caller = "$context.identity.caller" 87 | user = "$context.identity.user" 88 | requestTime = "$context.requestTime" 89 | eventType = "$context.eventType" 90 | routeKey = "$context.routeKey" 91 | status = "$context.status" 92 | connectionId = "$context.connectionId" 93 | errorMessage = "$context.integrationErrorMessage" 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionInitMessage, 3 | SubscribeMessage, 4 | CompleteMessage, 5 | PingMessage, 6 | PongMessage, 7 | } from 'graphql-ws'; 8 | import { DataMapper } from '@aws/dynamodb-data-mapper'; 9 | import { APIGatewayEvent } from 'aws-lambda'; 10 | import { GraphQLSchema } from 'graphql'; 11 | import { DynamoDB, StepFunctions } from 'aws-sdk'; 12 | import { Subscription, Connection } from './model'; 13 | 14 | export type ServerArgs = { 15 | /** GraphQL schema containing subscriptions. */ 16 | schema: GraphQLSchema; 17 | /** Constructor function for GraphQL context. */ 18 | context?: ((arg: { connectionParams: any }) => object) | object; 19 | 20 | /** Options for server->client ping/pong (recommended). */ 21 | ping?: { 22 | /** Rate at which pings are sent. */ 23 | interval: number; 24 | /** Time for pong response before closing socket. */ 25 | timeout: number; 26 | /** State machine resource for dispatching pings. */ 27 | machineArn: string; 28 | }; 29 | 30 | /** Override default table names. */ 31 | tableNames?: Partial; 32 | /** Override default DynamoDB instance. */ 33 | dynamodb?: DynamoDB; 34 | 35 | /** Called on incoming API Gateway `$connect` event. */ 36 | onConnect?: (e: { event: APIGatewayEvent }) => MaybePromise; 37 | /** Called on incoming API Gateway `$disconnect` event. */ 38 | onDisconnect?: (e: { event: APIGatewayEvent }) => MaybePromise; 39 | 40 | /** 41 | * Called on incoming graphql-ws `connection_init` message. 42 | * Returned value is persisted and provided at context creation on publish events. 43 | **/ 44 | onConnectionInit?: (e: { 45 | event: APIGatewayEvent; 46 | message: ConnectionInitMessage; 47 | }) => MaybePromise; 48 | /** Called on incoming graphql-ws `subscribe` message. */ 49 | onSubscribe?: (e: { 50 | event: APIGatewayEvent; 51 | message: SubscribeMessage; 52 | }) => MaybePromise; 53 | /** Called on graphql-ws `complete` message. */ 54 | onComplete?: (e: { 55 | event: APIGatewayEvent; 56 | message: CompleteMessage; 57 | }) => MaybePromise; 58 | /** Called on incoming graphql-ws `ping` message. */ 59 | onPing?: (e: { 60 | event: APIGatewayEvent; 61 | message: PingMessage; 62 | }) => MaybePromise; 63 | /** Called on incoming graphql-ws `pong` message. */ 64 | onPong?: (e: { 65 | event: APIGatewayEvent; 66 | message: PongMessage; 67 | }) => MaybePromise; 68 | 69 | /** Called on unexpected errors during resolution of API Gateway or graphql-ws events. */ 70 | onError?: (error: any, context: any) => void; 71 | }; 72 | 73 | type MaybePromise = T | Promise; 74 | 75 | export type ServerClosure = { 76 | mapper: DataMapper; 77 | model: { 78 | Subscription: typeof Subscription; 79 | Connection: typeof Connection; 80 | }; 81 | } & Omit; 82 | 83 | type TableNames = { 84 | connections: string; 85 | subscriptions: string; 86 | }; 87 | 88 | export type WebsocketResponse = { 89 | statusCode: number; 90 | headers?: Record; 91 | body: string; 92 | }; 93 | 94 | export type SubscriptionDefinition = { 95 | topic: string; 96 | filter?: object | (() => void); 97 | }; 98 | 99 | export type SubscribeHandler = (...args: any[]) => SubscribePsuedoIterable; 100 | 101 | export type SubscribePsuedoIterable = { 102 | (): void; 103 | definitions: SubscriptionDefinition[]; 104 | }; 105 | 106 | export type SubscribeArgs = any[]; 107 | 108 | export type Class = { new (...args: any[]): any }; 109 | 110 | export type StateFunctionInput = { 111 | connectionId: string; 112 | domainName: string; 113 | stage: string; 114 | state: 'PING' | 'REVIEW' | 'ABORT'; 115 | seconds: number; 116 | }; 117 | -------------------------------------------------------------------------------- /example/terraform/iam.tf: -------------------------------------------------------------------------------- 1 | # Allow DB access 2 | resource "aws_iam_policy" "dynamodb" { 3 | name = "subscriptionless_dynamodb" 4 | policy = jsonencode({ 5 | Version = "2012-10-17" 6 | Statement = [ 7 | { 8 | Action = ["dynamodb:*"] 9 | Effect = "Allow" 10 | Resource = [ 11 | "${aws_dynamodb_table.connections.arn}*", 12 | "${aws_dynamodb_table.subscriptions.arn}*" 13 | ] 14 | } 15 | ] 16 | }) 17 | } 18 | 19 | # Allow WebSocket API access 20 | resource "aws_iam_policy" "apigateway" { 21 | name = "subscriptionless_apigateway" 22 | policy = jsonencode({ 23 | Version = "2012-10-17" 24 | Statement = [ 25 | { 26 | Action = ["execute-api:*"] 27 | Effect = "Allow" 28 | Resource = [ 29 | aws_apigatewayv2_api.ws.execution_arn, 30 | "${aws_apigatewayv2_api.ws.execution_arn}/*" 31 | ] 32 | } 33 | ] 34 | }) 35 | } 36 | 37 | resource "aws_iam_policy" "lambda_logging" { 38 | name = "subscriptionless_lambda_logging" 39 | policy = jsonencode({ 40 | Version = "2012-10-17" 41 | Statement = [ 42 | { 43 | Action = ["logs:*"] 44 | Effect = "Allow" 45 | Resource = ["arn:aws:logs:*:*:*"] 46 | } 47 | ] 48 | }) 49 | } 50 | 51 | # Allow invocation of state machine 52 | resource "aws_iam_policy" "state_machine_invoke" { 53 | name = "subscriptionless_state_machine_invoke" 54 | policy = jsonencode({ 55 | Version = "2012-10-17" 56 | Statement = [ 57 | { 58 | Action = ["states:StartExecution"] 59 | Effect = "Allow" 60 | Resource = [ 61 | aws_sfn_state_machine.ping_state_machine.arn 62 | ] 63 | }, 64 | ] 65 | }) 66 | } 67 | resource "aws_iam_policy" "state_machine_lambda_invoke" { 68 | name = "subscriptionless_state_machine_lambda_invoke" 69 | policy = jsonencode({ 70 | Version = "2012-10-17" 71 | Statement = [ 72 | { 73 | Action = ["lambda:InvokeFunction"] 74 | Effect = "Allow" 75 | Resource = [aws_lambda_function.machine.arn] 76 | }, 77 | ] 78 | }) 79 | } 80 | 81 | # Policy for ws handler 82 | resource "aws_iam_role" "gateway_handler" { 83 | name = "subscriptionless_gateway_handler" 84 | assume_role_policy = jsonencode({ 85 | Version = "2012-10-17" 86 | Statement = [ 87 | { 88 | Action = "sts:AssumeRole" 89 | Effect = "Allow" 90 | Principal = { 91 | Service = [ 92 | "lambda.amazonaws.com" 93 | ] 94 | } 95 | }, 96 | ] 97 | }) 98 | managed_policy_arns = [ 99 | aws_iam_policy.apigateway.arn, 100 | aws_iam_policy.dynamodb.arn, 101 | aws_iam_policy.state_machine_invoke.arn, 102 | aws_iam_policy.lambda_logging.arn 103 | ] 104 | } 105 | 106 | # Policy for ping/pong 107 | resource "aws_iam_role" "state_machine" { 108 | name = "subscriptionless_state_machine" 109 | assume_role_policy = jsonencode({ 110 | Version = "2012-10-17" 111 | Statement = [ 112 | { 113 | Action = "sts:AssumeRole" 114 | Effect = "Allow" 115 | Principal = { 116 | Service = [ 117 | "states.amazonaws.com" 118 | ] 119 | } 120 | }, 121 | ] 122 | }) 123 | managed_policy_arns = [ 124 | aws_iam_policy.state_machine_lambda_invoke.arn 125 | ] 126 | } 127 | 128 | resource "aws_iam_role" "state_machine_function" { 129 | name = "subscriptionless_state_machine_function" 130 | assume_role_policy = jsonencode({ 131 | Version = "2012-10-17" 132 | Statement = [ 133 | { 134 | Action = "sts:AssumeRole" 135 | Effect = "Allow" 136 | Principal = { 137 | Service = [ 138 | "lambda.amazonaws.com", 139 | ] 140 | } 141 | }, 142 | ] 143 | }) 144 | managed_policy_arns = [ 145 | aws_iam_policy.apigateway.arn, 146 | aws_iam_policy.dynamodb.arn 147 | ] 148 | } 149 | 150 | 151 | # Policy for sns handler 152 | resource "aws_iam_role" "snsHandler" { 153 | name = "subscriptionless-snsHandler" 154 | assume_role_policy = jsonencode({ 155 | Version = "2012-10-17" 156 | Statement = [ 157 | { 158 | Effect = "Allow" 159 | Action = "sts:AssumeRole" 160 | Principal = { 161 | Service = [ 162 | "lambda.amazonaws.com" 163 | ] 164 | } 165 | }, 166 | ] 167 | }) 168 | managed_policy_arns = [ 169 | aws_iam_policy.apigateway.arn, 170 | aws_iam_policy.dynamodb.arn 171 | ] 172 | } 173 | -------------------------------------------------------------------------------- /src/messages/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { SubscribeMessage, MessageType } from 'graphql-ws'; 2 | import { 3 | validate, 4 | parse, 5 | execute, 6 | GraphQLError, 7 | ExecutionResult, 8 | } from 'graphql'; 9 | import { 10 | buildExecutionContext, 11 | assertValidExecutionArguments, 12 | } from 'graphql/execution/execute'; 13 | import { MessageHandler } from './types'; 14 | import { 15 | constructContext, 16 | deleteConnection, 17 | getResolverAndArgs, 18 | isAsyncIterable, 19 | promisify, 20 | sendMessage, 21 | } from '../utils'; 22 | import { assign } from '../model'; 23 | import { ServerClosure, SubscribeHandler } from '../types'; 24 | 25 | /** Handler function for 'subscribe' message. */ 26 | export const subscribe: MessageHandler = 27 | (c) => 28 | async ({ event, message }) => { 29 | try { 30 | const [connection] = await Promise.all([ 31 | await c.mapper.get( 32 | assign(new c.model.Connection(), { 33 | id: event.requestContext.connectionId!, 34 | }) 35 | ), 36 | await promisify(() => c.onSubscribe?.({ event, message })), 37 | ]); 38 | const connectionParams = connection.payload || {}; 39 | 40 | // GraphQL validation 41 | const errors = validateMessage(c)(message); 42 | 43 | if (errors) { 44 | return sendMessage({ 45 | ...event.requestContext, 46 | message: { 47 | type: MessageType.Error, 48 | id: message.id, 49 | payload: errors, 50 | }, 51 | }); 52 | } 53 | 54 | const contextValue = await constructContext(c)({ connectionParams }); 55 | const query = parse(message.payload.query); 56 | const execContext = buildExecutionContext( 57 | c.schema, 58 | query, 59 | undefined, 60 | contextValue, 61 | message.payload.variables, 62 | message.payload.operationName, 63 | undefined 64 | ); 65 | 66 | if (!('operation' in execContext)) { 67 | return sendMessage({ 68 | ...event.requestContext, 69 | message: { 70 | type: MessageType.Next, 71 | id: message.id, 72 | payload: { 73 | errors: execContext, 74 | }, 75 | }, 76 | }); 77 | } 78 | 79 | if (execContext.operation.operation !== 'subscription') { 80 | const result = await execute({ 81 | schema: c.schema, 82 | document: query, 83 | contextValue, 84 | variableValues: message.payload.variables, 85 | operationName: message.payload.operationName, 86 | }); 87 | 88 | // Support for @defer and @stream directives 89 | const parts = isAsyncIterable(result) 90 | ? result 91 | : [result]; 92 | for await (let part of parts) { 93 | await sendMessage({ 94 | ...event.requestContext, 95 | message: { 96 | type: MessageType.Next, 97 | id: message.id, 98 | payload: part, 99 | }, 100 | }); 101 | } 102 | 103 | await sendMessage({ 104 | ...event.requestContext, 105 | message: { 106 | type: MessageType.Complete, 107 | id: message.id, 108 | }, 109 | }); 110 | 111 | return; 112 | } 113 | 114 | const [field, root, args, context, info] = 115 | getResolverAndArgs(c)(execContext); 116 | 117 | // Dispatch onSubscribe side effect 118 | const onSubscribe = field.resolve.onSubscribe; 119 | if (onSubscribe) { 120 | await onSubscribe(root, args, context, info); 121 | } 122 | 123 | const topicDefinitions = (field.subscribe as SubscribeHandler)( 124 | root, 125 | args, 126 | context, 127 | info 128 | ).definitions; // Access subscribe instance 129 | await Promise.all( 130 | topicDefinitions.map(async ({ topic, filter }) => { 131 | const subscription = assign(new c.model.Subscription(), { 132 | id: `${event.requestContext.connectionId}|${message.id}`, 133 | topic, 134 | filter: filter || {}, 135 | subscriptionId: message.id, 136 | subscription: { 137 | variableValues: args, 138 | ...message.payload, 139 | }, 140 | connectionId: event.requestContext.connectionId!, 141 | connectionParams, 142 | requestContext: event.requestContext, 143 | ttl: connection.ttl, 144 | }); 145 | await c.mapper.put(subscription); 146 | }) 147 | ); 148 | } catch (err) { 149 | await promisify(() => c.onError?.(err, { event, message })); 150 | await deleteConnection(event.requestContext); 151 | } 152 | }; 153 | 154 | /** Validate incoming query and arguments */ 155 | const validateMessage = (c: ServerClosure) => (message: SubscribeMessage) => { 156 | const errors = validate(c.schema, parse(message.payload.query)); 157 | 158 | if (errors && errors.length) { 159 | return errors; 160 | } 161 | 162 | try { 163 | assertValidExecutionArguments( 164 | c.schema, 165 | parse(message.payload.query), 166 | message.payload.variables 167 | ); 168 | } catch (err) { 169 | return [err] as GraphQLError[]; 170 | } 171 | }; 172 | -------------------------------------------------------------------------------- /example/serverless.yml: -------------------------------------------------------------------------------- 1 | service: subscriptionless-example 2 | provider: 3 | name: aws 4 | stage: dev 5 | runtime: nodejs14.x 6 | iam: 7 | role: 8 | statements: 9 | - Effect: Allow 10 | Action: dynamodb:* 11 | Resource: 12 | - !GetAtt ConnectionsTable.Arn 13 | - !GetAtt SubscriptionsTable.Arn 14 | - Effect: Allow 15 | Action: execute-api:* 16 | Resource: 17 | - !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebsocketsApi}/*' 18 | - Effect: Allow 19 | Action: states:StartExecution 20 | Resource: 21 | - !Sub 'arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:PingStepFunctionsStateMachine-*' 22 | - Effect: Allow 23 | Resource: 24 | - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-machine' 25 | Action: 26 | - lambda:InvokeFunction 27 | environment: 28 | SUBSCRIPTIONS_TABLE: subscriptions 29 | CONNECTIONS_TABLE: connections 30 | 31 | plugins: 32 | - serverless-plugin-typescript 33 | - serverless-step-functions 34 | 35 | functions: 36 | subscription: 37 | handler: src/handler.gatewayHandler 38 | environment: 39 | PING_STATE_MACHINE_ARN: ${self:resources.Outputs.PingStateMachine.Value} 40 | events: 41 | - websocket: 42 | route: $connect 43 | - websocket: 44 | route: $disconnect 45 | - websocket: 46 | route: $default 47 | 48 | # Example push-based event 49 | snsEvent: 50 | handler: src/handler.snsHandler 51 | events: 52 | - sns: NEW_ARTICLE 53 | 54 | # Ping/pong functions 55 | machine: 56 | handler: src/handler.stateMachineHandler 57 | 58 | # Step function for server->client ping/pong (recommended) 59 | stepFunctions: 60 | stateMachines: 61 | ping: 62 | role: !GetAtt IamRoleLambdaExecution.Arn 63 | events: 64 | - websocket: 65 | route: $connect 66 | definition: 67 | Comment: 'An example of the Amazon States Language using wait states' 68 | StartAt: Wait 69 | States: 70 | Eval: 71 | Type: Task 72 | Resource: !GetAtt machine.Arn 73 | Next: Choose 74 | Wait: 75 | Type: Wait 76 | SecondsPath: '$.seconds' 77 | Next: Eval 78 | Choose: 79 | Type: Choice 80 | Choices: 81 | - Not: 82 | Variable: '$.state' 83 | StringEquals: 'ABORT' 84 | Next: Wait 85 | Default: End 86 | End: 87 | Type: Pass 88 | End: true 89 | 90 | resources: 91 | Resources: 92 | # Table for tracking connections 93 | ConnectionsTable: 94 | Type: AWS::DynamoDB::Table 95 | Properties: 96 | TableName: ${self:provider.environment.CONNECTIONS_TABLE} 97 | AttributeDefinitions: 98 | - AttributeName: id 99 | AttributeType: S 100 | KeySchema: 101 | - AttributeName: id 102 | KeyType: HASH 103 | ProvisionedThroughput: 104 | ReadCapacityUnits: 1 105 | WriteCapacityUnits: 1 106 | # Table for tracking subscriptions 107 | SubscriptionsTable: 108 | Type: AWS::DynamoDB::Table 109 | Properties: 110 | TableName: ${self:provider.environment.SUBSCRIPTIONS_TABLE} 111 | AttributeDefinitions: 112 | - AttributeName: id 113 | AttributeType: S 114 | - AttributeName: topic 115 | AttributeType: S 116 | - AttributeName: connectionId 117 | AttributeType: S 118 | KeySchema: 119 | - AttributeName: id 120 | KeyType: HASH 121 | - AttributeName: topic 122 | KeyType: RANGE 123 | GlobalSecondaryIndexes: 124 | - IndexName: ConnectionIndex 125 | KeySchema: 126 | - AttributeName: connectionId 127 | KeyType: HASH 128 | Projection: 129 | ProjectionType: ALL 130 | ProvisionedThroughput: 131 | ReadCapacityUnits: 1 132 | WriteCapacityUnits: 1 133 | - IndexName: TopicIndex 134 | KeySchema: 135 | - AttributeName: topic 136 | KeyType: HASH 137 | Projection: 138 | ProjectionType: ALL 139 | ProvisionedThroughput: 140 | ReadCapacityUnits: 1 141 | WriteCapacityUnits: 1 142 | ProvisionedThroughput: 143 | ReadCapacityUnits: 1 144 | WriteCapacityUnits: 1 145 | 146 | extensions: 147 | PingStepFunctionsStateMachine: 148 | Properties: 149 | RoleArn: !GetAtt IamRoleLambdaExecution.Arn 150 | DependsOn: 151 | - IamRoleLambdaExecution 152 | 153 | IamRoleLambdaExecution: 154 | Properties: 155 | AssumeRolePolicyDocument: 156 | Version: '2012-10-17' 157 | Statement: 158 | - Effect: Allow 159 | Principal: 160 | Service: 161 | - lambda.amazonaws.com 162 | - states.amazonaws.com 163 | Action: 164 | - sts:AssumeRole 165 | 166 | Outputs: 167 | PingStateMachine: 168 | Value: 169 | Ref: PingStepFunctionsStateMachine 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | GraphQL subscriptions for AWS Lambda and API Gateway WebSockets. 4 | 5 | Have all the functionality of GraphQL subscriptions on a stateful server without the cost. 6 | 7 | > Note: This project uses the [graphql-ws protocol](https://github.com/enisdenjo/graphql-ws) under the hood. 8 | 9 | ## ⚠️ Limitations 10 | 11 | Seriously, **read this first** before you even think about using this. 12 | 13 |
14 | 15 | This is in beta 16 | 17 | This is beta and should be treated as such. 18 | 19 |
20 | 21 |
22 | 23 | AWS API Gateway Limitations 24 | 25 | There are a few noteworthy limitations to the AWS API Gateway WebSocket implementation. 26 | 27 | > Note: If you work on AWS and want to run through this, hit me up! 28 | 29 | #### Socket timeouts 30 | 31 | Default socket idleness [detection in API Gateway is unpredictable](https://github.com/andyrichardson/subscriptionless/issues/3). 32 | 33 | It is strongly recommended to use socket idleness detection [listed here](#configure-idleness-detection-pingpong). Alternatively, client->server pinging can be used to keep a connection alive. 34 | 35 | #### Socket errors 36 | 37 | API Gateway's current socket closing functionality doesn't support any kind of message/payload. Along with this, [graphql-ws won't support error messages](https://github.com/enisdenjo/graphql-ws/issues/112). 38 | 39 | Because of this limitation, there is no clear way to communicate subprotocol errors to the client. In the case of a subprotocol error the socket will be closed by the server (with no meaningful disconnect payload). 40 | 41 |
42 | 43 | ## Setup 44 | 45 | #### Create a subscriptionless instance. 46 | 47 | ```ts 48 | import { createInstance } from 'subscriptionless'; 49 | 50 | const instance = createInstance({ 51 | schema, 52 | }); 53 | ``` 54 | 55 | #### Export the handler. 56 | 57 | ```ts 58 | export const gatewayHandler = instance.gatewayHandler; 59 | ``` 60 | 61 | #### Configure API Gateway 62 | 63 | Set up API Gateway to route WebSocket events to the exported handler. 64 | 65 |
66 | 💾 serverless framework example 67 | 68 | ```yaml 69 | functions: 70 | websocket: 71 | name: my-subscription-lambda 72 | handler: ./handler.gatewayHandler 73 | events: 74 | - websocket: 75 | route: $connect 76 | - websocket: 77 | route: $disconnect 78 | - websocket: 79 | route: $default 80 | ``` 81 | 82 |
83 | 84 |
85 | 💾 terraform example 86 | 87 | ```tf 88 | resource "aws_apigatewayv2_api" "ws" { 89 | name = "websocket-api" 90 | protocol_type = "WEBSOCKET" 91 | route_selection_expression = "$request.body.action" 92 | } 93 | 94 | resource "aws_apigatewayv2_route" "default_route" { 95 | api_id = aws_apigatewayv2_api.ws.id 96 | route_key = "$default" 97 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 98 | } 99 | 100 | resource "aws_apigatewayv2_route" "connect_route" { 101 | api_id = aws_apigatewayv2_api.ws.id 102 | route_key = "$connect" 103 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 104 | } 105 | 106 | resource "aws_apigatewayv2_route" "disconnect_route" { 107 | api_id = aws_apigatewayv2_api.ws.id 108 | route_key = "$disconnect" 109 | target = "integrations/${aws_apigatewayv2_integration.default_integration.id}" 110 | } 111 | 112 | resource "aws_apigatewayv2_integration" "default_integration" { 113 | api_id = aws_apigatewayv2_api.ws.id 114 | integration_type = "AWS_PROXY" 115 | integration_uri = aws_lambda_function.gateway_handler.invoke_arn 116 | } 117 | 118 | resource "aws_lambda_permission" "apigateway_invoke_lambda" { 119 | action = "lambda:InvokeFunction" 120 | function_name = aws_lambda_function.gateway_handler.function_name 121 | principal = "apigateway.amazonaws.com" 122 | } 123 | 124 | resource "aws_apigatewayv2_deployment" "ws" { 125 | api_id = aws_apigatewayv2_api.ws.id 126 | 127 | triggers = { 128 | redeployment = sha1(join(",", tolist([ 129 | jsonencode(aws_apigatewayv2_integration.default_integration), 130 | jsonencode(aws_apigatewayv2_route.default_route), 131 | jsonencode(aws_apigatewayv2_route.connect_route), 132 | jsonencode(aws_apigatewayv2_route.disconnect_route), 133 | ]))) 134 | } 135 | 136 | depends_on = [ 137 | aws_apigatewayv2_route.default_route, 138 | aws_apigatewayv2_route.connect_route, 139 | aws_apigatewayv2_route.disconnect_route 140 | ] 141 | } 142 | 143 | resource "aws_apigatewayv2_stage" "ws" { 144 | api_id = aws_apigatewayv2_api.ws.id 145 | name = "example" 146 | deployment_id = aws_apigatewayv2_deployment.ws.id 147 | } 148 | ``` 149 | 150 |
151 | 152 | #### Create DynanmoDB tables for state 153 | 154 | In-flight connections and subscriptions need to be persisted. 155 | 156 |
157 | 158 | 📖 Changing DynamoDB table names 159 | 160 | Use the `tableNames` argument to override the default table names. 161 | 162 | ```ts 163 | const instance = createInstance({ 164 | /* ... */ 165 | tableNames: { 166 | connections: 'my_connections', 167 | subscriptions: 'my_subscriptions', 168 | }, 169 | }); 170 | ``` 171 | 172 |
173 | 174 |
175 | 176 | 💾 serverless framework example 177 | 178 | ```yaml 179 | resources: 180 | Resources: 181 | # Table for tracking connections 182 | connectionsTable: 183 | Type: AWS::DynamoDB::Table 184 | Properties: 185 | TableName: ${self:provider.environment.CONNECTIONS_TABLE} 186 | AttributeDefinitions: 187 | - AttributeName: id 188 | AttributeType: S 189 | KeySchema: 190 | - AttributeName: id 191 | KeyType: HASH 192 | TimeToLiveSpecification: 193 | AttributeName: ttl 194 | Enabled: true 195 | ProvisionedThroughput: 196 | ReadCapacityUnits: 1 197 | WriteCapacityUnits: 1 198 | # Table for tracking subscriptions 199 | subscriptionsTable: 200 | Type: AWS::DynamoDB::Table 201 | Properties: 202 | TableName: ${self:provider.environment.SUBSCRIPTIONS_TABLE} 203 | AttributeDefinitions: 204 | - AttributeName: id 205 | AttributeType: S 206 | - AttributeName: topic 207 | AttributeType: S 208 | - AttributeName: connectionId 209 | AttributeType: S 210 | KeySchema: 211 | - AttributeName: id 212 | KeyType: HASH 213 | - AttributeName: topic 214 | KeyType: RANGE 215 | GlobalSecondaryIndexes: 216 | - IndexName: ConnectionIndex 217 | KeySchema: 218 | - AttributeName: connectionId 219 | KeyType: HASH 220 | Projection: 221 | ProjectionType: ALL 222 | ProvisionedThroughput: 223 | ReadCapacityUnits: 1 224 | WriteCapacityUnits: 1 225 | - IndexName: TopicIndex 226 | KeySchema: 227 | - AttributeName: topic 228 | KeyType: HASH 229 | Projection: 230 | ProjectionType: ALL 231 | ProvisionedThroughput: 232 | ReadCapacityUnits: 1 233 | WriteCapacityUnits: 1 234 | TimeToLiveSpecification: 235 | AttributeName: ttl 236 | Enabled: true 237 | ProvisionedThroughput: 238 | ReadCapacityUnits: 1 239 | WriteCapacityUnits: 1 240 | ``` 241 | 242 |
243 | 244 |
245 | 246 | 💾 terraform example 247 | 248 | ```tf 249 | resource "aws_dynamodb_table" "connections-table" { 250 | name = "subscriptionless_connections" 251 | billing_mode = "PROVISIONED" 252 | read_capacity = 1 253 | write_capacity = 1 254 | hash_key = "id" 255 | 256 | attribute { 257 | name = "id" 258 | type = "S" 259 | } 260 | 261 | ttl { 262 | attribute_name = "ttl" 263 | enabled = true 264 | } 265 | } 266 | 267 | resource "aws_dynamodb_table" "subscriptions-table" { 268 | name = "subscriptionless_subscriptions" 269 | billing_mode = "PROVISIONED" 270 | read_capacity = 1 271 | write_capacity = 1 272 | hash_key = "id" 273 | range_key = "topic" 274 | 275 | attribute { 276 | name = "id" 277 | type = "S" 278 | } 279 | 280 | attribute { 281 | name = "topic" 282 | type = "S" 283 | } 284 | 285 | attribute { 286 | name = "connectionId" 287 | type = "S" 288 | } 289 | 290 | global_secondary_index { 291 | name = "ConnectionIndex" 292 | hash_key = "connectionId" 293 | write_capacity = 1 294 | read_capacity = 1 295 | projection_type = "ALL" 296 | } 297 | 298 | global_secondary_index { 299 | name = "TopicIndex" 300 | hash_key = "topic" 301 | write_capacity = 1 302 | read_capacity = 1 303 | projection_type = "ALL" 304 | } 305 | 306 | ttl { 307 | attribute_name = "ttl" 308 | enabled = true 309 | } 310 | } 311 | ``` 312 | 313 |
314 | 315 | #### Configure idleness detection (ping/pong) 316 | 317 | Set up server->client pinging for socket idleness detection. 318 | 319 | > Note: While not a hard requirement, this is [strongly recommended](#%EF%B8%8F-limitations). 320 | 321 |
322 | 323 | 📖 Configuring instance 324 | 325 | Pass a `ping` argument to configure delays and what state machine to invoke. 326 | 327 | ```ts 328 | const instance = createInstance({ 329 | /* ... */ 330 | ping: { 331 | interval: 60, // Rate in seconds to send ping message 332 | timeout: 30, // Threshold for pong response before closing socket 333 | machineArn: process.env.MACHINE_ARN, // State machine to invoke 334 | }, 335 | }); 336 | ``` 337 | 338 | Export the resulting handler for use by the state machine. 339 | 340 | ```ts 341 | export const stateMachineHandler = instance.stateMachineHandler; 342 | ``` 343 | 344 |
345 | 346 |
347 | 348 | 💾 serverless framework example 349 | 350 | Create a function which exports the aforementioned machine handler. 351 | 352 | ```yaml 353 | functions: 354 | machine: 355 | handler: src/handler.stateMachineHandler 356 | ``` 357 | 358 | Use the [serverless-step-functions](https://github.com/serverless-operations/serverless-step-functions) plugin to create a state machine which invokes the machine handler. 359 | 360 | ```yaml 361 | stepFunctions: 362 | stateMachines: 363 | ping: 364 | role: !GetAtt IamRoleLambdaExecution.Arn 365 | definition: 366 | StartAt: Wait 367 | States: 368 | Eval: 369 | Type: Task 370 | Resource: !GetAtt machine.Arn 371 | Next: Choose 372 | Wait: 373 | Type: Wait 374 | SecondsPath: '$.seconds' 375 | Next: Eval 376 | Choose: 377 | Type: Choice 378 | Choices: 379 | - Not: 380 | Variable: '$.state' 381 | StringEquals: 'ABORT' 382 | Next: Wait 383 | Default: End 384 | End: 385 | Type: Pass 386 | End: true 387 | ``` 388 | 389 | The state machine _arn_ can be passed to your websocket handler function via outputs. 390 | 391 | > Note: [naming of resources](https://www.serverless.com/framework/docs/providers/aws/guide/resources/) will be dependent the function/machine naming in the serverless config. 392 | 393 | ```yaml 394 | functions: 395 | subscription: 396 | handler: src/handler.gatewayHandler 397 | environment: 398 | PING_STATE_MACHINE_ARN: ${self:resources.Outputs.PingStateMachine.Value} 399 | # ... 400 | 401 | resources: 402 | Outputs: 403 | PingStateMachine: 404 | Value: 405 | Ref: PingStepFunctionsStateMachine 406 | ``` 407 | 408 | On `connection_init`, the state machine will be invoked. Ensure that the websocket handler has the following permissions. 409 | 410 | ```yaml 411 | - Effect: Allow 412 | Resource: !GetAtt PingStepFunctionsStateMachine.Arn 413 | Action: 414 | - states:StartExecution 415 | ``` 416 | 417 | The state machine itself will need the following permissions 418 | 419 | ```yaml 420 | - Effect: Allow 421 | Resource: !GetAtt connectionsTable.Arn 422 | Action: 423 | - dynamodb:GetItem 424 | - dynamodb:UpdateItem 425 | - Effect: Allow 426 | Resource: '*' 427 | Action: 428 | - execute-api:* 429 | ``` 430 | 431 | > Note: For a full reproduction, see the example project. 432 | 433 |
434 | 435 |
436 | 💾 terraform example 437 | 438 | Create a function which can be invoked by the state machine. 439 | 440 | ```tf 441 | resource "aws_lambda_function" "machine" { 442 | function_name = "machine" 443 | runtime = "nodejs14.x" 444 | filename = data.archive_file.handler.output_path 445 | source_code_hash = data.archive_file.handler.output_base64sha256 446 | handler = "example.stateMachineHandler" 447 | role = aws_iam_role.state_machine_function.arn 448 | 449 | environment { 450 | variables = { 451 | CONNECTIONS_TABLE = aws_dynamodb_table.connections.id 452 | SUBSCRIPTIONS_TABLE = aws_dynamodb_table.subscriptions.id 453 | } 454 | } 455 | } 456 | ``` 457 | 458 | Create the following state machine which will be invoked by the gateway handler. 459 | 460 | ```tf 461 | resource "aws_sfn_state_machine" "ping_state_machine" { 462 | name = "ping-state-machine" 463 | role_arn = aws_iam_role.state_machine.arn 464 | definition = jsonencode({ 465 | StartAt = "Wait" 466 | States = { 467 | Wait = { 468 | Type = "Wait" 469 | SecondsPath = "$.seconds" 470 | Next = "Eval" 471 | } 472 | Eval = { 473 | Type = "Task" 474 | Resource = aws_lambda_function.machine.arn 475 | Next = "Choose" 476 | } 477 | Choose = { 478 | Type = "Choice" 479 | Choices = [{ 480 | Not = { 481 | Variable = "$.state" 482 | StringEquals = "ABORT" 483 | } 484 | Next = "Wait" 485 | }] 486 | Default = "End" 487 | } 488 | End = { 489 | Type = "Pass" 490 | End = true 491 | } 492 | } 493 | }) 494 | } 495 | ``` 496 | 497 | The state machine _arn_ can be passed to your websocket handler via an environment variable. 498 | 499 | ```tf 500 | resource "aws_lambda_function" "gateway_handler" { 501 | # ... 502 | 503 | environment { 504 | variables = { 505 | # ... 506 | PING_STATE_MACHINE_ARN = aws_sfn_state_machine.ping_state_machine.arn 507 | } 508 | } 509 | } 510 | ``` 511 | 512 | > Note: For a full reproduction, see the example project. 513 | 514 |
515 | 516 | ## Usage 517 | 518 | ### PubSub 519 | 520 | `subscriptionless` uses it's own _PubSub_ implementation which loosely implements the [Apollo PubSub Interface](https://github.com/apollographql/graphql-subscriptions#pubsub-implementations). 521 | 522 | > Note: Unlike the Apollo `PubSub` library, this implementation is (mostly) stateless 523 | 524 |
525 | 526 | 📖 Subscribing to topics 527 | 528 | Use the `subscribe` function to associate incoming subscriptions with a topic. 529 | 530 | ```ts 531 | import { subscribe } from 'subscriptionless/subscribe'; 532 | 533 | export const resolver = { 534 | Subscribe: { 535 | mySubscription: { 536 | resolve: (event, args, context) => {/* ... */} 537 | subscribe: subscribe('MY_TOPIC'), 538 | } 539 | } 540 | } 541 | ``` 542 | 543 |
544 | 545 |
546 | 547 | 📖 Filtering events 548 | 549 | Wrap any `subscribe` function call in a `withFilter` to provide filter conditions. 550 | 551 | > Note: If a function is provided, it will be called **on subscription start** and must return a serializable object. 552 | 553 | ```ts 554 | import { withFilter, subscribe } from 'subscriptionless/subscribe'; 555 | 556 | // Subscription agnostic filter 557 | withFilter(subscribe('MY_TOPIC'), { 558 | attr1: '`attr1` must have this value', 559 | attr2: { 560 | attr3: 'Nested attributes work fine', 561 | }, 562 | }); 563 | 564 | // Subscription specific filter 565 | withFilter(subscribe('MY_TOPIC'), (root, args, context, info) => ({ 566 | userId: args.userId, 567 | })); 568 | ``` 569 | 570 |
571 | 572 |
573 | 574 | 📖 Concatenating topic subscriptions 575 | 576 | Join multiple topic subscriptions together using `concat`. 577 | 578 | ```tsx 579 | import { concat, subscribe } from 'subscriptionless/subscribe'; 580 | 581 | concat(subscribe('TOPIC_1'), subscribe('TOPIC_2')); 582 | ``` 583 | 584 |
585 | 586 |
587 | 588 | 📖 Publishing events 589 | 590 | Use the `publish` on your subscriptionless instance to publish events to active subscriptions. 591 | 592 | ```tsx 593 | instance.publish({ 594 | type: 'MY_TOPIC', 595 | payload: 'HELLO', 596 | }); 597 | ``` 598 | 599 | Events can come from many sources 600 | 601 | ```tsx 602 | // SNS Event 603 | export const snsHandler = (event) => 604 | Promise.all( 605 | event.Records.map((r) => 606 | instance.publish({ 607 | topic: r.Sns.TopicArn.substring(r.Sns.TopicArn.lastIndexOf(':') + 1), // Get topic name (e.g. "MY_TOPIC") 608 | payload: JSON.parse(r.Sns.Message), 609 | }) 610 | ) 611 | ); 612 | 613 | // Manual Invocation 614 | export const invocationHandler = (payload) => 615 | instance.publish({ topic: 'MY_TOPIC', payload }); 616 | ``` 617 | 618 |
619 | 620 | ### Context 621 | 622 | Context values are accessible in all resolver level functions (`resolve`, `subscribe`, `onSubscribe` and `onComplete`). 623 | 624 |
625 | 626 | 📖 Default value 627 | 628 | Assuming no `context` argument is provided, the default value is an object containing a `connectionParams` attribute. 629 | 630 | This attribute contains the [(optionally parsed)](#events) payload from `connection_init`. 631 | 632 | ```ts 633 | export const resolver = { 634 | Subscribe: { 635 | mySubscription: { 636 | resolve: (event, args, context) => { 637 | console.log(context.connectionParams); // payload from connection_init 638 | }, 639 | }, 640 | }, 641 | }; 642 | ``` 643 | 644 |
645 | 646 |
647 | 648 | 📖 Setting static context value 649 | 650 | An object can be provided via the `context` attribute when calling `createInstance`. 651 | 652 | ```ts 653 | const instance = createInstance({ 654 | /* ... */ 655 | context: { 656 | myAttr: 'hello', 657 | }, 658 | }); 659 | ``` 660 | 661 | The default values (above) will be appended to this object prior to execution. 662 | 663 |
664 | 665 |
666 | 667 | 📖 Setting dynamic context value 668 | 669 | A function (optionally async) can be provided via the `context` attribute when calling `createInstance`. 670 | 671 | The default context value is passed as an argument. 672 | 673 | ```ts 674 | const instance = createInstance({ 675 | /* ... */ 676 | context: ({ connectionParams }) => ({ 677 | myAttr: 'hello', 678 | user: connectionParams.user, 679 | }), 680 | }); 681 | ``` 682 | 683 |
684 | 685 | ### Side effects 686 | 687 | Side effect handlers can be declared on subscription fields to handle `onSubscribe` (start) and `onComplete` (stop) events. 688 | 689 |
690 | 691 | 📖 Enabling side effects 692 | 693 | For `onSubscribe` and `onComplete` side effects to work, resolvers must first be passed to `prepareResolvers` prior to schema construction. 694 | 695 | ```ts 696 | import { prepareResolvers } from 'subscriptionless/subscribe'; 697 | 698 | const schema = makeExecutableSchema({ 699 | typedefs, 700 | resolvers: prepareResolvers(resolvers), 701 | }); 702 | ``` 703 | 704 |
705 | 706 |
707 | 708 | 📖 Adding side-effect handlers 709 | 710 | ```ts 711 | export const resolver = { 712 | Subscribe: { 713 | mySubscription: { 714 | resolve: (event, args, context) => { 715 | /* ... */ 716 | }, 717 | subscribe: subscribe('MY_TOPIC'), 718 | onSubscribe: (root, args) => { 719 | /* Do something on subscription start */ 720 | }, 721 | onComplete: (root, args) => { 722 | /* Do something on subscription stop */ 723 | }, 724 | }, 725 | }, 726 | }; 727 | ``` 728 | 729 |
730 | 731 | ### Events 732 | 733 | Global events can be provided when calling `createInstance` to track the execution cycle of the lambda. 734 | 735 |
736 | 737 | 📖 Connect (onConnect) 738 | 739 | Called on an incoming API Gateway `$connect` event. 740 | 741 | ```ts 742 | const instance = createInstance({ 743 | /* ... */ 744 | onConnect: ({ event }) => { 745 | /* */ 746 | }, 747 | }); 748 | ``` 749 | 750 |
751 | 752 |
753 | 754 | 📖 Disconnect (onDisconnect) 755 | 756 | Called on an incoming API Gateway `$disconnect` event. 757 | 758 | ```ts 759 | const instance = createInstance({ 760 | /* ... */ 761 | onDisconnect: ({ event }) => { 762 | /* */ 763 | }, 764 | }); 765 | ``` 766 | 767 |
768 | 769 |
770 | 771 | 📖 Authorization (connection_init) 772 | 773 | Called on incoming graphql-ws `connection_init` message. 774 | 775 | `onConnectionInit` can be used to verify the `connection_init` payload prior to persistence. 776 | 777 | > **Note:** Any sensitive data in the incoming message should be removed at this stage. 778 | 779 | ```ts 780 | const instance = createInstance({ 781 | /* ... */ 782 | onConnectionInit: ({ message }) => { 783 | const token = message.payload.token; 784 | 785 | if (!myValidation(token)) { 786 | throw Error('Token validation failed'); 787 | } 788 | 789 | // Prevent sensitive data from being written to DB 790 | return { 791 | ...message.payload, 792 | token: undefined, 793 | }; 794 | }, 795 | }); 796 | ``` 797 | 798 | By default, the (optionally parsed) payload will be accessible via [context](#context). 799 | 800 |
801 | 802 |
803 | 804 | 📖 Subscribe (onSubscribe) 805 | 806 | #### Subscribe (onSubscribe) 807 | 808 | Called on incoming graphql-ws `subscribe` message. 809 | 810 | ```ts 811 | const instance = createInstance({ 812 | /* ... */ 813 | onSubscribe: ({ event, message }) => { 814 | /* */ 815 | }, 816 | }); 817 | ``` 818 | 819 |
820 | 821 |
822 | 823 | 📖 Complete (onComplete) 824 | 825 | Called on graphql-ws `complete` message. 826 | 827 | ```ts 828 | const instance = createInstance({ 829 | /* ... */ 830 | onComplete: ({ event, message }) => { 831 | /* */ 832 | }, 833 | }); 834 | ``` 835 | 836 |
837 | 838 |
839 | 840 | 📖 Ping (onPing) 841 | 842 | Called on incoming graphql-ws `ping` message. 843 | 844 | ```ts 845 | const instance = createInstance({ 846 | /* ... */ 847 | onPing: ({ event, message }) => { 848 | /* */ 849 | }, 850 | }); 851 | ``` 852 | 853 |
854 | 855 |
856 | 857 | 📖 Pong (onPong) 858 | 859 | Called on incoming graphql-ws `pong` message. 860 | 861 | ```ts 862 | const instance = createInstance({ 863 | /* ... */ 864 | onPong: ({ event, message }) => { 865 | /* */ 866 | }, 867 | }); 868 | ``` 869 | 870 |
871 | 872 |
873 | 874 | 📖 Error (onError) 875 | 876 | Called on unexpected errors during resolution of API Gateway or graphql-ws events. 877 | 878 | ```ts 879 | const instance = createInstance({ 880 | /* ... */ 881 | onError: (error, context) => { 882 | /* */ 883 | }, 884 | }); 885 | ``` 886 | 887 |
888 | --------------------------------------------------------------------------------