Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/andyrichardson/subscriptionless
GraphQL subscriptions (and more) on serverless infrastructure
https://github.com/andyrichardson/subscriptionless
api-gateway aws graphql lambda serverless-framework subscriptions websocket
Last synced: 2 months ago
JSON representation
GraphQL subscriptions (and more) on serverless infrastructure
- Host: GitHub
- URL: https://github.com/andyrichardson/subscriptionless
- Owner: andyrichardson
- License: mit
- Created: 2021-02-12T10:47:27.000Z (almost 4 years ago)
- Default Branch: master
- Last Pushed: 2023-04-12T15:03:47.000Z (over 1 year ago)
- Last Synced: 2024-10-15T15:19:18.166Z (3 months ago)
- Topics: api-gateway, aws, graphql, lambda, serverless-framework, subscriptions, websocket
- Language: TypeScript
- Homepage:
- Size: 12.4 MB
- Stars: 93
- Watchers: 7
- Forks: 3
- Open Issues: 12
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
- awesome-list - subscriptionless
README
## About
GraphQL subscriptions for AWS Lambda and API Gateway WebSockets.
Have all the functionality of GraphQL subscriptions on a stateful server without the cost.
> Note: This project uses the [graphql-ws protocol](https://github.com/enisdenjo/graphql-ws) under the hood.
## ⚠️ Limitations
Seriously, **read this first** before you even think about using this.
This is in betaThis is beta and should be treated as such.
AWS API Gateway LimitationsThere are a few noteworthy limitations to the AWS API Gateway WebSocket implementation.
> Note: If you work on AWS and want to run through this, hit me up!
#### Socket timeouts
Default socket idleness [detection in API Gateway is unpredictable](https://github.com/andyrichardson/subscriptionless/issues/3).
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.
#### Socket errors
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).
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).
## Setup
#### Create a subscriptionless instance.
```ts
import { createInstance } from 'subscriptionless';const instance = createInstance({
schema,
});
```#### Export the handler.
```ts
export const gatewayHandler = instance.gatewayHandler;
```#### Configure API Gateway
Set up API Gateway to route WebSocket events to the exported handler.
💾 serverless framework example
```yaml
functions:
websocket:
name: my-subscription-lambda
handler: ./handler.gatewayHandler
events:
- websocket:
route: $connect
- websocket:
route: $disconnect
- websocket:
route: $default
```💾 terraform example
```tf
resource "aws_apigatewayv2_api" "ws" {
name = "websocket-api"
protocol_type = "WEBSOCKET"
route_selection_expression = "$request.body.action"
}resource "aws_apigatewayv2_route" "default_route" {
api_id = aws_apigatewayv2_api.ws.id
route_key = "$default"
target = "integrations/${aws_apigatewayv2_integration.default_integration.id}"
}resource "aws_apigatewayv2_route" "connect_route" {
api_id = aws_apigatewayv2_api.ws.id
route_key = "$connect"
target = "integrations/${aws_apigatewayv2_integration.default_integration.id}"
}resource "aws_apigatewayv2_route" "disconnect_route" {
api_id = aws_apigatewayv2_api.ws.id
route_key = "$disconnect"
target = "integrations/${aws_apigatewayv2_integration.default_integration.id}"
}resource "aws_apigatewayv2_integration" "default_integration" {
api_id = aws_apigatewayv2_api.ws.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.gateway_handler.invoke_arn
}resource "aws_lambda_permission" "apigateway_invoke_lambda" {
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.gateway_handler.function_name
principal = "apigateway.amazonaws.com"
}resource "aws_apigatewayv2_deployment" "ws" {
api_id = aws_apigatewayv2_api.ws.idtriggers = {
redeployment = sha1(join(",", tolist([
jsonencode(aws_apigatewayv2_integration.default_integration),
jsonencode(aws_apigatewayv2_route.default_route),
jsonencode(aws_apigatewayv2_route.connect_route),
jsonencode(aws_apigatewayv2_route.disconnect_route),
])))
}depends_on = [
aws_apigatewayv2_route.default_route,
aws_apigatewayv2_route.connect_route,
aws_apigatewayv2_route.disconnect_route
]
}resource "aws_apigatewayv2_stage" "ws" {
api_id = aws_apigatewayv2_api.ws.id
name = "example"
deployment_id = aws_apigatewayv2_deployment.ws.id
}
```#### Create DynanmoDB tables for state
In-flight connections and subscriptions need to be persisted.
📖 Changing DynamoDB table namesUse the `tableNames` argument to override the default table names.
```ts
const instance = createInstance({
/* ... */
tableNames: {
connections: 'my_connections',
subscriptions: 'my_subscriptions',
},
});
```
💾 serverless framework example```yaml
resources:
Resources:
# Table for tracking connections
connectionsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.CONNECTIONS_TABLE}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
# Table for tracking subscriptions
subscriptionsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.SUBSCRIPTIONS_TABLE}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: topic
AttributeType: S
- AttributeName: connectionId
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
- AttributeName: topic
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: ConnectionIndex
KeySchema:
- AttributeName: connectionId
KeyType: HASH
Projection:
ProjectionType: ALL
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
- IndexName: TopicIndex
KeySchema:
- AttributeName: topic
KeyType: HASH
Projection:
ProjectionType: ALL
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
```
💾 terraform example```tf
resource "aws_dynamodb_table" "connections-table" {
name = "subscriptionless_connections"
billing_mode = "PROVISIONED"
read_capacity = 1
write_capacity = 1
hash_key = "id"attribute {
name = "id"
type = "S"
}ttl {
attribute_name = "ttl"
enabled = true
}
}resource "aws_dynamodb_table" "subscriptions-table" {
name = "subscriptionless_subscriptions"
billing_mode = "PROVISIONED"
read_capacity = 1
write_capacity = 1
hash_key = "id"
range_key = "topic"attribute {
name = "id"
type = "S"
}attribute {
name = "topic"
type = "S"
}attribute {
name = "connectionId"
type = "S"
}global_secondary_index {
name = "ConnectionIndex"
hash_key = "connectionId"
write_capacity = 1
read_capacity = 1
projection_type = "ALL"
}global_secondary_index {
name = "TopicIndex"
hash_key = "topic"
write_capacity = 1
read_capacity = 1
projection_type = "ALL"
}ttl {
attribute_name = "ttl"
enabled = true
}
}
```#### Configure idleness detection (ping/pong)
Set up server->client pinging for socket idleness detection.
> Note: While not a hard requirement, this is [strongly recommended](#%EF%B8%8F-limitations).
📖 Configuring instance
Pass a `ping` argument to configure delays and what state machine to invoke.
```ts
const instance = createInstance({
/* ... */
ping: {
interval: 60, // Rate in seconds to send ping message
timeout: 30, // Threshold for pong response before closing socket
machineArn: process.env.MACHINE_ARN, // State machine to invoke
},
});
```Export the resulting handler for use by the state machine.
```ts
export const stateMachineHandler = instance.stateMachineHandler;
```💾 serverless framework example
Create a function which exports the aforementioned machine handler.
```yaml
functions:
machine:
handler: src/handler.stateMachineHandler
```Use the [serverless-step-functions](https://github.com/serverless-operations/serverless-step-functions) plugin to create a state machine which invokes the machine handler.
```yaml
stepFunctions:
stateMachines:
ping:
role: !GetAtt IamRoleLambdaExecution.Arn
definition:
StartAt: Wait
States:
Eval:
Type: Task
Resource: !GetAtt machine.Arn
Next: Choose
Wait:
Type: Wait
SecondsPath: '$.seconds'
Next: Eval
Choose:
Type: Choice
Choices:
- Not:
Variable: '$.state'
StringEquals: 'ABORT'
Next: Wait
Default: End
End:
Type: Pass
End: true
```The state machine _arn_ can be passed to your websocket handler function via outputs.
> 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.
```yaml
functions:
subscription:
handler: src/handler.gatewayHandler
environment:
PING_STATE_MACHINE_ARN: ${self:resources.Outputs.PingStateMachine.Value}
# ...resources:
Outputs:
PingStateMachine:
Value:
Ref: PingStepFunctionsStateMachine
```On `connection_init`, the state machine will be invoked. Ensure that the websocket handler has the following permissions.
```yaml
- Effect: Allow
Resource: !GetAtt PingStepFunctionsStateMachine.Arn
Action:
- states:StartExecution
```The state machine itself will need the following permissions
```yaml
- Effect: Allow
Resource: !GetAtt connectionsTable.Arn
Action:
- dynamodb:GetItem
- dynamodb:UpdateItem
- Effect: Allow
Resource: '*'
Action:
- execute-api:*
```> Note: For a full reproduction, see the example project.
💾 terraform example
Create a function which can be invoked by the state machine.
```tf
resource "aws_lambda_function" "machine" {
function_name = "machine"
runtime = "nodejs14.x"
filename = data.archive_file.handler.output_path
source_code_hash = data.archive_file.handler.output_base64sha256
handler = "example.stateMachineHandler"
role = aws_iam_role.state_machine_function.arnenvironment {
variables = {
CONNECTIONS_TABLE = aws_dynamodb_table.connections.id
SUBSCRIPTIONS_TABLE = aws_dynamodb_table.subscriptions.id
}
}
}
```Create the following state machine which will be invoked by the gateway handler.
```tf
resource "aws_sfn_state_machine" "ping_state_machine" {
name = "ping-state-machine"
role_arn = aws_iam_role.state_machine.arn
definition = jsonencode({
StartAt = "Wait"
States = {
Wait = {
Type = "Wait"
SecondsPath = "$.seconds"
Next = "Eval"
}
Eval = {
Type = "Task"
Resource = aws_lambda_function.machine.arn
Next = "Choose"
}
Choose = {
Type = "Choice"
Choices = [{
Not = {
Variable = "$.state"
StringEquals = "ABORT"
}
Next = "Wait"
}]
Default = "End"
}
End = {
Type = "Pass"
End = true
}
}
})
}
```The state machine _arn_ can be passed to your websocket handler via an environment variable.
```tf
resource "aws_lambda_function" "gateway_handler" {
# ...environment {
variables = {
# ...
PING_STATE_MACHINE_ARN = aws_sfn_state_machine.ping_state_machine.arn
}
}
}
```> Note: For a full reproduction, see the example project.
## Usage
### PubSub
`subscriptionless` uses it's own _PubSub_ implementation which loosely implements the [Apollo PubSub Interface](https://github.com/apollographql/graphql-subscriptions#pubsub-implementations).
> Note: Unlike the Apollo `PubSub` library, this implementation is (mostly) stateless
📖 Subscribing to topicsUse the `subscribe` function to associate incoming subscriptions with a topic.
```ts
import { subscribe } from 'subscriptionless/subscribe';export const resolver = {
Subscribe: {
mySubscription: {
resolve: (event, args, context) => {/* ... */}
subscribe: subscribe('MY_TOPIC'),
}
}
}
```
📖 Filtering eventsWrap any `subscribe` function call in a `withFilter` to provide filter conditions.
> Note: If a function is provided, it will be called **on subscription start** and must return a serializable object.
```ts
import { withFilter, subscribe } from 'subscriptionless/subscribe';// Subscription agnostic filter
withFilter(subscribe('MY_TOPIC'), {
attr1: '`attr1` must have this value',
attr2: {
attr3: 'Nested attributes work fine',
},
});// Subscription specific filter
withFilter(subscribe('MY_TOPIC'), (root, args, context, info) => ({
userId: args.userId,
}));
```
📖 Concatenating topic subscriptionsJoin multiple topic subscriptions together using `concat`.
```tsx
import { concat, subscribe } from 'subscriptionless/subscribe';concat(subscribe('TOPIC_1'), subscribe('TOPIC_2'));
```
📖 Publishing eventsUse the `publish` on your subscriptionless instance to publish events to active subscriptions.
```tsx
instance.publish({
type: 'MY_TOPIC',
payload: 'HELLO',
});
```Events can come from many sources
```tsx
// SNS Event
export const snsHandler = (event) =>
Promise.all(
event.Records.map((r) =>
instance.publish({
topic: r.Sns.TopicArn.substring(r.Sns.TopicArn.lastIndexOf(':') + 1), // Get topic name (e.g. "MY_TOPIC")
payload: JSON.parse(r.Sns.Message),
})
)
);// Manual Invocation
export const invocationHandler = (payload) =>
instance.publish({ topic: 'MY_TOPIC', payload });
```### Context
Context values are accessible in all resolver level functions (`resolve`, `subscribe`, `onSubscribe` and `onComplete`).
📖 Default valueAssuming no `context` argument is provided, the default value is an object containing a `connectionParams` attribute.
This attribute contains the [(optionally parsed)](#events) payload from `connection_init`.
```ts
export const resolver = {
Subscribe: {
mySubscription: {
resolve: (event, args, context) => {
console.log(context.connectionParams); // payload from connection_init
},
},
},
};
```
📖 Setting static context valueAn object can be provided via the `context` attribute when calling `createInstance`.
```ts
const instance = createInstance({
/* ... */
context: {
myAttr: 'hello',
},
});
```The default values (above) will be appended to this object prior to execution.
📖 Setting dynamic context valueA function (optionally async) can be provided via the `context` attribute when calling `createInstance`.
The default context value is passed as an argument.
```ts
const instance = createInstance({
/* ... */
context: ({ connectionParams }) => ({
myAttr: 'hello',
user: connectionParams.user,
}),
});
```### Side effects
Side effect handlers can be declared on subscription fields to handle `onSubscribe` (start) and `onComplete` (stop) events.
📖 Enabling side effectsFor `onSubscribe` and `onComplete` side effects to work, resolvers must first be passed to `prepareResolvers` prior to schema construction.
```ts
import { prepareResolvers } from 'subscriptionless/subscribe';const schema = makeExecutableSchema({
typedefs,
resolvers: prepareResolvers(resolvers),
});
```
📖 Adding side-effect handlers```ts
export const resolver = {
Subscribe: {
mySubscription: {
resolve: (event, args, context) => {
/* ... */
},
subscribe: subscribe('MY_TOPIC'),
onSubscribe: (root, args) => {
/* Do something on subscription start */
},
onComplete: (root, args) => {
/* Do something on subscription stop */
},
},
},
};
```### Events
Global events can be provided when calling `createInstance` to track the execution cycle of the lambda.
📖 Connect (onConnect)Called on an incoming API Gateway `$connect` event.
```ts
const instance = createInstance({
/* ... */
onConnect: ({ event }) => {
/* */
},
});
```
📖 Disconnect (onDisconnect)Called on an incoming API Gateway `$disconnect` event.
```ts
const instance = createInstance({
/* ... */
onDisconnect: ({ event }) => {
/* */
},
});
```
📖 Authorization (connection_init)Called on incoming graphql-ws `connection_init` message.
`onConnectionInit` can be used to verify the `connection_init` payload prior to persistence.
> **Note:** Any sensitive data in the incoming message should be removed at this stage.
```ts
const instance = createInstance({
/* ... */
onConnectionInit: ({ message }) => {
const token = message.payload.token;if (!myValidation(token)) {
throw Error('Token validation failed');
}// Prevent sensitive data from being written to DB
return {
...message.payload,
token: undefined,
};
},
});
```By default, the (optionally parsed) payload will be accessible via [context](#context).
📖 Subscribe (onSubscribe)#### Subscribe (onSubscribe)
Called on incoming graphql-ws `subscribe` message.
```ts
const instance = createInstance({
/* ... */
onSubscribe: ({ event, message }) => {
/* */
},
});
```
📖 Complete (onComplete)Called on graphql-ws `complete` message.
```ts
const instance = createInstance({
/* ... */
onComplete: ({ event, message }) => {
/* */
},
});
```
📖 Ping (onPing)Called on incoming graphql-ws `ping` message.
```ts
const instance = createInstance({
/* ... */
onPing: ({ event, message }) => {
/* */
},
});
```
📖 Pong (onPong)Called on incoming graphql-ws `pong` message.
```ts
const instance = createInstance({
/* ... */
onPong: ({ event, message }) => {
/* */
},
});
```📖 Error (onError)
Called on unexpected errors during resolution of API Gateway or graphql-ws events.
```ts
const instance = createInstance({
/* ... */
onError: (error, context) => {
/* */
},
});
```