From ed71098a1ef1b6e2edc9b9e0ceadae7e441a6202 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Tue, 18 May 2021 15:20:32 -0400 Subject: [PATCH] feat!: add websocket server option BREAKING CHANGE: the subscription server export is now an abstract class --- README.md | 35 +++ package.json | 11 +- src/AuthorizedSocketConnection.ts | 17 +- src/GraphqlSocketSubscriptionServer.ts | 110 --------- src/SocketIOSubscriptionServer.ts | 67 ++++++ src/SubscriptionServer.ts | 46 +--- src/WebSocketSubscriptionServer.ts | 170 ++++++++++++++ src/index.ts | 15 +- src/types.ts | 28 +++ test/data/database.js | 94 ++++++++ test/data/schema.graphql | 214 +++++++++++++++++ test/data/schema.js | 307 +++++++++++++++++++++++++ test/helpers.ts | 230 ++++++++++++++++++ test/socket-io.test.ts | 136 +++++++++++ test/tsconfig.json | 3 +- test/websocket.test.ts | 124 ++++++++++ update-schema.js | 11 + yarn.lock | 232 +++++++++++++++---- 18 files changed, 1640 insertions(+), 210 deletions(-) delete mode 100644 src/GraphqlSocketSubscriptionServer.ts create mode 100644 src/SocketIOSubscriptionServer.ts create mode 100644 src/WebSocketSubscriptionServer.ts create mode 100644 src/types.ts create mode 100644 test/data/database.js create mode 100644 test/data/schema.graphql create mode 100644 test/data/schema.js create mode 100644 test/helpers.ts create mode 100644 test/socket-io.test.ts create mode 100644 test/websocket.test.ts create mode 100644 update-schema.js diff --git a/README.md b/README.md index 7fe3dc73..7246ee50 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ # GraphQL Subscription Server + +A subscription server for GraphQL subscriptions. Supports streaming over plain web sockets +or Socket.IO, and integrates with Redis or any other Pub/Sub service. + +## Setup + +### Socket.IO + +```js +import http from 'http'; +import { + SocketIOSubscriptionServer, // or WebSocketSubscriptionServer + JwtCredentialManager, + RedisSubscriber, +} from '@4c/graphql-subscription-server'; + +const server = http.createServer(); + +const subscriptionServer = new SocketIOSubscriptionServer({ + schema, + path: '/socket.io/graphql', + subscriber: new RedisSubscriber(), + hasPermission: (message, credentials) => { + authorize(message, credentials); + }, + createCredentialsManager: (req) => new JwtCredentialManager(), + createLogger: () => console.debug, +}); + +subscriptionServer.attach(server); + +server.listen(4000, () => { + console.log('server running'); +}); +``` diff --git a/package.json b/package.json index 55d39ecb..5a078ecd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "tdd": "jest --watch", "test": "yarn lint && yarn typecheck && jest", "testonly": "jest", - "typecheck": "tsc --noEmit && tsc -p test --noEmit" + "typecheck": "tsc --noEmit && tsc -p test --noEmit", + "update-schema": "NODE_ENV=test babel-node ./update-schema.js" }, "husky": { "hooks": { @@ -51,7 +52,7 @@ "express": "^4.17.1", "graphql-ws": "^4.3.2", "redis": "^3.0.0", - "ws": "^7.4.4" + "ws": "^7.4.5" }, "peerDependencies": { "graphql": ">=0.12.3", @@ -71,6 +72,7 @@ "@4c/tsconfig": "^0.3.1", "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", + "@babel/node": "^7.14.2", "@babel/preset-typescript": "^7.12.7", "@types/express": "^4.17.9", "@types/jest": "^26.0.19", @@ -88,13 +90,16 @@ "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.0", "graphql": "^15.4.0", + "graphql-relay": "^0.6.0", + "graphql-relay-subscription": "^0.3.1", "husky": "^4.3.6", "jest": "^26.6.3", "lint-staged": "^10.5.3", "prettier": "^2.2.1", "redis-mock": "^0.55.1", "semantic-release": "^17.3.0", - "socket.io": "^3.0.4", + "socket.io": "^4.1.2", + "socket.io-client": "^4.1.2", "travis-deploy-once": "^5.0.11", "typescript": "^4.1.3", "utility-types": "^3.10.0" diff --git a/src/AuthorizedSocketConnection.ts b/src/AuthorizedSocketConnection.ts index bfeccb0e..ba3fa855 100644 --- a/src/AuthorizedSocketConnection.ts +++ b/src/AuthorizedSocketConnection.ts @@ -9,13 +9,13 @@ import { validate, } from 'graphql'; import { ExecutionResult } from 'graphql/execution/execute'; -import io from 'socket.io'; import * as AsyncUtils from './AsyncUtils'; import { CredentialsManager } from './CredentialsManager'; import { CreateLogger, Logger } from './Logger'; import { Subscriber } from './Subscriber'; import SubscriptionContext from './SubscriptionContext'; +import { WebSocket } from './types'; export type CreateValidationRules = ({ query, @@ -62,7 +62,7 @@ const acknowledge = (cb?: () => void) => { * - Rudimentary connection constraints (max connections) */ export default class AuthorizedSocketConnection { - socket: io.Socket; + socket: WebSocket; config: AuthorizedSocketOptions; @@ -74,7 +74,7 @@ export default class AuthorizedSocketConnection { >; constructor( - socket: io.Socket, + socket: WebSocket, config: AuthorizedSocketOptions, ) { this.socket = socket; @@ -83,12 +83,11 @@ export default class AuthorizedSocketConnection { this.log = config.createLogger('@4c/SubscriptionServer::AuthorizedSocket'); this.subscriptionContexts = new Map(); - this.socket - .on('authenticate', this.handleAuthenticate) - .on('subscribe', this.handleSubscribe) - .on('unsubscribe', this.handleUnsubscribe) - .on('connect', this.handleConnect) - .on('disconnect', this.handleDisconnect); + this.socket.on('authenticate', this.handleAuthenticate); + this.socket.on('subscribe', this.handleSubscribe); + this.socket.on('unsubscribe', this.handleUnsubscribe); + this.socket.on('connect', this.handleConnect); + this.socket.on('disconnect', this.handleDisconnect); } emitError(error: { code: string; data?: any }) { diff --git a/src/GraphqlSocketSubscriptionServer.ts b/src/GraphqlSocketSubscriptionServer.ts deleted file mode 100644 index e9ac8477..00000000 --- a/src/GraphqlSocketSubscriptionServer.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { IncomingMessage } from 'http'; -import { promisify } from 'util'; - -import express from 'express'; -import type { GraphQLSchema } from 'graphql'; -import { useServer } from 'graphql-ws/lib/use/ws'; -import ws from 'ws'; - -import AuthorizedSocketConnection from './AuthorizedSocketConnection'; -import type { CreateValidationRules } from './AuthorizedSocketConnection'; -import type { CredentialsManager } from './CredentialsManager'; -import type { CreateLogger, Logger } from './Logger'; -import type { Subscriber } from './Subscriber'; - -export type SubscriptionServerConfig = { - path: string; - schema: GraphQLSchema; - subscriber: Subscriber; - createCredentialsManager: (request: any) => CredentialsManager; - hasPermission: (data: any, credentials: TCredentials) => boolean; - createContext?: ( - request: any, - credentials: TCredentials | null | undefined, - ) => TContext; - maxSubscriptionsPerConnection?: number; - createValidationRules?: CreateValidationRules; - createLogger?: CreateLogger; -}; - -// eslint-disable-next-line @typescript-eslint/no-empty-function -const defaultCreateLogger = () => () => {}; - -export default class SubscriptionServer { - config: SubscriptionServerConfig; - - log: Logger; - - server: ws.Server | null = null; - - constructor(config: SubscriptionServerConfig) { - this.config = config; - - const createLogger: CreateLogger = - config.createLogger || defaultCreateLogger; - this.log = createLogger('@4c/SubscriptionServer::Server'); - } - - attach(httpServer: any) { - this.server = new ws.Server({ - server: httpServer, - path: this.config.path, - }); - - const { createContext } = this.config; - - useServer( - // from the previous step - { - schema: this.config.schema, - context: (ctx, msg, args) => { - - }, - onConnect() - - // credentialsManager: this.config.createCredentialsManager(request), - // hasPermission: this.config.hasPermission, - createContext: - createContext && - ((credentials: TCredentials | null | undefined) => - createContext(request, credentials)), - maxSubscriptionsPerConnection: this.config - .maxSubscriptionsPerConnection, - createValidationRules: this.config.createValidationRules, - createLogger: this.config.createLogger || defaultCreateLogger, - }, - wsServer, - ); - - this.server.on('connection', this.handleConnection); - } - - handleConnection = (socket: ws, req: IncomingMessage) => { - this.log('debug', 'new socket connection'); - - const request = Object.create((express as any).request); - Object.assign(request, req); - - const { createContext } = this.config; - - // eslint-disable-next-line no-new - new AuthorizedSocketConnection(socket, { - schema: this.config.schema, - subscriber: this.config.subscriber, - credentialsManager: this.config.createCredentialsManager(request), - hasPermission: this.config.hasPermission, - createContext: - createContext && - ((credentials: TCredentials | null | undefined) => - createContext(request, credentials)), - maxSubscriptionsPerConnection: this.config.maxSubscriptionsPerConnection, - createValidationRules: this.config.createValidationRules, - createLogger: this.config.createLogger || defaultCreateLogger, - }); - }; - - async close() { - // @ts-ignore - await promisify((...args) => this.io.close(...args))(); - } -} diff --git a/src/SocketIOSubscriptionServer.ts b/src/SocketIOSubscriptionServer.ts new file mode 100644 index 00000000..40ba3fc3 --- /dev/null +++ b/src/SocketIOSubscriptionServer.ts @@ -0,0 +1,67 @@ +import { promisify } from 'util'; + +import express from 'express'; +import type io from 'socket.io'; + +import SubscriptionServer, { + SubscriptionServerConfig, +} from './SubscriptionServer'; + +export interface SocketIOSubscriptionServerConfig + extends SubscriptionServerConfig { + socketIoServer?: io.Server; +} + +export default class SocketIOSubscriptionServer< + TContext, + TCredentials +> extends SubscriptionServer { + io: io.Server; + + constructor({ + socketIoServer, + ...config + }: SocketIOSubscriptionServerConfig) { + super(config); + + this.io = socketIoServer!; + if (!this.io) { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const IoServer = require('socket.io').Server; + this.io = new IoServer({ + serveClient: false, + path: this.config.path, + transports: ['websocket'], + allowEIO3: true, + }); + } + + this.io.on('connection', (socket: io.Socket) => { + const request = Object.create((express as any).request); + Object.assign(request, socket.request); + this.opened( + { + id: socket.id, + protocol: 'socket-io', + on: socket.on.bind(socket), + emit(event: string, data: any) { + socket.emit(event, data); + }, + close() { + socket.disconnect(); + }, + }, + request, + ); + }); + } + + attach(httpServer: any) { + this.io.attach(httpServer); + } + + async close() { + // @ts-ignore + await promisify((...args) => this.io.close(...args))(); + } +} diff --git a/src/SubscriptionServer.ts b/src/SubscriptionServer.ts index 28075ab7..1fc52138 100644 --- a/src/SubscriptionServer.ts +++ b/src/SubscriptionServer.ts @@ -1,16 +1,14 @@ -import { promisify } from 'util'; - -import express from 'express'; +import { Request } from 'express'; import type { GraphQLSchema } from 'graphql'; -import type io from 'socket.io'; import AuthorizedSocketConnection from './AuthorizedSocketConnection'; import type { CreateValidationRules } from './AuthorizedSocketConnection'; import type { CredentialsManager } from './CredentialsManager'; import type { CreateLogger, Logger } from './Logger'; import type { Subscriber } from './Subscriber'; +import { WebSocket } from './types'; -export type SubscriptionServerConfig = { +export interface SubscriptionServerConfig { path: string; schema: GraphQLSchema; subscriber: Subscriber; @@ -23,51 +21,30 @@ export type SubscriptionServerConfig = { maxSubscriptionsPerConnection?: number; createValidationRules?: CreateValidationRules; createLogger?: CreateLogger; - socketIoServer: io.Server; -}; +} // eslint-disable-next-line @typescript-eslint/no-empty-function const defaultCreateLogger = () => () => {}; -export default class SubscriptionServer { +export default abstract class SubscriptionServer { config: SubscriptionServerConfig; log: Logger; - io: io.Server; - constructor(config: SubscriptionServerConfig) { this.config = config; const createLogger: CreateLogger = config.createLogger || defaultCreateLogger; - this.log = createLogger('@4c/SubscriptionServer::Server'); - - this.io = config.socketIoServer; - if (!this.io) { - // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const IoServer = require('socket.io').Server; - this.io = new IoServer({ - serveClient: false, - path: this.config.path, - transports: ['websocket'], - wsEngine: 'ws', - }); - } - this.io.on('connection', this.handleConnection); + this.log = createLogger('@4c/SubscriptionServer::Server'); } - attach(httpServer: any) { - this.io.attach(httpServer); - } + public abstract attach(httpServer: any): void; - handleConnection = (socket: io.Socket) => { + protected opened(socket: WebSocket, request: Request) { this.log('debug', 'new socket connection'); - const request = Object.create((express as any).request); - Object.assign(request, socket.request); - const { createContext } = this.config; // eslint-disable-next-line no-new @@ -84,10 +61,7 @@ export default class SubscriptionServer { createValidationRules: this.config.createValidationRules, createLogger: this.config.createLogger || defaultCreateLogger, }); - }; - - async close() { - // @ts-ignore - await promisify((...args) => this.io.close(...args))(); } + + abstract close(): void | Promise; } diff --git a/src/WebSocketSubscriptionServer.ts b/src/WebSocketSubscriptionServer.ts new file mode 100644 index 00000000..6b342ea1 --- /dev/null +++ b/src/WebSocketSubscriptionServer.ts @@ -0,0 +1,170 @@ +/* eslint-disable max-classes-per-file */ +import { EventEmitter } from 'events'; +import type * as http from 'http'; +import url from 'url'; + +import ws from 'ws'; + +import SubscriptionServer, { + SubscriptionServerConfig, +} from './SubscriptionServer'; +import { MessageType } from './types'; + +interface Message { + type: MessageType; + payload: any; + ackId?: number; +} + +class GraphQLSocket extends EventEmitter { + protocol: 'graphql-transport-ws' | 'socket-io'; + + private pingHandle: NodeJS.Timeout | null; + + private pongWait: NodeJS.Timeout | null; + + constructor(private socket: ws, { keepAlive = 12 * 1000 } = {}) { + super(); + this.socket = socket; + + this.protocol = + socket.protocol === 'graphql-transport-ws' + ? socket.protocol + : 'socket-io'; + + socket.on('message', (data) => { + let msg: Message | null = null; + try { + msg = JSON.parse(data.toString()); + } catch (err) { + // this.log('err'); + } + super.emit(msg!.type, msg!.payload, this.ack(msg)); + }); + + socket.on('close', (code: number, reason: string) => { + clearTimeout(this.pongWait!); + clearInterval(this.pingHandle!); + + super.emit('close', code, reason); + }); + + // keep alive through ping-pong messages + this.pongWait = null; + + this.pingHandle = + keepAlive > 0 && Number.isFinite(keepAlive) + ? setInterval(() => { + // ping pong on open sockets only + if (this.socket.readyState === this.socket.OPEN) { + // terminate the connection after pong wait has passed because the client is idle + this.pongWait = setTimeout(() => { + this.socket.terminate(); + }, keepAlive); + + // listen for client's pong and stop socket termination + this.socket.once('pong', () => { + clearTimeout(this.pongWait!); + this.pongWait = null; + }); + + this.socket.ping(); + } + }, keepAlive) + : null; + } + + private ack(msg: { ackId?: number } | null) { + if (!msg || msg.ackId == null) return undefined; + const { ackId } = msg; + return (data: any) => { + this.socket.send( + JSON.stringify({ type: `ack:${ackId}`, payload: data }), + ); + }; + } + + emit(msg: MessageType, payload?: any) { + this.socket.send( + JSON.stringify({ + type: msg, + payload, + }), + ); + return true; + } + + close(code: number, reason: string) { + this.socket.close(code, reason); + } +} + +export default class WebSocketSubscriptionServer< + TContext, + TCredentials +> extends SubscriptionServer { + private ws: ws.Server; + + constructor(config: SubscriptionServerConfig) { + super(config); + + this.ws = new ws.Server({ noServer: true }); + + this.ws.on('error', () => { + // catch the first thrown error and re-throw it once all clients have been notified + let firstErr: Error | null = null; + + // report server errors by erroring out all clients with the same error + for (const client of this.ws.clients) { + try { + client.close(1011, 'Internal Error'); + } catch (err) { + firstErr = firstErr ?? err; + } + } + + if (firstErr) throw firstErr; + }); + + this.ws.on('connection', (socket, request) => { + const gqlSocket = new GraphQLSocket(socket); + + this.opened(gqlSocket, request as any); + + // socket io clients do this behind the scenes + // so we keep it out of the server logic + if (gqlSocket.protocol === 'socket-io') { + // inform the client they are good to go + gqlSocket.emit('connect'); + } + }); + } + + attach(httpServer: http.Server) { + httpServer.on( + 'upgrade', + (req: http.IncomingMessage, socket: any, head) => { + const { pathname } = url.parse(req.url!); + if (pathname !== this.config.path) { + socket.destroy(); + return; + } + + this.ws.handleUpgrade(req, socket, head, (client) => { + this.ws.emit('connection', client, req); + }); + }, + ); + } + + async close() { + for (const client of this.ws.clients) { + client.close(1001, 'Going away'); + } + this.ws.removeAllListeners(); + + await new Promise((resolve, reject) => { + this.ws.close((err) => (err ? reject(err) : resolve())); + }); + } +} diff --git a/src/index.ts b/src/index.ts index 633878ea..ce44a80a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,13 @@ export { default as EventSubscriber } from './EventSubscriber'; export { default as JwtCredentialsManager } from './JwtCredentialsManager'; export { default as RedisSubscriber } from './RedisSubscriber'; export { default as SubscriptionServer } from './SubscriptionServer'; - +export { default as SocketIOSubscriptionServer } from './SocketIOSubscriptionServer'; +export { default as WebSocketSubscriptionServer } from './WebSocketSubscriptionServer'; export { AsyncQueue } from './AsyncUtils'; -export { CreateValidationRules } from './AuthorizedSocketConnection'; -export { CredentialsManager } from './CredentialsManager'; -export { Logger, CreateLogger, LogLevels } from './Logger'; -export { Subscriber } from './Subscriber'; -export { SubscriptionServerConfig } from './SubscriptionServer'; + +export type { CreateValidationRules } from './AuthorizedSocketConnection'; +export type { CredentialsManager } from './CredentialsManager'; +export type { Logger, CreateLogger, LogLevels } from './Logger'; +export type { Subscriber } from './Subscriber'; +export type { SubscriptionServerConfig } from './SubscriptionServer'; +export type { SocketIOSubscriptionServerConfig } from './SocketIOSubscriptionServer'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..7ae7d2cc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,28 @@ +export interface WebSocket { + protocol: string; + id?: string; + + close(code: number, reason: string): Promise | void; + + on( + message: string, + listener: (data: any, ack?: (data?: any) => Promise | void) => void, + ): void; + + emit(message: string, data: any): Promise | void | boolean; + // onMessage(cb: (data: string) => Promise): void; +} + +export type MessageType = + | 'authenticate' + | 'subscribe' + | 'unsubscribe' + | 'connect' + | 'disconnect' + | `ack:${number}`; + +export interface BaseMessage { + type: MessageType; + payload?: D; + ackId?: number; +} diff --git a/test/data/database.js b/test/data/database.js new file mode 100644 index 00000000..9a8d8c49 --- /dev/null +++ b/test/data/database.js @@ -0,0 +1,94 @@ +export class Todo extends Object {} +export class User extends Object {} + +// Mock authenticated ID. +const VIEWER_ID = 'me'; + +// Mock user data. +const viewer = new User(); +viewer.id = VIEWER_ID; +const usersById = { + [VIEWER_ID]: viewer, +}; + +const todosById = {}; +const todoIdsByUser = { + [VIEWER_ID]: [], +}; +let nextTodoId = 0; + +export function addTodo(text, complete) { + const todo = new Todo(); + Object.assign(todo, { + id: `${nextTodoId++}`, + complete: Boolean(complete), + text, + }); + + todosById[todo.id] = todo; + todoIdsByUser[VIEWER_ID].push(todo.id); + + return todo.id; +} + +// Mock todo data. +addTodo('Taste JavaScript', true); +addTodo('Buy a unicorn', false); + +export function getTodo(id) { + return todosById[id]; +} + +export function changeTodoStatus(id, complete) { + const todo = getTodo(id); + todo.complete = complete; +} + +export function getTodos(status = 'any') { + const todos = todoIdsByUser[VIEWER_ID].map((id) => todosById[id]); + if (status === 'any') { + return todos; + } + + return todos.filter((todo) => todo.complete === (status === 'completed')); +} + +export function getUser() { + return usersById[VIEWER_ID]; +} + +export function getViewer() { + return getUser(VIEWER_ID); +} + +export function markAllTodos(complete) { + const changedTodos = []; + getTodos().forEach((todo) => { + if (todo.complete !== complete) { + /* eslint-disable no-param-reassign */ + todo.complete = complete; + /* eslint-enable no-param-reassign */ + changedTodos.push(todo); + } + }); + return changedTodos.map((todo) => todo.id); +} + +export function removeTodo(id) { + const todoIndex = todoIdsByUser[VIEWER_ID].indexOf(id); + if (todoIndex !== -1) { + todoIdsByUser[VIEWER_ID].splice(todoIndex, 1); + } + delete todosById[id]; +} + +export function removeCompletedTodos() { + const todosToRemove = getTodos().filter((todo) => todo.complete); + todosToRemove.forEach((todo) => removeTodo(todo.id)); + return todosToRemove.map((todo) => todo.id); +} + +export function renameTodo(id, text) { + const todo = getTodo(id); + todo.text = text; +} diff --git a/test/data/schema.graphql b/test/data/schema.graphql new file mode 100644 index 00000000..2e110296 --- /dev/null +++ b/test/data/schema.graphql @@ -0,0 +1,214 @@ +schema { + query: Root + mutation: Mutation + subscription: Subscription +} + +type Root { + viewer: User + + """ + Fetches an object given its ID + """ + node( + """ + The ID of an object + """ + id: ID! + ): Node +} + +type User implements Node { + """ + The ID of an object + """ + id: ID! + todos( + status: String = "any" + after: String + first: Int + before: String + last: Int + ): TodoConnection + numTodos: Int + numCompletedTodos: Int +} + +""" +An object with an ID +""" +interface Node { + """ + The id of the object. + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type TodoConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + + """ + A list of edges. + """ + edges: [TodoEdge] +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String + + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String +} + +""" +An edge in a connection. +""" +type TodoEdge { + """ + The item at the end of the edge + """ + node: Todo + + """ + A cursor for use in pagination + """ + cursor: String! +} + +type Todo implements Node { + """ + The ID of an object + """ + id: ID! + complete: Boolean + text: String +} + +type Mutation { + addTodo(input: AddTodoInput!): AddTodoPayload + changeTodoStatus(input: ChangeTodoStatusInput!): ChangeTodoStatusPayload + markAllTodos(input: MarkAllTodosInput!): MarkAllTodosPayload + removeCompletedTodos( + input: RemoveCompletedTodosInput! + ): RemoveCompletedTodosPayload + removeTodo(input: RemoveTodoInput!): RemoveTodoPayload + renameTodo(input: RenameTodoInput!): RenameTodoPayload +} + +type AddTodoPayload { + viewer: User + todoEdge: TodoEdge + clientMutationId: String +} + +input AddTodoInput { + text: String! + clientMutationId: String +} + +type ChangeTodoStatusPayload { + viewer: User + todo: Todo + clientMutationId: String +} + +input ChangeTodoStatusInput { + id: ID! + complete: Boolean! + clientMutationId: String +} + +type MarkAllTodosPayload { + viewer: User + changedTodos: [Todo] + clientMutationId: String +} + +input MarkAllTodosInput { + complete: Boolean! + clientMutationId: String +} + +type RemoveCompletedTodosPayload { + viewer: User + deletedIds: [String] + clientMutationId: String +} + +input RemoveCompletedTodosInput { + clientMutationId: String +} + +type RemoveTodoPayload { + viewer: User + deletedId: ID + clientMutationId: String +} + +input RemoveTodoInput { + id: ID! + clientMutationId: String +} + +type RenameTodoPayload { + todo: Todo + clientMutationId: String +} + +input RenameTodoInput { + id: ID! + text: String! + clientMutationId: String +} + +type Subscription { + todoUpdated( + input: TodoUpdatedSubscriptionInput! + ): TodoUpdatedSubscriptionPayload + todoCreated( + input: TodoCreatedSubscriptionInput! + ): TodoCreatedSubscriptionPayload +} + +type TodoUpdatedSubscriptionPayload { + todo: Todo + clientSubscriptionId: String +} + +input TodoUpdatedSubscriptionInput { + id: ID! + clientSubscriptionId: String +} + +type TodoCreatedSubscriptionPayload { + todo: Todo + clientSubscriptionId: String +} + +input TodoCreatedSubscriptionInput { + clientSubscriptionId: String +} diff --git a/test/data/schema.js b/test/data/schema.js new file mode 100644 index 00000000..857bd7c3 --- /dev/null +++ b/test/data/schema.js @@ -0,0 +1,307 @@ +import { + GraphQLBoolean, + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from 'graphql'; +import { + connectionArgs, + connectionDefinitions, + connectionFromArray, + cursorForObjectInConnection, + fromGlobalId, + globalIdField, + mutationWithClientMutationId, + nodeDefinitions, + toGlobalId, +} from 'graphql-relay'; +import { subscriptionWithClientId } from 'graphql-relay-subscription'; + +import { + Todo, + User, + addTodo, + changeTodoStatus, + getTodo, + getTodos, + getUser, + getViewer, + markAllTodos, + removeCompletedTodos, + removeTodo, + renameTodo, +} from './database'; + +/* eslint-disable no-use-before-define */ + +const { nodeInterface, nodeField } = nodeDefinitions( + (globalId) => { + const { type, id } = fromGlobalId(globalId); + if (type === 'Todo') { + return getTodo(id); + } + if (type === 'User') { + return getUser(id); + } + return null; + }, + (obj) => { + if (obj instanceof Todo) { + return GraphQLTodo; + } + if (obj instanceof User) { + return GraphQLUser; + } + return null; + }, +); + +const GraphQLTodo = new GraphQLObjectType({ + name: 'Todo', + fields: { + id: globalIdField(), + complete: { type: GraphQLBoolean }, + text: { type: GraphQLString }, + }, + interfaces: [nodeInterface], +}); + +const { + connectionType: TodosConnection, + edgeType: GraphQLTodoEdge, +} = connectionDefinitions({ nodeType: GraphQLTodo }); + +const GraphQLUser = new GraphQLObjectType({ + name: 'User', + fields: { + id: globalIdField(), + todos: { + type: TodosConnection, + args: { + status: { + type: GraphQLString, + defaultValue: 'any', + }, + ...connectionArgs, + }, + resolve: (obj, { status, ...args }) => + connectionFromArray(getTodos(status), args), + }, + numTodos: { + type: GraphQLInt, + resolve: () => getTodos().length, + }, + numCompletedTodos: { + type: GraphQLInt, + resolve: () => getTodos('completed').length, + }, + }, + interfaces: [nodeInterface], +}); + +const GraphQLRoot = new GraphQLObjectType({ + name: 'Root', + fields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + node: nodeField, + }, +}); + +const GraphQLAddTodoMutation = mutationWithClientMutationId({ + name: 'AddTodo', + inputFields: { + text: { type: new GraphQLNonNull(GraphQLString) }, + }, + outputFields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + todoEdge: { + type: GraphQLTodoEdge, + resolve: ({ todoId }) => { + const todo = getTodo(todoId); + return { + cursor: cursorForObjectInConnection(getTodos(), todo), + node: todo, + }; + }, + }, + }, + mutateAndGetPayload: ({ text }) => { + const todoId = addTodo(text); + return { todoId }; + }, +}); + +const GraphQLChangeTodoStatusMutation = mutationWithClientMutationId({ + name: 'ChangeTodoStatus', + inputFields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + complete: { type: new GraphQLNonNull(GraphQLBoolean) }, + }, + outputFields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + todo: { + type: GraphQLTodo, + resolve: ({ todoId }) => getTodo(todoId), + }, + }, + mutateAndGetPayload: ({ id, complete }) => { + const { id: todoId } = fromGlobalId(id); + changeTodoStatus(todoId, complete); + return { todoId }; + }, +}); + +const GraphQLMarkAllTodosMutation = mutationWithClientMutationId({ + name: 'MarkAllTodos', + inputFields: { + complete: { type: new GraphQLNonNull(GraphQLBoolean) }, + }, + outputFields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + changedTodos: { + type: new GraphQLList(GraphQLTodo), + resolve: ({ changedTodoIds }) => changedTodoIds.map(getTodo), + }, + }, + mutateAndGetPayload: ({ complete }) => { + const changedTodoIds = markAllTodos(complete); + return { changedTodoIds }; + }, +}); + +const GraphQLRemoveCompletedTodosMutation = mutationWithClientMutationId({ + name: 'RemoveCompletedTodos', + outputFields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + deletedIds: { + type: new GraphQLList(GraphQLString), + resolve: ({ deletedIds }) => deletedIds, + }, + }, + mutateAndGetPayload: () => { + const deletedTodoIds = removeCompletedTodos(); + const deletedIds = deletedTodoIds.map(toGlobalId.bind(null, 'Todo')); + return { deletedIds }; + }, +}); + +const GraphQLRemoveTodoMutation = mutationWithClientMutationId({ + name: 'RemoveTodo', + inputFields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + }, + outputFields: { + viewer: { + type: GraphQLUser, + resolve: getViewer, + }, + deletedId: { + type: GraphQLID, + resolve: ({ id }) => id, + }, + }, + mutateAndGetPayload: ({ id }) => { + const { id: todoId } = fromGlobalId(id); + removeTodo(todoId); + return { id }; + }, +}); + +const GraphQLRenameTodoMutation = mutationWithClientMutationId({ + name: 'RenameTodo', + inputFields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + text: { type: new GraphQLNonNull(GraphQLString) }, + }, + outputFields: { + todo: { + type: GraphQLTodo, + resolve: ({ todoId }) => getTodo(todoId), + }, + }, + mutateAndGetPayload: ({ id, text }) => { + const { id: todoId } = fromGlobalId(id); + renameTodo(todoId, text); + return { todoId }; + }, +}); + +const GraphQLMutation = new GraphQLObjectType({ + name: 'Mutation', + fields: { + addTodo: GraphQLAddTodoMutation, + changeTodoStatus: GraphQLChangeTodoStatusMutation, + markAllTodos: GraphQLMarkAllTodosMutation, + removeCompletedTodos: GraphQLRemoveCompletedTodosMutation, + removeTodo: GraphQLRemoveTodoMutation, + renameTodo: GraphQLRenameTodoMutation, + }, +}); + +const GraphQLTodoUpdatedSubscription = subscriptionWithClientId({ + name: 'TodoUpdatedSubscription', + + inputFields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + }, + + outputFields: { + todo: { + type: GraphQLTodo, + resolve: ({ id }) => { + return getTodo(id); + }, + }, + }, + + subscribe: ({ id }, { subscribe }) => { + return subscribe(`todo:${id}:updated`); + }, +}); + +const GraphQLTodoCreatedSubscription = subscriptionWithClientId({ + name: 'TodoCreatedSubscription', + outputFields: { + todo: { + type: GraphQLTodo, + resolve: ({ id }) => getTodo(id), + }, + }, + + subscribe: (_, { subscribe }) => { + return subscribe(`todo:created`); + }, +}); + +const GraphQLSubscription = new GraphQLObjectType({ + name: 'Subscription', + fields: () => ({ + todoUpdated: GraphQLTodoUpdatedSubscription, + todoCreated: GraphQLTodoCreatedSubscription, + }), +}); + +export default new GraphQLSchema({ + query: GraphQLRoot, + mutation: GraphQLMutation, + subscription: GraphQLSubscription, +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 00000000..225bebd1 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,230 @@ +// import { RedisClient } from 'redis'; +// import type { Socket } from 'socket.io-client'; +// import socketio from 'socket.io-client'; +import { EventEmitter } from 'events'; +import http from 'http'; + +import socketio, { Socket } from 'socket.io-client'; +import WebSocket from 'ws'; + +import { CredentialsManager } from '../src/CredentialsManager'; +import RedisSubscriber from '../src/RedisSubscriber'; +import type SubscriptionServer from '../src/SubscriptionServer'; + +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + // eslint-disable-next-line no-bitwise + const r = (Math.random() * 16) | 0; + // eslint-disable-next-line no-bitwise + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function graphql(strings: any): string { + if (strings.length !== 1) { + throw new Error('must not use string interpolation with GraphQL query'); + } + + return strings[0]; +} + +export class TestCredentialsManager implements CredentialsManager { + private credentials: any = null; + + getCredentials() { + return Promise.resolve(this.credentials); + } + + authenticate() { + this.credentials = {}; + } + + unauthenticate() { + this.credentials = null; + } +} + +export async function startServer( + createServer: (sub: RedisSubscriber) => SubscriptionServer, +) { + const subscriber = new RedisSubscriber({ + parseMessage: (msg) => JSON.parse(msg)?.data, + }); + + const httpServer = http.createServer(); + const server = createServer(subscriber); + + server.attach(httpServer); + + await new Promise((resolve) => + httpServer.listen(5000, () => { + resolve(); + }), + ); + + return { + server, + httpServer, + subscriber, + async close() { + httpServer.close(); + await server.close(); + }, + }; +} + +let i = 0; +class WebSocketShim extends EventEmitter { + socket: WebSocket; + + id: string; + + constructor(path: string, { protocol = 'socket-io' } = {}) { + super(); + const socket = new WebSocket(path, { protocol }); + + this.socket = socket; + this.id = uuid(); + + socket.on('message', (data) => { + const { type, payload } = JSON.parse(data.toString()); + super.emit(type, payload); + }); + } + + private ack(cb?: (data?: any) => void) { + if (!cb) return undefined; + + const ackId = i++; + this.once(`ack:${ackId}`, (data) => { + cb(data); + }); + return ackId; + } + + emit(type: string, data: any, cb?: () => void) { + this.socket.send( + JSON.stringify({ + type, + payload: data, + ackId: this.ack(cb), + }), + ); + return true; + } + + disconnect() { + this.socket.removeAllListeners(); + this.socket.close(); + } +} + +export class TestClient { + socket: Socket | WebSocketShim; + + constructor( + public subscriber: RedisSubscriber, + public query: string, + public variables: Record | null = null, + { engine = 'socket.io' }: { engine?: 'socket.io' | 'ws' } = {}, + ) { + this.socket = + engine === 'socket.io' + ? socketio('http://localhost:5000', { + path: '/graphql', + transports: ['websocket'], + }) + : new WebSocketShim('ws://localhost:5000/graphql'); + } + + init() { + const { socket } = this; + return new Promise((resolve, reject) => { + socket.on('connect', () => { + // console.debug('CONNECT'); + socket.once('connect_error', (data: Record) => { + console.error(data); + reject(new Error(`connect_error: ${JSON.stringify(data)}`)); + }); + setTimeout(() => resolve(), 0); + }); + }); + } + + authenticate() { + return new Promise((resolve) => { + this.socket.emit('authenticate', 'token', resolve); + }); + } + + unsubscribe() { + return new Promise((resolve) => + this.socket.emit('unsubscribe', 'foo', () => { + resolve(); + }), + ); + } + + subscribe() { + return new Promise((resolve) => + this.socket.emit( + 'subscribe', + { + id: 'foo', + query: this.query, + variables: this.variables, + }, + () => { + resolve(); + }, + ), + ); + } + + async subscribeAndPublish({ + topic, + data, + subscribe = true, + }: { + topic: string; + data?: Record; + subscribe?: boolean; + }) { + if (subscribe) { + await this.subscribe(); + } + if (data) { + this.subscriber.redis.publish(topic, JSON.stringify({ data })); + } + } + + getSubscriptionResult(info: { + topic: string; + data?: Record; + subscribe?: boolean; + }) { + const events = ['subscription update', 'app_error', 'connection_error']; + const result = Promise.race( + events.map( + (event) => + new Promise((resolve) => { + this.socket.on(event, (payload: Record) => { + resolve({ event, payload }); + }); + }), + ), + ); + + // no need to await this + this.subscribeAndPublish(info); + + return result; + } + + close() { + // @ts-ignore + this.socket.removeAllListeners(); + this.socket.disconnect(); + } +} diff --git a/test/socket-io.test.ts b/test/socket-io.test.ts new file mode 100644 index 00000000..ab0926a1 --- /dev/null +++ b/test/socket-io.test.ts @@ -0,0 +1,136 @@ +import socketio from 'socket.io-client'; + +import SocketIOSubscriptionServer from '../src/SocketIOSubscriptionServer'; +import schema from './data/schema'; +import { + TestClient, + TestCredentialsManager, + graphql, + startServer, +} from './helpers'; + +function createServer(subscriber) { + return new SocketIOSubscriptionServer({ + path: '/graphql', + schema, + subscriber, + hasPermission: (_, creds) => { + return creds !== null; + }, + createCredentialsManager: () => new TestCredentialsManager(), + // createLogger: () => console.debug, + }); +} + +type PromiseType

