Skip to content

Commit

Permalink
feat(server): Implement following the new transport protocol (#1)
Browse files Browse the repository at this point in the history
* feat(server): begin

# Conflicts:
#	package.json

* feat(server): be more strict about protocol

* test: manually control server

* refactor: dispose waits for server to close

* test(server): failing for graceful dispose

* refactor: improve disposal and event listeners

* feat: return resulting server on creation

* refactor: explicit server naming as creation result

* refactor: a bit of cleanup

* feat: report server errors by closing the connections

* refactor: consistency

* refactor: unnecessary client setup [skip ci]

* refactor: comply with the new protocol

* refactor: use parseMessage [skip ci]

* refactor: documentation comments

* feat: more type safety

* feat: add more server options

* feat: comply with updated protocol

* refactor: use promise resolutions and smaller waits

* feat: implement onConnect callback and wait timeout

* test(onConnect): longer promise resolution

* refactor: smaller refinements [skip ci]

* chore(deps): add missing ws types after rebase

* style: make linter happy

* feat(stringifyMessage): implement and use

* refactor(message): simplify message parsing errors

* style: typescript can infer

* feat(Subscribe): terminate socket if connection is not acknowledged

* feat(message): query can be of DocumentNode type too

* refactor(Error): payload is an array of errors

* refactor(message): parameters are readonly

* refactor: schema is optional in onSubscribe exec args

* feat: implement query and mutation operation

* test(Subscribe): simple query operation

* test(Subscribe): simple query validation errors

* test(Subscribe): socket shouldnt close or error because of GraphQL errors

* refactor: typo

* test(Subscribe): support operations with `DocumentNode` type query

* test(Subscribe): close socket on request if schema is undefined

* test(Subscribe): pick up the schema from `onSubscribe`

* feat: implement subscription operation

* refactor: typo

* refactor: use fixture
  • Loading branch information
enisdenjo committed Aug 17, 2020
1 parent 42045c5 commit a412d25
Show file tree
Hide file tree
Showing 14 changed files with 1,515 additions and 4 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.js
Expand Up @@ -12,4 +12,8 @@ module.exports = {
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'prettier'],
rules: {
// unused vars will be handled by the TS compiler
'@typescript-eslint/no-unused-vars': 'off',
},
};
2 changes: 1 addition & 1 deletion jest.config.js
Expand Up @@ -2,5 +2,5 @@ module.exports = {
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
testRegex: '/tests/.+.ts$',
testPathIgnorePatterns: ['/node_modules/'],
testPathIgnorePatterns: ['/node_modules/', '/fixtures/'],
};
9 changes: 8 additions & 1 deletion package.json
Expand Up @@ -27,8 +27,12 @@
"build": "tsc -b",
"release": "semantic-release"
},
"peerDependencies": {
"graphql": ">=15.0.0"
},
"dependencies": {
"websocket-as-promised": "^1.0.1"
"websocket-as-promised": "^1.0.1",
"ws": "^7.3.1"
},
"devDependencies": {
"@babel/core": "^7.11.1",
Expand All @@ -41,12 +45,15 @@
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/git": "^9.0.0",
"@types/jest": "^26.0.9",
"@types/ws": "^7.2.6",
"@typescript-eslint/eslint-plugin": "^3.9.0",
"@typescript-eslint/parser": "^3.9.0",
"babel-jest": "^26.3.0",
"eslint": "^7.6.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"graphql": "^15.3.0",
"graphql-subscriptions": "^1.1.0",
"jest": "^26.3.0",
"prettier": "^2.0.5",
"semantic-release": "^17.1.1",
Expand Down
147 changes: 147 additions & 0 deletions src/message.ts
@@ -0,0 +1,147 @@
/**
*
* message
*
*/

import { GraphQLError, ExecutionResult, DocumentNode } from 'graphql';
import {
isObject,
hasOwnProperty,
hasOwnObjectProperty,
hasOwnStringProperty,
hasOwnArrayProperty,
} from './utils';

/** Types of messages allowed to be sent by the client/server over the WS protocol. */
export enum MessageType {
ConnectionInit = 'connection_init', // Client -> Server
ConnectionAck = 'connection_ack', // Server -> Client

Subscribe = 'subscribe', // Client -> Server
Next = 'next', // Server -> Client
Error = 'error', // Server -> Client
Complete = 'complete', // bidirectional
}

export interface ConnectionInitMessage {
readonly type: MessageType.ConnectionInit;
readonly payload?: Record<string, unknown>; // connectionParams
}

export interface ConnectionAckMessage {
readonly type: MessageType.ConnectionAck;
}

export interface SubscribeMessage {
readonly id: string;
readonly type: MessageType.Subscribe;
readonly payload: {
readonly operationName: string;
readonly query: string | DocumentNode;
readonly variables: Record<string, unknown>;
};
}

export interface NextMessage {
readonly id: string;
readonly type: MessageType.Next;
readonly payload: ExecutionResult;
}

export interface ErrorMessage {
readonly id: string;
readonly type: MessageType.Error;
readonly payload: readonly GraphQLError[];
}

export interface CompleteMessage {
readonly id: string;
readonly type: MessageType.Complete;
}

export type Message<
T extends MessageType = MessageType
> = T extends MessageType.ConnectionAck
? ConnectionAckMessage
: T extends MessageType.ConnectionInit
? ConnectionInitMessage
: T extends MessageType.Subscribe
? SubscribeMessage
: T extends MessageType.Next
? NextMessage
: T extends MessageType.Error
? ErrorMessage
: T extends MessageType.Complete
? CompleteMessage
: never;

export function isMessage(val: unknown): val is Message {
if (isObject(val)) {
// all messages must have the `type` prop
if (!hasOwnProperty(val, 'type')) {
return false;
}
// validate other properties depending on the `type`
switch (val.type) {
case MessageType.ConnectionInit:
// the connection init message can have optional object `connectionParams` in the payload
return !hasOwnProperty(val, 'payload') || isObject(val.payload);
case MessageType.ConnectionAck:
return true;
case MessageType.Subscribe:
return (
hasOwnStringProperty(val, 'id') &&
hasOwnObjectProperty(val, 'payload') &&
hasOwnStringProperty(val.payload, 'operationName') &&
(hasOwnStringProperty(val.payload, 'query') || // string query
hasOwnObjectProperty(val.payload, 'query')) && // document node query
hasOwnObjectProperty(val.payload, 'variables')
);
case MessageType.Next:
return (
hasOwnStringProperty(val, 'id') &&
hasOwnObjectProperty(val, 'payload') &&
// ExecutionResult
(hasOwnObjectProperty(val.payload, 'data') ||
hasOwnObjectProperty(val.payload, 'errors'))
);
case MessageType.Error:
return (
hasOwnStringProperty(val, 'id') &&
// GraphQLError
hasOwnArrayProperty(val, 'payload') &&
val.payload.length > 0 // must be at least one error
);
case MessageType.Complete:
return hasOwnStringProperty(val, 'id');
default:
return false;
}
}
return false;
}

export function parseMessage(data: unknown): Message {
if (isMessage(data)) {
return data;
}
if (typeof data === 'string') {
const message = JSON.parse(data);
if (!isMessage(message)) {
throw new Error('Invalid message');
}
return message;
}
throw new Error('Message not parsable');
}

/** Helps stringifying a valid message ready to be sent through the socket. */
export function stringifyMessage<T extends MessageType>(
msg: Message<T>,
): string {
if (!isMessage(msg)) {
throw new Error('Cannot stringify invalid message');
}
return JSON.stringify(msg);
}
7 changes: 7 additions & 0 deletions src/protocol.ts
@@ -0,0 +1,7 @@
/**
*
* protocol
*
*/

export const GRAPHQL_TRANSPORT_WS_PROTOCOL = 'graphql-transport-ws';
Empty file removed src/server/.gitkeep
Empty file.
1 change: 1 addition & 0 deletions src/server/index.ts
@@ -0,0 +1 @@
export * from './server';

0 comments on commit a412d25

Please sign in to comment.