@the_pixelport/aws-lambda-graphql
Version:
Apollo server for AWS Lambda with WebSocket subscriptions support over API Gateway v1 + v2
365 lines • 19.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Server = void 0;
const apollo_server_lambda_1 = require("apollo-server-lambda");
const assert_1 = __importDefault(require("assert"));
const iterall_1 = require("iterall");
const graphql_subscriptions_1 = require("graphql-subscriptions");
const helpers_1 = require("./helpers");
const protocol_1 = require("./protocol");
const formatMessage_1 = require("./formatMessage");
const execute_1 = require("./execute");
class Server extends apollo_server_lambda_1.ApolloServer {
constructor({ connectionManager, context, eventProcessor, onError, subscriptionManager, subscriptions, ...restConfig }) {
super({
...restConfig,
context:
// if context is function, pass integration context from graphql server options and then merge the result
// if it's object, merge it with integrationContext
typeof context === 'function'
? (integrationContext) => Promise.resolve(context(integrationContext)).then((ctx) => ({
...ctx,
...integrationContext,
}))
: (integrationContext) => ({
...context,
...integrationContext,
}),
});
assert_1.default.ok(connectionManager, 'Please provide connectionManager and ensure it implements IConnectionManager');
assert_1.default.ok(eventProcessor, 'Please provide eventProcessor and ensure it implements IEventProcessor');
assert_1.default.ok(subscriptionManager, 'Please provide subscriptionManager and ensure it implements ISubscriptionManager');
assert_1.default.ok(typeof onError === 'function' || onError == null, 'onError must be a function');
assert_1.default.ok(subscriptions == null || typeof subscriptions === 'object', 'Property subscriptions must be an object');
this.connectionManager = connectionManager;
this.eventProcessor = eventProcessor;
this.onError = onError || ((err) => console.error(err));
this.subscriptionManager = subscriptionManager;
this.subscriptionOptions = subscriptions;
}
getConnectionManager() {
return this.connectionManager;
}
getSubscriptionManager() {
return this.subscriptionManager;
}
createGraphQLServerOptions(event, context, internal) {
const $$internal = {
// this provides all other internal params
// that are assigned in web socket handler
...internal,
connectionManager: this.connectionManager,
subscriptionManager: this.subscriptionManager,
};
return super
.graphQLServerOptions({
event,
lambdaContext: context,
$$internal,
...($$internal.connection && $$internal.connection.data
? $$internal.connection.data.context
: {}),
})
.then((options) => ({ ...options, $$internal }));
}
/**
* Event handler is responsible for processing published events and sending them
* to all subscribed connections
*/
createEventHandler() {
return this.eventProcessor.createHandler(this);
}
/**
* HTTP event handler is responsible for processing AWS API Gateway v1 events
*/
createHttpHandler(options) {
const handler = this.createHandler(options);
return (event, context) => {
return new Promise((resolve, reject) => {
try {
handler(event, context, (err, result) => {
if (err) {
reject(err);
}
else {
resolve(result);
}
});
}
catch (e) {
reject(e);
}
});
};
}
/**
* WebSocket handler is responsible for processing AWS API Gateway v2 events
*/
createWebSocketHandler() {
return async (event, lambdaContext) => {
var _a, _b, _c;
try {
// based on routeKey, do actions
switch (event.requestContext.routeKey) {
case '$connect': {
const { onWebsocketConnect, connectionEndpoint } = this.subscriptionOptions || {};
// register connection
// if error is thrown during registration, connection is rejected
// we can implement some sort of authorization here
const endpoint = connectionEndpoint || (0, helpers_1.extractEndpointFromEvent)(event);
console.log('[DEBUG] Server $connect route:', {
customConnectionEndpoint: connectionEndpoint,
extractedEndpoint: (0, helpers_1.extractEndpointFromEvent)(event),
finalEndpoint: endpoint,
connectionId: event.requestContext.connectionId,
});
const connection = await this.connectionManager.registerConnection({
endpoint,
connectionId: event.requestContext.connectionId,
});
let newConnectionContext = {};
if (onWebsocketConnect) {
try {
const result = await onWebsocketConnect(connection, event, lambdaContext);
if (result === false) {
throw new Error('Prohibited connection!');
}
else if (result !== null && typeof result === 'object') {
newConnectionContext = result;
}
}
catch (err) {
const errorResponse = (0, formatMessage_1.formatMessage)({
type: protocol_1.SERVER_EVENT_TYPES.GQL_ERROR,
payload: {
message: err instanceof Error ? err.message : String(err),
},
});
await this.connectionManager.unregisterConnection(connection);
return {
body: errorResponse,
statusCode: 401,
};
}
}
// set connection context which will be available during graphql execution
const connectionData = {
...connection.data,
context: newConnectionContext,
};
await this.connectionManager.setConnectionData(connectionData, connection);
return {
body: '',
headers: ((_b = (_a = event.headers) === null || _a === void 0 ? void 0 : _a['Sec-WebSocket-Protocol']) === null || _b === void 0 ? void 0 : _b.includes('graphql-ws'))
? {
'Sec-WebSocket-Protocol': 'graphql-ws',
}
: undefined,
statusCode: 200,
};
}
case '$disconnect': {
const { onDisconnect } = this.subscriptionOptions || {};
// this event is called eventually by AWS APIGateway v2
// we actualy don't care about a result of this operation because client is already
// disconnected, it is meant only for clean up purposes
// hydrate connection
const connection = await this.connectionManager.hydrateConnection(event.requestContext.connectionId);
if (onDisconnect) {
onDisconnect(connection);
}
await this.connectionManager.unregisterConnection(connection);
return {
body: '',
statusCode: 200,
};
}
case '$default': {
// here we are processing messages received from a client
// if we respond here and the route has integration response assigned
// it will send the body back to client, so it is easy to respond with operation results
const { connectionId } = event.requestContext;
const { onConnect, onOperation, onOperationComplete, waitForInitialization: { retryCount: waitRetryCount = 10, timeout: waitTimeout = 50, } = {}, } = this.subscriptionOptions || {};
// parse operation from body
const operation = (0, helpers_1.parseOperationFromEvent)(event);
// hydrate connection
let connection = await this.connectionManager.hydrateConnection(connectionId, {
retryCount: 1,
timeout: waitTimeout,
});
if ((0, protocol_1.isGQLConnectionInit)(operation)) {
let newConnectionContext = operation.payload;
if (onConnect) {
try {
const result = await onConnect(operation.payload, connection, event, lambdaContext);
if (result === false) {
throw new Error('Prohibited connection!');
}
else if (result !== null && typeof result === 'object') {
newConnectionContext = result;
}
}
catch (err) {
const errorResponse = (0, formatMessage_1.formatMessage)({
type: protocol_1.SERVER_EVENT_TYPES.GQL_ERROR,
payload: {
message: err instanceof Error ? err.message : String(err),
},
});
await this.connectionManager.sendToConnection(connection, errorResponse);
await this.connectionManager.closeConnection(connection);
return {
body: errorResponse,
statusCode: 401,
};
}
}
// set connection context which will be available during graphql execution
const connectionData = {
...connection.data,
context: {
...(_c = connection.data) === null || _c === void 0 ? void 0 : _c.context,
...newConnectionContext,
},
isInitialized: true,
};
await this.connectionManager.setConnectionData(connectionData, connection);
// send GQL_CONNECTION_INIT message to client
const response = (0, formatMessage_1.formatMessage)({
type: protocol_1.SERVER_EVENT_TYPES.GQL_CONNECTION_ACK,
});
await this.connectionManager.sendToConnection(connection, response);
return {
body: response,
statusCode: 200,
};
}
// wait for connection to be initialized
connection = await (async () => {
let freshConnection = connection;
if (freshConnection.data.isInitialized) {
return freshConnection;
}
for (let i = 0; i <= waitRetryCount; i++) {
freshConnection = await this.connectionManager.hydrateConnection(connectionId);
if (freshConnection.data.isInitialized) {
return freshConnection;
}
// wait for another round
await new Promise((r) => setTimeout(r, waitTimeout));
}
return freshConnection;
})();
if (!connection.data.isInitialized) {
// refuse connection which did not send GQL_CONNECTION_INIT operation
const errorResponse = (0, formatMessage_1.formatMessage)({
type: protocol_1.SERVER_EVENT_TYPES.GQL_ERROR,
payload: { message: 'Prohibited connection!' },
});
await this.connectionManager.sendToConnection(connection, errorResponse);
await this.connectionManager.closeConnection(connection);
return {
body: errorResponse,
statusCode: 401,
};
}
if ((0, protocol_1.isGQLStopOperation)(operation)) {
// unsubscribe client
if (onOperationComplete) {
onOperationComplete(connection, operation.id);
}
const response = (0, formatMessage_1.formatMessage)({
id: operation.id,
type: protocol_1.SERVER_EVENT_TYPES.GQL_COMPLETE,
});
await this.connectionManager.sendToConnection(connection, response);
await this.subscriptionManager.unsubscribeOperation(connection.id, operation.id);
return {
body: response,
statusCode: 200,
};
}
if ((0, protocol_1.isGQLConnectionTerminate)(operation)) {
// unregisterConnection will be handled by $disconnect, return straightaway
return {
body: '',
statusCode: 200,
};
}
const pubSub = new graphql_subscriptions_1.PubSub();
// following line is really redundant but we need to
// this makes sure that if you invoke the event
// and you use Context creator function
// then it'll be called with $$internal context according to spec
const options = await this.createGraphQLServerOptions(event, lambdaContext, {
// this allows createGraphQLServerOptions() to append more extra data
// to context from connection.data.context
connection,
operation,
pubSub,
registerSubscriptions: true,
});
const result = await (0, execute_1.execute)({
...options,
connection,
connectionManager: this.connectionManager,
event,
lambdaContext,
onOperation,
operation,
pubSub,
// tell execute to register subscriptions
registerSubscriptions: true,
subscriptionManager: this.subscriptionManager,
});
if (!(0, iterall_1.isAsyncIterable)(result)) {
// send response to client so it can finish operation in case of query or mutation
if (onOperationComplete) {
onOperationComplete(connection, operation.operationId);
}
const response = (0, formatMessage_1.formatMessage)({
id: operation.operationId,
payload: result,
type: protocol_1.SERVER_EVENT_TYPES.GQL_DATA,
});
await this.connectionManager.sendToConnection(connection, response);
return {
body: response,
statusCode: 200,
};
}
// this is just to make sure
// when you deploy this using serverless cli
// then integration response is not assigned to $default route
// so this won't make any difference
// but the sendToConnection above will send the response to client
// so client'll receive the response for his operation
return {
body: '',
statusCode: 200,
};
}
default: {
throw new Error(`Invalid event ${event.requestContext.routeKey} received`);
}
}
}
catch (e) {
this.onError(e);
return {
body: (e instanceof Error ? e.message : String(e)) ||
'Internal server error',
statusCode: 500,
};
}
};
}
installSubscriptionHandlers() {
throw new Error(`Please don't use this method as this server handles subscriptions in it's own way in createWebSocketHandler()`);
}
}
exports.Server = Server;
//# sourceMappingURL=Server.js.map