= P extends Promise ? R : never; + +describe('socket-io client', () => { + let server: PromiseType>; + let client: TestClient | null = null; + + async function createClient(query: string, variables: any) { + client = new TestClient(server.subscriber, query, variables); + + await client.init(); + return client; + } + + beforeAll(async () => { + server = await startServer(createServer); + }); + + afterEach(() => { + client?.close(); + client = null; + }); + + afterAll(async () => { + client?.close(); + await server.close(); + }); + + it('should connect', (done) => { + const socket = socketio('http://localhost:5000', { + path: '/graphql', + transports: ['websocket'], + }); + + socket.on('connect', () => { + socket.close(); + done(); + }); + }, 10000); + + it('should subscribe', async () => { + const socket = await createClient( + graphql` + subscription TestTodoUpdatedSubscription( + $input: TodoUpdatedSubscriptionInput! + ) { + todoUpdated(input: $input) { + todo { + text + } + } + } + `, + { + input: { + id: '1', + }, + }, + ); + + await socket.authenticate(); + + expect( + await socket.getSubscriptionResult({ + topic: `todo:1:updated`, + data: { + id: '1', + text: 'Make work', + }, + }), + ).toMatchInlineSnapshot(` + Object { + "event": "subscription update", + "payload": Object { + "data": Object { + "todoUpdated": Object { + "todo": Object { + "text": "Buy a unicorn", + }, + }, + }, + "id": "foo", + }, + } + `); + }); + + it('should unsubscribe', async () => { + const socket = await createClient( + graphql` + subscription TestTodoUpdatedSubscription( + $input: TodoUpdatedSubscriptionInput! + ) { + todoUpdated(input: $input) { + todo { + text + } + } + } + `, + { + input: { + id: '1', + }, + }, + ); + + await socket.authenticate(); + await socket.subscribe(); + + await socket.unsubscribe(); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 11640259..be96ace8 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "rootDir": "../", "noImplicitAny": false, "types": ["jest", "node"] }, - "include": ["**/*.ts"] + "include": ["./", "../src"] } diff --git a/test/websocket.test.ts b/test/websocket.test.ts new file mode 100644 index 00000000..081783e3 --- /dev/null +++ b/test/websocket.test.ts @@ -0,0 +1,124 @@ +import WebSocketSubscriptionServer from '../src/WebSocketSubscriptionServer'; +import schema from './data/schema'; +import { + TestClient, + TestCredentialsManager, + graphql, + startServer, +} from './helpers'; + +function createServer(subscriber) { + return new WebSocketSubscriptionServer({ + path: '/graphql', + schema, + subscriber, + hasPermission: (_, creds) => { + return creds !== null; + }, + createCredentialsManager: () => new TestCredentialsManager(), + // createLogger: () => console.debug, + }); +} + +type PromiseType

= P extends Promise ? R : never; + +describe('socket-io client', () => { + let server: PromiseType>; + let client: TestClient | null = null; + + async function createClient(query: string, variables: any) { + client = new TestClient(server.subscriber, query, variables, { + engine: 'ws', + }); + + await client.init(); + return client; + } + + beforeAll(async () => { + server = await startServer(createServer); + }); + + afterEach(() => { + client?.close(); + client = null; + }); + + afterAll(async () => { + client?.close(); + await server.close(); + }); + + it('should subscribe', async () => { + const socket = await createClient( + graphql` + subscription TestTodoUpdatedSubscription( + $input: TodoUpdatedSubscriptionInput! + ) { + todoUpdated(input: $input) { + todo { + text + } + } + } + `, + { + input: { + id: '1', + }, + }, + ); + + await socket.authenticate(); + + expect( + await socket.getSubscriptionResult({ + topic: `todo:1:updated`, + data: { + id: '1', + text: 'Make work', + }, + }), + ).toMatchInlineSnapshot(` + Object { + "event": "subscription update", + "payload": Object { + "data": Object { + "todoUpdated": Object { + "todo": Object { + "text": "Buy a unicorn", + }, + }, + }, + "id": "foo", + }, + } + `); + }); + + it('should unsubscribe', async () => { + const socket = await createClient( + graphql` + subscription TestTodoUpdatedSubscription( + $input: TodoUpdatedSubscriptionInput! + ) { + todoUpdated(input: $input) { + todo { + text + } + } + } + `, + { + input: { + id: '1', + }, + }, + ); + + await socket.authenticate(); + await socket.subscribe(); + + await socket.unsubscribe(); + }); +}); diff --git a/update-schema.js b/update-schema.js new file mode 100644 index 00000000..19f2659c --- /dev/null +++ b/update-schema.js @@ -0,0 +1,11 @@ +import fs from 'fs'; +import path from 'path'; + +import { printSchema } from 'graphql/utilities'; + +import schema from './test/data/schema'; + +fs.writeFileSync( + path.join(__dirname, './test/data/schema.graphql'), + printSchema(schema), +); diff --git a/yarn.lock b/yarn.lock index 325c1c20..1a105a9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -488,6 +488,18 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/node@^7.14.2": + version "7.14.2" + resolved "https://registry.yarnpkg.com/@babel/node/-/node-7.14.2.tgz#d860c10306020d18e3fd0327c63bfaf2dbfc7470" + integrity sha512-QB/C+Kl6gIYpTjZ/hcZj+chkiAVGcgSHuR849cdNvNJBz4VztO2775/o2ge8imB94EAsLcgkrdWH/3+UIVv1TA== + dependencies: + "@babel/register" "^7.13.16" + commander "^4.0.1" + core-js "^3.2.1" + node-environment-flags "^1.0.5" + regenerator-runtime "^0.13.4" + v8flags "^3.1.1" + "@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81" @@ -1150,6 +1162,17 @@ pirates "^4.0.0" source-map-support "^0.5.16" +"@babel/register@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.13.16.tgz#ae3ab0b55c8ec28763877383c454f01521d9a53d" + integrity sha512-dh2t11ysujTwByQjXNgJ48QZ2zcXKQVdV8s0TbeMI0flmtGWCdTwK9tJiACHXPLmncm5+ktNn/diojA45JE4jg== + dependencies: + clone-deep "^4.0.1" + find-cache-dir "^2.0.0" + make-dir "^2.1.0" + pirates "^4.0.0" + source-map-support "^0.5.16" + "@babel/runtime-corejs3@^7.10.2": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz#ffee91da0eb4c6dae080774e94ba606368e414f4" @@ -1786,6 +1809,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/component-emitter@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea" + integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg== + "@types/connect@*": version "3.4.33" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546" @@ -1916,16 +1944,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.7.tgz#8ea1e8f8eae2430cf440564b98c6dfce1ec5945d" integrity sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg== +"@types/node@>=10.0.0": + version "15.3.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.3.0.tgz#d6fed7d6bc6854306da3dea1af9f874b00783e26" + integrity sha512-8/bnjSZD86ZfpBsDlCIkNXIvm+h6wi9g7IqL+kmFkQ+Wvu3JrasgLElfiPgoo8V8vVfnEi0QVS12gbl94h9YsQ== + "@types/node@^12.7.1": version "12.19.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.4.tgz#cdfbb62e26c7435ed9aab9c941393cc3598e9b46" integrity sha512-o3oj1bETk8kBwzz1WlO6JWL/AfAA3Vm6J1B3C9CsdxHYp7XgPiH7OEXPUbZTndHlRaIElrANkQfe6ZmfJb3H2w== -"@types/node@^14.14.7": - version "14.14.13" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.13.tgz#9e425079799322113ae8477297ae6ef51b8e0cdf" - integrity sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ== - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -2641,6 +2669,11 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" +backo2@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" + integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -3289,6 +3322,15 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + clone-response@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" @@ -3777,6 +3819,11 @@ core-js@^2.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== +core-js@^3.2.1: + version "3.12.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.12.1.tgz#6b5af4ff55616c08a44d386f1f510917ff204112" + integrity sha512-Ne9DKPHTObRuB09Dru5AjwKjY4cJHVGu+y5f7coGn1E9Grkc3p2iBwE9AI/nJzsE29mQF7oq+mhYYRqOMFN1Bw== + core-js@^3.3.2: version "3.7.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.7.0.tgz#b0a761a02488577afbf97179e4681bf49568520f" @@ -4038,12 +4085,12 @@ debug@~2.1.1: dependencies: ms "0.7.0" -debug@~4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== +debug@~4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: - ms "^2.1.1" + ms "2.1.2" debuglog@^1.0.1: version "1.0.1" @@ -4493,25 +4540,40 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" -engine.io-parser@~4.0.0: +engine.io-client@~5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-5.1.1.tgz#f5c3aaaef1bdc9443aac6ffde48b3b2fb2dc56fc" + integrity sha512-jPFpw2HLL0lhZ2KY0BpZhIJdleQcUO9W1xkIpo0h3d6s+5D6+EV/xgQw9qWOmymszv2WXef/6KUUehyxEKomlQ== + dependencies: + base64-arraybuffer "0.1.4" + component-emitter "~1.3.0" + debug "~4.3.1" + engine.io-parser "~4.0.1" + has-cors "1.1.0" + parseqs "0.0.6" + parseuri "0.0.6" + ws "~7.4.2" + yeast "0.1.2" + +engine.io-parser@~4.0.0, engine.io-parser@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e" integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg== dependencies: base64-arraybuffer "0.1.4" -engine.io@~4.0.0: - version "4.0.5" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-4.0.5.tgz#3ff6d5c72560ad93423c1ce1e807733206622a1c" - integrity sha512-Ri+whTNr2PKklxQkfbGjwEo+kCBUM4Qxk4wtLqLrhH+b1up2NFL9g9pjYWiCV/oazwB0rArnvF/ZmZN2ab5Hpg== +engine.io@~5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-5.1.1.tgz#a1f97e51ddf10cbd4db8b5ff4b165aad3760cdd3" + integrity sha512-aMWot7H5aC8L4/T8qMYbLdvKlZOdJTH54FxfdFunTGvhMx1BHkJOntWArsVfgAZVwAO9LC2sryPWRcEeUzCe5w== dependencies: accepts "~1.3.4" base64id "2.0.0" cookie "~0.4.1" cors "~2.8.5" - debug "~4.1.0" + debug "~4.3.1" engine.io-parser "~4.0.0" - ws "^7.1.2" + ws "~7.4.2" enquirer@^2.3.5, enquirer@^2.3.6: version "2.3.6" @@ -5837,6 +5899,18 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.5 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +graphql-relay-subscription@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/graphql-relay-subscription/-/graphql-relay-subscription-0.3.1.tgz#9645760294f1d4b09573e2889085bf251810c492" + integrity sha512-np9ez07ct0l6JkoM6cqiqoEtUNccETAAQNmkOSxkvdEm864CzfBBLmQorGhvhA5nnbLsckMCRJt0Vm0auGevDw== + +graphql-relay@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/graphql-relay/-/graphql-relay-0.6.0.tgz#18ec36b772cfcb3dbb9bd369c3f8004cf42c7b93" + integrity sha512-OVDi6C9/qOT542Q3KxZdXja3NrDvqzbihn1B44PH8P/c5s0Q90RyQwT6guhGqXqbYEH6zbeLJWjQqiYvcg2vVw== + dependencies: + prettier "^1.16.0" + graphql-ws@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-4.3.2.tgz#c58b03acc3bd5d4a92a6e9f729d29ba5e90d46a3" @@ -5902,6 +5976,11 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-cors@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" + integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -5978,6 +6057,13 @@ header-case@^1.0.0: no-case "^2.2.0" upper-case "^1.1.3" +homedir-polyfill@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" + integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== + dependencies: + parse-passwd "^1.0.0" + hook-std@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hook-std/-/hook-std-2.0.0.tgz#ff9aafdebb6a989a354f729bb6445cf4a3a7077c" @@ -8588,6 +8674,14 @@ node-emoji@^1.10.0: dependencies: lodash.toarray "^4.4.0" +node-environment-flags@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" + integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== + dependencies: + object.getownpropertydescriptors "^2.0.3" + semver "^5.7.0" + node-fetch-npm@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4" @@ -9465,11 +9559,26 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse-passwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" + integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= + parse5@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== +parseqs@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5" + integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== + +parseuri@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a" + integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== + parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -9713,6 +9822,11 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" +prettier@^1.16.0: + version "1.19.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" + integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== + prettier@^2.0.0, prettier@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" @@ -10678,7 +10792,7 @@ semver-regex@^2.0.0: resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw== -"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", "semver@^2.3.0 || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", "semver@^2.3.0 || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -10780,6 +10894,13 @@ sha@^3.0.0: dependencies: graceful-fs "^4.1.2" +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -10936,33 +11057,47 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -socket.io-adapter@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.0.3.tgz#372b7cde7a535fc4f4f0d5ac7f73952a3062d438" - integrity sha512-2wo4EXgxOGSFueqvHAdnmi5JLZzWqMArjuP4nqC26AtLh5PoCPsaRbRdah2xhcwTAMooZfjYiNVNkkmmSMaxOQ== +socket.io-adapter@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.0.tgz#63090df6dd6d289b0806acff4a0b2f1952ffe37e" + integrity sha512-jdIbSFRWOkaZpo5mXy8T7rXEN6qo3bOFuq4nVeX1ZS7AtFlkbk39y153xTXEIW7W94vZfhVOux1wTU88YxcM1w== -socket.io-parser@~4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.1.tgz#b8ec84364c7369ad32ae0c16dd4d388db19b3a04" - integrity sha512-5JfNykYptCwU2lkOI0ieoePWm+6stEhkZ2UnLDjqnE1YEjUlXXLd1lpxPZ+g+h3rtaytwWkWrLQCaJULlGqjOg== +socket.io-client@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.1.2.tgz#95ad7113318ea01fba0860237b96d71e1b1fd2eb" + integrity sha512-RDpWJP4DQT1XeexmeDyDkm0vrFc0+bUsHDKiVGaNISJvJonhQQOMqV9Vwfg0ZpPJ27LCdan7iqTI92FRSOkFWQ== dependencies: + "@types/component-emitter" "^1.2.10" + backo2 "~1.0.2" component-emitter "~1.3.0" - debug "~4.1.0" + debug "~4.3.1" + engine.io-client "~5.1.1" + parseuri "0.0.6" + socket.io-parser "~4.0.4" -socket.io@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-3.0.4.tgz#20130a80b57e48dadb671f22e3776047cc7f9d53" - integrity sha512-Vj1jUoO75WGc9txWd311ZJJqS9Dr8QtNJJ7gk2r7dcM/yGe9sit7qOijQl3GAwhpBOz/W8CwkD7R6yob07nLbA== +socket.io-parser@~4.0.3, socket.io-parser@~4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0" + integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g== + dependencies: + "@types/component-emitter" "^1.2.10" + component-emitter "~1.3.0" + debug "~4.3.1" + +socket.io@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.1.2.tgz#f90f9002a8d550efe2aa1d320deebb9a45b83233" + integrity sha512-xK0SD1C7hFrh9+bYoYCdVt+ncixkSLKtNLCax5aEy1o3r5PaO5yQhVb97exIe67cE7lAK+EpyMytXWTWmyZY8w== dependencies: "@types/cookie" "^0.4.0" "@types/cors" "^2.8.8" - "@types/node" "^14.14.7" + "@types/node" ">=10.0.0" accepts "~1.3.4" base64id "~2.0.0" - debug "~4.1.0" - engine.io "~4.0.0" - socket.io-adapter "~2.0.3" - socket.io-parser "~4.0.1" + debug "~4.3.1" + engine.io "~5.1.0" + socket.io-adapter "~2.3.0" + socket.io-parser "~4.0.3" sockjs-client@1.4.0: version "1.4.0" @@ -12178,6 +12313,13 @@ v8-to-istanbul@^7.0.0: convert-source-map "^1.6.0" source-map "^0.7.3" +v8flags@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656" + integrity sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg== + dependencies: + homedir-polyfill "^1.0.1" + validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -12527,20 +12669,15 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" -ws@^7.1.2: - version "7.4.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.1.tgz#a333be02696bd0e54cea0434e21dcc8a9ac294bb" - integrity sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ== - ws@^7.2.3: version "7.4.0" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7" integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ== -ws@^7.4.4: - version "7.4.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" - integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== +ws@^7.4.5, ws@~7.4.2: + version "7.4.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1" + integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g== xdg-basedir@^3.0.0: version "3.0.0" @@ -12740,3 +12877,8 @@ yargs@^8.0.2: which-module "^2.0.0" y18n "^3.2.1" yargs-parser "^7.0.0" + +yeast@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" + integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=