diff --git a/package.json b/package.json index 4ca2342c..7d74a470 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "scripts": { "lint": "eslint 'src'", "type-check": "tsc --noEmit", - "test": "jest", + "test": "jest -i", "build": "tsc -b", "release": "semantic-release" }, @@ -31,7 +31,6 @@ "graphql": ">=15.0.0" }, "dependencies": { - "websocket-as-promised": "^1.0.1", "ws": "^7.3.1" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index 3af02b40..42f288eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,411 +1,296 @@ /** * - * GraphQL subscriptions over the WebSocket Protocol + * GraphQL over WebSocket Protocol * * Check out the `PROTOCOL.md` document for the transport specification. * */ -import WebSocketAsPromised from 'websocket-as-promised'; - -/** - * The shape of a GraphQL response as dictated by the - * [spec](https://graphql.github.io/graphql-spec/June2018/#sec-Response-Format) - */ -export interface GraphQLResponseWithData { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: Record; - errors?: { - message: string; - locations?: Array<{ - line: number; - column: number; - }>; - }[]; - path?: string[] | number[]; -} -export interface GraphQLResponseWithoutData { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?: Record; - errors: { - message: string; - locations?: Array<{ - line: number; - column: number; - }>; - }[]; - path?: Array; -} -export interface GraphQLError { - message: string; -} -export type GraphQLResponse = - | GraphQLResponseWithData - | GraphQLResponseWithoutData - | GraphQLError; - -/** Used to indicate that the requestId is missing. */ -const NO_REQUEST_ID = 'NRID'; -function isNoRequestId(val: unknown): val is typeof NO_REQUEST_ID { - return val === NO_REQUEST_ID; -} - -/** - * Is the raw message being sent through the WebSocket connection. - * Since the ID generation is done automatically, we have 2 separate - * types for the two possible messages. - */ -export interface MessageWithoutID { - type: MessageType; - payload?: GraphQLResponse | null; // missing for connection messages -} -export interface Message extends MessageWithoutID { - /** - * The message ID (internally represented as the `requestId`). - * Can be missing in cases when managing the subscription - * connection itself. - */ - id: string | typeof NO_REQUEST_ID; -} - -/** 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 - ConnectionError = 'connection_error', // Server -> Client - - // NOTE: The keep alive message type does not follow the standard due to connection optimizations - ConnectionKeepAlive = 'ka', // Server -> Client - - ConnectionTerminate = 'connection_terminate', // Client -> Server - Start = 'start', // Client -> Server - Data = 'data', // Server -> Client - Error = 'error', // Server -> Client - Complete = 'complete', // Server -> Client - Stop = 'stop', // Client -> Server -} - -/** Checks if the value has a shape of a `Message`. */ -function isMessage(val: unknown): val is Message { - if (typeof val !== 'object' || val == null) { - return false; - } - // TODO-db-200603 validate the type - if ('type' in val && Boolean((val as Message).type)) { - return true; - } - return false; -} - -/** Checks if the value has a shape of a `GraphQLResponse`. */ -function isGraphQLResponse(val: unknown): val is GraphQLResponse { - if (typeof val !== 'object' || val == null) { - return false; - } - if ( - // GraphQLResponseWithData - 'data' in val || - // GraphQLResponseWithoutData - 'errors' in val || - // GraphQLError - ('message' in val && Object.keys(val).length === 1) - ) { - return true; - } - return false; -} - -/** The payload used for starting GraphQL subscriptions. */ -export interface StartPayload { - // GraphQL operation name. - operationName?: string; - // GraphQL operation as string or parsed GraphQL document node. - query: string; - // Object with GraphQL variables. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - variables: Record; -} - -/** The sink to communicate the subscription through. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface Sink { - next(value: T): void; - error(error: Error): void; - complete(): void; - readonly closed: boolean; -} +import { Sink, UUID, Disposable } from './types'; +import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from './protocol'; +import { + MessageType, + SubscribeMessage, + parseMessage, + stringifyMessage, +} from './message'; +import { noop } from './utils'; /** Configuration used for the `create` client function. */ -export interface Config { +export interface ClientOptions { // URL of the GraphQL server to connect. url: string; // Optional parameters that the client specifies when establishing a connection with the server. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - connectionParams?: Record | (() => Record); + connectionParams?: Record | (() => Record); } -export interface Client { +export interface Client extends Disposable { /** * Subscribes through the WebSocket following the config parameters. It * uses the `sink` to emit received data or errors. Returns a _cleanup_ * function used for dropping the subscription and cleaning stuff up. */ - subscribe(payload: StartPayload, sink: Sink): () => void; - /** Disposes of all active subscriptions, closes the WebSocket client and frees up memory. */ - dispose(): Promise; + subscribe( + payload: SubscribeMessage['payload'], + sink: Sink, + ): () => void; } /** Creates a disposable GQL subscriptions client. */ -export function createClient({ url, connectionParams }: Config): Client { - const ws = new WebSocketAsPromised(url, { - timeout: 2 * 1000, // timeout for opening connections and sending requests -> 2 seconds - createWebSocket: (url) => new WebSocket(url, 'graphql-ws'), - packMessage: (data) => JSON.stringify(data), - unpackMessage: (data) => { - if (typeof data !== 'string') { - throw new Error(`Unsupported message data type ${typeof data}`); - } - return JSON.parse(data); - }, - // omits when receiving a no request id symbol to avoid confusion and reduce message size - attachRequestId: (data, requestId): Message => { - if (isNoRequestId(requestId)) { - return data; - } - return { - ...data, - id: String(requestId), - }; - }, - // injecting no request id symbol allows us to request/response on id-less messages - extractRequestId: (data) => data?.id ?? NO_REQUEST_ID, - }); +export function createClient({ url, connectionParams }: ClientOptions): Client { + // holds all currently subscribed sinks, will use this map + // to dispatch messages to the correct destination + const subscribedSinks: Record = {}; - // connects on demand, already open connections are ignored - let isConnected = false, - isConnecting = false, - isDisconnecting = false; - async function waitForConnected() { - let waitedTimes = 0; - while (!isConnected) { - await new Promise((resolve) => setTimeout(resolve, 100)); - // 100ms * 100 = 10s - if (waitedTimes >= 100) { - throw new Error('Waited 10 seconds but socket never connected.'); - } - waitedTimes++; - } + function errorAllSinks(err: Error) { + Object.entries(subscribedSinks).forEach(([, sink]) => sink.error(err)); } - async function waitForDisconnected() { - let waitedTimes = 0; - while (isConnected) { - await new Promise((resolve) => setTimeout(resolve, 100)); - // 100ms * 100 = 10s - if (waitedTimes >= 100) { - throw new Error('Waited 10 seconds but socket never disconnected.'); - } - waitedTimes++; - } + function completeAllSinks() { + Object.entries(subscribedSinks).forEach(([, sink]) => sink.complete()); } - async function connect() { - if (isConnected) return; - if (isConnecting) { - return waitForConnected(); - } - if (isDisconnecting) { - await waitForDisconnected(); - } - // open and initialize a connection, send the start message and flag as connected - isConnected = false; - isConnecting = true; - await ws.open(); - const ack = await request( - MessageType.ConnectionInit, - connectionParams && typeof connectionParams === 'function' - ? connectionParams() - : connectionParams, - NO_REQUEST_ID, - ); - if (ack.type !== MessageType.ConnectionAck) { - await ws.close(); - throw new Error('Connection not acknowledged'); + // Lazily uses the socket singleton to establishes a connection described by the protocol. + let socket: WebSocket | null = null, + connected = false, + connecting = false; + async function connect(): Promise { + if (connected) { + return; } - isConnecting = false; - isConnected = true; - } - // disconnects on demand, already closed connections are ignored - async function disconnect() { - isDisconnecting = true; - if (isConnected) { - // sends a terminate message, then closes the websocket - send(MessageType.ConnectionTerminate); + if (connecting) { + let waitedTimes = 0; + while (!connected) { + await new Promise((resolve) => setTimeout(resolve, 100)); + // 100ms * 50 = 5sec + if (waitedTimes >= 50) { + throw new Error('Waited 10 seconds but socket never connected'); + } + waitedTimes++; + } + + // connected === true + return; } - await ws.close(); - isDisconnecting = false; - isConnected = false; + + connected = false; + connecting = true; + return new Promise((resolve, reject) => { + let done = false; // used to avoid resolving/rejecting the promise multiple times + socket = new WebSocket(url, GRAPHQL_TRANSPORT_WS_PROTOCOL); + + /** + * `onerror` handler is unnecessary because even if an error occurs, the `onclose` handler will be called + * + * From: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications + * > If an error occurs while attempting to connect, first a simple event with the name error is sent to the + * > WebSocket object (thereby invoking its onerror handler), and then the CloseEvent is sent to the WebSocket + * > object (thereby invoking its onclose handler) to indicate the reason for the connection's closing. + */ + + socket.onclose = ({ code, reason }) => { + const err = new Error( + `Socket closed with event ${code}` + !reason ? '' : `: ${reason}`, + ); + + if (code === 1000 || code === 1001) { + // close event `1000: Normal Closure` is ok and so is `1001: Going Away` (maybe the server is restarting) + completeAllSinks(); + } else { + // all other close events are considered erroneous + errorAllSinks(err); + } + + if (!done) { + done = true; + connecting = false; + connected = false; // the connection is lost + socket = null; + reject(err); // we reject here bacause the close is not supposed to be called during the connect phase + } + }; + socket.onopen = () => { + try { + if (!socket) { + throw new Error('Opened a socket on nothing'); + } + socket.send( + stringifyMessage({ + type: MessageType.ConnectionInit, + payload: + typeof connectionParams === 'function' + ? connectionParams() + : connectionParams, + }), + ); + } catch (err) { + errorAllSinks(err); + if (!done) { + done = true; + connecting = false; + if (socket) { + socket.close(); + socket = null; + } + reject(err); + } + } + }; + + function handleMessage({ data }: MessageEvent) { + try { + if (!socket) { + throw new Error('Received a message on nothing'); + } + + const message = parseMessage(data); + if (message.type !== MessageType.ConnectionAck) { + throw new Error(`First message cannot be of type ${message.type}`); + } + + // message.type === MessageType.ConnectionAck + if (!done) { + done = true; + connecting = false; + connected = true; // only now is the connection ready + resolve(); + } + } catch (err) { + errorAllSinks(err); + if (!done) { + done = true; + connecting = false; + if (socket) { + socket.close(); + socket = null; + } + reject(err); + } + } finally { + if (socket) { + // this listener is not necessary anymore + socket.removeEventListener('message', handleMessage); + } + } + } + socket.addEventListener('message', handleMessage); + }); } - // holds all currently subscribed sinks, will use this map - // to dispatch messages to the correct destination and - // as a decision system on when to unsubscribe - const requestIdSink: Record = {}; - function messageForSinkWithRequestId(requestId: string, message: Message) { - let hasCompleted = false; - Object.entries(requestIdSink).some(([sinkRequestId, sink]) => { - if (requestId === sinkRequestId) { + return { + subscribe: (payload, sink) => { + const uuid = generateUUID(); + if (subscribedSinks[uuid]) { + sink.error(new Error(`Sink with ID ${uuid} already registered`)); + return noop; + } + subscribedSinks[uuid] = sink; + + function handleMessage({ data }: MessageEvent) { + const message = parseMessage(data); switch (message.type) { - case MessageType.Data: { - const err = checkServerPayload(message.payload); - if (err) { - sink.error(err); - hasCompleted = true; - return true; + case MessageType.Next: { + if (message.id === uuid) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sink.next(message.payload as any); } - sink.next(message.payload); break; } case MessageType.Error: { - const err = checkServerPayload(message.payload); - if (err) { - sink.error(err); - } else { - sink.error( - new Error('Unkown error received from the subscription server'), - ); + if (message.id === uuid) { + sink.error(message.payload); } - hasCompleted = true; break; } case MessageType.Complete: { - sink.complete(); - hasCompleted = true; + if (message.id === uuid) { + sink.complete(); + } break; } } - return true; } - return false; - }); - // if the sink got completed, remove it from the subscribed sinks - if (hasCompleted) { - delete requestIdSink[requestId]; - } - // if there are no subscriptions left over, disconnect - if (Object.keys(requestIdSink).length === 0) { - // TODO-db-200603 report possible errors on disconnect - disconnect(); - } - } - function errorAllSinks(error: Error) { - Object.entries(requestIdSink).forEach(([, sink]) => sink.error(error)); - } - - // listens exclusively to messages with matching request ids - function responseListener(data: unknown) { - if (!isMessage(data)) { - return errorAllSinks( - new Error('Received an invalid message from the subscription server'), - ); - } - messageForSinkWithRequestId(data.id, data); - } - ws.onResponse.addListener(responseListener); - function subscribe(payload: StartPayload, sink: Sink) { - // generate a unique request id for this subscription - const requestId = randomString(); - if (requestIdSink[requestId]) { - sink.error(new Error(`Sink already registered for ID: ${requestId}`)); - return () => { - /**/ - }; - } - requestIdSink[requestId] = sink; + (async () => { + try { + await connect(); + if (!socket) { + throw new Error('Socket connected but empty'); + } - connect() - // start the subscription on a connection - .then(() => send(MessageType.Start, payload, requestId)) - // will also error this sink because its added to the map above - .catch(errorAllSinks); + socket.addEventListener('message', handleMessage); + socket.send( + stringifyMessage({ + id: uuid, + type: MessageType.Subscribe, + payload, + }), + ); + } catch (err) { + sink.error(err); + } + })(); - return () => { - connect() - // stop the subscription, after the server acknowledges this the sink will complete - .then(() => send(MessageType.Stop, undefined, requestId)) - // will also error this sink because its added to the map above - .catch(errorAllSinks); - }; - } + return () => { + if (socket) { + socket.send( + stringifyMessage({ + id: uuid, + type: MessageType.Complete, + }), + ); - function send( - type: T, - payload?: StartPayload, - requestId?: string | typeof NO_REQUEST_ID, - ) { - if (requestId) { - return ws.sendPacked({ id: requestId, type, payload }); - } - return ws.sendPacked({ type, payload }); - } + socket.removeEventListener('message', handleMessage); - async function request( - type: T, - payload?: StartPayload, - requestId?: string | typeof NO_REQUEST_ID, - ): Promise { - return await ws.sendRequest({ type, payload }, { requestId }); - } + // equal to 1 because this sink is the last one. + // the deletion from the map happens afterwards + if (Object.entries(subscribedSinks).length === 1) { + socket.close(1000, 'Normal Closure'); + socket = null; + } + } - return { - subscribe(payload, sink) { - return subscribe(payload, sink); + sink.complete(); + delete subscribedSinks[uuid]; + }; }, - async dispose() { + dispose: async () => { // complete all sinks - Object.entries(requestIdSink).forEach(([, sink]) => sink.complete()); - // remove all subscriptions - Object.keys(requestIdSink).forEach((key) => { - delete requestIdSink[key]; + // TODO-db-200817 complete or error? the sinks should be completed BEFORE the client gets disposed + completeAllSinks(); + + // delete all sinks + Object.keys(subscribedSinks).forEach((uuid) => { + delete subscribedSinks[uuid]; }); - // remove all listeners - ws.removeAllListeners(); - // do disconnect - return disconnect(); + + // if there is an active socket, close it with a normal closure + if (socket && socket.readyState === WebSocket.OPEN) { + // TODO-db-200817 decide if `1001: Going Away` should be used instead + socket.close(1000, 'Normal Closure'); + socket = null; + } }, }; } -/** - * Takes in the payload received from the server, parses and validates it, - * checks for errors and returns a single error for all problematic cases. - */ -function checkServerPayload( - payload: GraphQLResponse | null | undefined, -): Error | null { - if (!payload) { - return new Error('Received empty payload from the subscription server'); - } - if (!isGraphQLResponse(payload)) { - return new Error( - 'Received invalid payload structure from the subscription server', - ); - } - if ('errors' in payload && payload.errors) { - return new Error(payload.errors.map(({ message }) => message).join(', ')); - } - if ( - Object.keys(payload).length === 1 && - 'message' in payload && - payload.message - ) { - return new Error(payload.message); +/** Generates a new v4 UUID. Reference: https://stackoverflow.com/a/2117523/709884 */ +export function generateUUID(): UUID { + if (!window.crypto) { + // fallback to Math.random when crypto is not available + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function ( + c, + ) { + const r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); } - return null; -} - -/** randomString does exactly what the name says. */ -function randomString() { - return Math.random().toString(36).substr(2, 6); + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (s) => { + const c = Number.parseInt(s, 10); + return ( + c ^ + (window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) + ).toString(16); + }); } diff --git a/src/message.ts b/src/message.ts index 50aeb176..d987e3f0 100644 --- a/src/message.ts +++ b/src/message.ts @@ -86,7 +86,11 @@ export function isMessage(val: unknown): val is Message { 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); + return ( + !hasOwnProperty(val, 'payload') || + val.payload === undefined || + isObject(val.payload) + ); case MessageType.ConnectionAck: return true; case MessageType.Subscribe: diff --git a/src/tests/client.ts b/src/tests/client.ts new file mode 100644 index 00000000..16a05e6c --- /dev/null +++ b/src/tests/client.ts @@ -0,0 +1,231 @@ +/** + * @jest-environment jsdom + */ + +import WebSocket from 'ws'; +import { url, startServer, pubsub } from './fixtures/simple'; +import { createClient } from '../client'; +import { noop } from '../utils'; + +/** Waits for the specified timeout and then resolves the promise. */ +const wait = (timeout: number) => + new Promise((resolve) => setTimeout(resolve, timeout)); + +Object.assign(global, { + WebSocket: WebSocket, +}); + +let dispose: (() => Promise) | undefined; +beforeAll(async () => { + [, dispose] = await startServer(); +}); +afterAll(async () => { + if (dispose) { + await dispose(); + } + dispose = undefined; +}); + +describe('query operation', () => { + it('should execute the query, "next" the result and then complete', (done) => { + const client = createClient({ url }); + + client.subscribe( + { + operationName: 'ValueGetter', + query: `query ValueGetter { + getValue + }`, + variables: {}, + }, + { + next: (result) => { + expect(result).toEqual({ data: { getValue: 'value' } }); + }, + error: () => { + fail(`Unexpected error call`); + }, + complete: done, + }, + ); + }); +}); + +describe('subscription operation', () => { + it('should execute and "next" the emitted results until disposed', async () => { + const client = createClient({ url }); + + const nextFn = jest.fn(); + const completeFn = jest.fn(); + + const dispose = client.subscribe( + { + operationName: 'BecomingHappy', + query: `subscription BecomingHappy { + becameHappy { + name + } + }`, + variables: {}, + }, + { + next: nextFn, + error: () => { + fail(`Unexpected error call`); + }, + complete: completeFn, + }, + ); + + await wait(5); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'john', + }, + }); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'jane', + }, + }); + + await wait(5); + + expect(nextFn).toHaveBeenNthCalledWith(1, { + data: { becameHappy: { name: 'john' } }, + }); + expect(nextFn).toHaveBeenNthCalledWith(2, { + data: { becameHappy: { name: 'jane' } }, + }); + expect(completeFn).not.toBeCalled(); + + dispose(); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'jeff', + }, + }); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'jenny', + }, + }); + + await wait(5); + + expect(nextFn).toBeCalledTimes(2); + expect(completeFn).toBeCalled(); + }); + + it('should emit results to correct distinct sinks', async () => { + const client = createClient({ url }); + + const nextFnForHappy = jest.fn(); + const completeFnForHappy = jest.fn(); + const disposeHappy = client.subscribe( + { + operationName: 'BecomingHappy', + query: `subscription BecomingHappy { + becameHappy { + name + } + }`, + variables: {}, + }, + { + next: nextFnForHappy, + error: () => { + fail(`Unexpected error call`); + }, + complete: completeFnForHappy, + }, + ); + await wait(5); + + const nextFnForBananas = jest.fn(); + const completeFnForBananas = jest.fn(); + const disposeBananas = client.subscribe( + { + operationName: 'BoughtBananas', + query: `subscription BoughtBananas { + boughtBananas { + name + } + }`, + variables: {}, + }, + { + next: nextFnForBananas, + error: () => { + fail(`Unexpected error call`); + }, + complete: completeFnForBananas, + }, + ); + await wait(5); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'john', + }, + }); + + pubsub.publish('boughtBananas', { + boughtBananas: { + name: 'jane', + }, + }); + + await wait(5); + + expect(nextFnForHappy).toBeCalledTimes(1); + expect(nextFnForHappy).toBeCalledWith({ + data: { becameHappy: { name: 'john' } }, + }); + + expect(nextFnForBananas).toBeCalledTimes(1); + expect(nextFnForBananas).toBeCalledWith({ + data: { boughtBananas: { name: 'jane' } }, + }); + + disposeHappy(); + + pubsub.publish('becameHappy', { + becameHappy: { + name: 'jeff', + }, + }); + + pubsub.publish('boughtBananas', { + boughtBananas: { + name: 'jenny', + }, + }); + + await wait(5); + + expect(nextFnForHappy).toHaveBeenCalledTimes(1); + expect(completeFnForHappy).toBeCalled(); + + expect(nextFnForBananas).toHaveBeenNthCalledWith(2, { + data: { boughtBananas: { name: 'jenny' } }, + }); + + disposeBananas(); + + pubsub.publish('boughtBananas', { + boughtBananas: { + name: 'jack', + }, + }); + + await wait(5); + + expect(nextFnForBananas).toHaveBeenCalledTimes(2); + expect(completeFnForBananas).toBeCalled(); + }); +}); diff --git a/src/tests/fixtures/simple.ts b/src/tests/fixtures/simple.ts index 4a342cf9..eb790c8f 100644 --- a/src/tests/fixtures/simple.ts +++ b/src/tests/fixtures/simple.ts @@ -12,6 +12,14 @@ import { createServer, ServerOptions, Server } from '../../server'; export const pubsub = new PubSub(); +const personType = new GraphQLObjectType({ + name: 'Person', + fields: { + id: { type: new GraphQLNonNull(GraphQLString) }, + name: { type: new GraphQLNonNull(GraphQLString) }, + }, +}); + export const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -26,17 +34,17 @@ export const schema = new GraphQLSchema({ name: 'Subscription', fields: { becameHappy: { - type: new GraphQLObjectType({ - name: 'Person', - fields: { - id: { type: new GraphQLNonNull(GraphQLString) }, - name: { type: new GraphQLNonNull(GraphQLString) }, - }, - }), + type: personType, subscribe: () => { return pubsub.asyncIterator('becameHappy'); }, }, + boughtBananas: { + type: personType, + subscribe: () => { + return pubsub.asyncIterator('boughtBananas'); + }, + }, }, }), }); diff --git a/src/types.d.ts b/src/types.d.ts index 2ea60044..e06fe729 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -4,12 +4,20 @@ * */ +import { GraphQLError } from 'graphql'; + +/** + * UUID v4 string type alias generated through the + * `generateUUID` function from the client. + */ +export type UUID = string; + export interface Disposable { dispose: () => Promise; } export interface Sink { next(value: T): void; - error(error: Error): void; + error(error: Error | readonly GraphQLError[]): void; complete(): void; } diff --git a/yarn.lock b/yarn.lock index 889b6e9b..3d008d9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1249,7 +1249,6 @@ __metadata: prettier: ^2.0.5 semantic-release: ^17.1.1 typescript: ^3.9.7 - websocket-as-promised: ^1.0.1 ws: ^7.3.1 peerDependencies: graphql: ">=15.0.0" @@ -2928,13 +2927,6 @@ __metadata: languageName: node linkType: hard -"chnl@npm:^1.0.0": - version: 1.2.0 - resolution: "chnl@npm:1.2.0" - checksum: febad1c0418e65448d41e54f8692e601a2eb0ce0f87eea317704be55757b9c01dede0ef2c59d9e4af9ed36d330069b6f551ad89af473008b9d472b0e4e9e310a - languageName: node - linkType: hard - "chownr@npm:^1.1.1, chownr@npm:^1.1.2, chownr@npm:^1.1.4": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -3884,7 +3876,7 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.17.0-next.0, es-abstract@npm:^1.17.0-next.1, es-abstract@npm:^1.17.5": +"es-abstract@npm:^1.17.0-next.1, es-abstract@npm:^1.17.5": version: 1.17.6 resolution: "es-abstract@npm:1.17.6" dependencies: @@ -8414,13 +8406,6 @@ fsevents@^2.1.2: languageName: node linkType: hard -"promise-controller@npm:^1.0.0": - version: 1.0.0 - resolution: "promise-controller@npm:1.0.0" - checksum: 3170e82c1a7affa3814ab820817d38dd742d80999fabdb6a3e76c78b83bb98dc82e4f8ac526e8a5d7707a866421997a68b9aa98459e2b430fe471e8b0f6f57be - languageName: node - linkType: hard - "promise-inflight@npm:^1.0.1, promise-inflight@npm:~1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -8438,17 +8423,6 @@ fsevents@^2.1.2: languageName: node linkType: hard -"promise.prototype.finally@npm:^3.1.1": - version: 3.1.2 - resolution: "promise.prototype.finally@npm:3.1.2" - dependencies: - define-properties: ^1.1.3 - es-abstract: ^1.17.0-next.0 - function-bind: ^1.1.1 - checksum: bad87121f91056f363df22cc7abb03901c073d0b6d593931a75be96a591bbcdd15b651122ef65221b36cc6dbb0c6cd2f37d8120d917464c642b858dfa5ea18e5 - languageName: node - linkType: hard - "prompts@npm:^2.0.1": version: 2.3.2 resolution: "prompts@npm:2.3.2" @@ -10678,17 +10652,6 @@ typescript@^3.9.7: languageName: node linkType: hard -"websocket-as-promised@npm:^1.0.1": - version: 1.0.1 - resolution: "websocket-as-promised@npm:1.0.1" - dependencies: - chnl: ^1.0.0 - promise-controller: ^1.0.0 - promise.prototype.finally: ^3.1.1 - checksum: 57f46af82281652d0cf2d7d52dfe99c346fa2bbd302aa00267ee717490e46c4c87f3757a94930988f65dc0ceabed08245b171ee51a3f164efaeba50c5e6db380 - languageName: node - linkType: hard - "whatwg-encoding@npm:^1.0.5": version: 1.0.5 resolution: "whatwg-encoding@npm:1.0.5"