UNPKG

apollo-client-ws

Version:

GraphQL WebSocket Network Interface for Apollo Client

428 lines (399 loc) 19.4 kB
/* ** Apollo-Client-WS -- GraphQL WebSocket Network Link for Apollo Client ** Copyright (c) 2017-2021 Dr. Ralf S. Engelschall <rse@engelschall.com> ** ** Permission is hereby granted, free of charge, to any person obtaining ** a copy of this software and associated documentation files (the ** "Software"), to deal in the Software without restriction, including ** without limitation the rights to use, copy, modify, merge, publish, ** distribute, sublicense, and/or sell copies of the Software, and to ** permit persons to whom the Software is furnished to do so, subject to ** the following conditions: ** ** The above copyright notice and this permission notice shall be included ** in all copies or substantial portions of the Software. ** ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* external dependencies */ import { ApolloLink, Observable } from "apollo-link" import WebSocket from "ws" import WebSocketFramed from "websocket-framed" import { print as printGraphQLQuery } from "graphql/language/printer" import compressGraphQLQuery from "graphql-query-compress" import Latching from "latching" import EventEmitter from "eventemitter3" import Ducky from "ducky" /* Apollo Client Link Interface for WebSocket Communication */ class ApolloClientWS extends ApolloLink { constructor (...args) { /* initialize ApolloLink base class */ super() /* sanity check constructor arguments */ if (args.length === 2 && typeof args[0] === "string" && typeof args[1] === "object") this._args = { uri: args[0], opts: args[1] } else if (args.length === 1 && typeof args[0] === "object") this._args = args[0] else throw new Error("invalid arguments to ApolloClientWS constructor (invalid number or type of arguments)") if (this._args.uri === undefined) throw new Error("invalid arguments to ApolloClientWS constructor (missing URI)") /* provide default values for options */ this._args.opts = Object.assign({ debug: 0, protocols: [], compress: false, encoding: "json", keepalive: 0, reconnectattempts: -1, reconnectdelay: 2 * 1000 }, this._args.opts) /* validate options */ const errors = [] if (!Ducky.validate(this._args.opts, `{ debug: number, protocols: [ string* ], compress: boolean, encoding: string, keepalive: number, reconnectattempts: number, reconnectdelay: number }`, errors)) throw new Error(`invalid options: ${errors.join("; ")}`) /* initialize state variables */ this._ws = null this._wsf = null this._to = null this._tx = {} /* provide hook latching sub-system */ this._latching = new Latching() /* provide event emitter sub-system */ this._emitter = new EventEmitter() } /* ADDON: pass-through methods of latching sub-system */ hook (...args) { return this._latching.hook(...args) } at (...args) { return this._latching.at(...args) } latch (...args) { return this._latching.latch(...args) } unlatch (...args) { return this._latching.unlatch(...args) } /* ADDON: pass-through methods of emitter sub-system */ emit (...args) { return this._emitter.emit(...args) } once (...args) { return this._emitter.once(...args) } on (...args) { return this._emitter.on(...args) } off (...args) { return this._emitter.off(...args) } addListener (...args) { return this._emitter.addListener(...args) } removeListener (...args) { return this._emitter.removeListener(...args) } removeAllListeners (...args) { return this._emitter.removeAllListeners(...args) } listenerCount (...args) { return this._emitter.listenerCount(...args) } listeners (...args) { return this._emitter.listeners(...args) } eventNames (...args) { return this._emitter.eventNames(...args) } /* ADDON: log a debug message */ log (level, msg) { if (level <= this._args.opts.debug) { const date = (new Date()).toISOString() const log = `${date} DEBUG [${level}]: ${msg}` this.emit("debug", { date, level, msg, log }) } } /* ADDON: connect to the peer */ connect () { const connectInternal = (attempt = 0) => { return new Promise((resolve, reject) => { this.emit("connect") this.log(1, "connect: begin") /* create a new WebSocket client */ let ws if (process.env.PLATFORM === "browser") ws = new WebSocket(this._args.uri, this._args.opts.protocols) else { const opts = this.hook("connect:options", "pass", {}) ws = new WebSocket(this._args.uri, this._args.opts.protocols, opts) } /* configure binary transfer */ ws.binaryType = process.env.PLATFORM === "browser" ? "arraybuffer" : "nodebuffer" /* create a new WebSocket-Framed wrapper */ const wsf = new WebSocketFramed(ws, this._args.opts.encoding) /* react (once) on error messages */ const onError = (ev) => { if (this._ws !== null && this._ws._explicitDisconnect) return this.log(1, `connect: end (connection error: ${ev.message})`) ws.removeEventListener("error", onError) ws._errorOnConnect = true if (attempt < this._args.opts.reconnectattempts || this._args.opts.reconnectattempts === -1) { this.log(2, "connection error: trigger new connect attempt " + `(in ${Math.trunc(this._args.opts.reconnectdelay / 1000)}s)`) setTimeout(() => { /* handle repeated connection attempts (subsequently) */ connectInternal(attempt + 1) .then(() => resolve()) .catch((err) => reject(err)) }, this._args.opts.reconnectdelay) } else reject(ev) } ws.addEventListener("error", onError) /* react (once) on the connection opening */ const onOpen = () => { this.log(1, "connect: end (connection open)") ws.removeEventListener("open", onOpen) this._ws = ws this._wsf = wsf if (this._args.opts.keepalive > 0) { this.log(2, "connect: start auto-disconnect timer") this._to = setTimeout(() => { this.disconnect() }, this._args.opts.keepalive) } this.emit("open") resolve() } ws.addEventListener("open", onOpen) /* react (always) on received response messages */ const onMessage = (ev) => { ev = this.hook("receive:message", "pass", ev) const { rid, type, data } = ev.frame if ( type === "GRAPHQL-RESPONSE" && typeof data === "object") { /* is a valid GraphQL response */ this.log(3, `query: response (framed): ${JSON.stringify(ev.frame)}`) if (this._tx[rid] !== undefined) { if (Ducky.validate(data, "({ data: Object, errors?: [ Object* ] } | { data?: Object, errors: [ Object* ] })")) this._tx[rid](data) else this._tx[rid](null, "invalid GraphQL response object") } } else { /* is a non-standard message */ this.log(2, `message received: ${JSON.stringify(ev.frame)}`) this.emit("receive", ev.frame) } } wsf.on("message", onMessage) const onSocketError = (err) => { this.log(2, `WebSocket error: ${err}`) /* not now, because of auto-reconnects we don't want to mention everything: this.emit("error", `WebSocket error: ${err}`) */ } wsf.on("error", onSocketError) /* react (once) on the connection closing */ const onClose = (ev) => { if (this._ws !== null && this._ws._explicitDisconnect) return this.log(1, `connection closed (code: ${ev.code})`) ws.removeEventListener("error", onError) ws.removeEventListener("open", onOpen) ws.removeEventListener("message", onMessage) ws.removeEventListener("close", onClose) if (this._to !== null) clearTimeout(this._to) this._to = null this._ws = null this._wsf = null const errorOnConnect = ws._errorOnConnect delete ws._errorOnConnect this.emit("close") if (!errorOnConnect && (ev.code > 1000 || this._args.opts.keepalive === 0)) { this.log(2, "connection closed: trigger re-connect " + `(in ${this._args.opts.reconnectdelay / 1000}s)`) setTimeout(() => { this.connect() .catch((err) => void (err)) }, this._args.opts.reconnectdelay) } } ws.addEventListener("close", onClose) }) } /* handle subsequent connect calls */ if (this._connectPromise) return Promise.resolve(this._connectPromise) else { /* handle repeated connection attempts (initially) */ return (this._connectPromise = connectInternal(0).then( () => { delete this._connectPromise }, (err) => { delete this._connectPromise; throw err } )) } } /* ADDON: disconnect from the peer */ disconnect () { /* handle subsequent disconnect calls */ if (this._disconnectPromise) return Promise.resolve(this._disconnectPromise) else { return (this._disconnectPromise = new Promise((resolve, reject) => { /* disconnect from the peer */ this.emit("disconnect") this.log(1, "disconnect: begin") if (this._ws !== null) { this._ws._explicitDisconnect = true const onClose = (ev) => { if (this._ws === null) return this._ws.removeEventListener("close", onClose) if (this._to !== null) { clearTimeout(this._to) this._to = null } this._ws = null this._wsf = null this.log(1, "disconnect: end") this.emit("close") resolve() } this._ws.addEventListener("close", onClose) this._ws.close() } else { this.log(1, "disconnect: end (no-op)") resolve() } }).then( () => { delete this._disconnectPromise }, () => { delete this._disconnectPromise } )) } } /* ADDON: send message to the peer */ send (type, data) { this.log(1, "send: begin") this.emit("send", { type, data }) return new Promise((resolve, reject) => { if (this._ws === null) { this.log(2, "send: on-the-fly connect") this.connect() .then(() => resolve()) .catch((err) => reject(err)) } else resolve() }).then(() => { /* await WebSocket ready-state OPEN */ return new Promise((resolve, reject) => { const check = (k) => { if (k <= 0) reject(new Error("failed to await WebSocket ready-state OPEN")) else if (this._ws.readyState === WebSocket.CLOSED) reject(new Error("failed to send to WebSocket, already in ready-state CLOSED")) else if (this._ws.readyState === WebSocket.CLOSING) reject(new Error("failed to send to WebSocket, already in ready-state CLOSING")) else if (this._ws.readyState === WebSocket.CONNECTING) setTimeout(() => check(k - 1), 100) else resolve() } check(100) }) }).then(() => { /* send the message */ const { frame } = this._wsf.send({ type, data }) this.log(2, `message sent: ${JSON.stringify(frame)}`) this.log(1, "send: end") return frame }) } /* STANDARD: send request to the peer */ request (operation) { return new Observable((observer) => { this.emit("request", operation) this.log(1, "request: begin") /* we here would have (but don't use): const { operationName, extensions, variables, query } = operation */ void (new Promise((resolve, reject) => { /* optionally perform the deferred connect */ if (this._ws === null) { this.log(2, "request: on-the-fly connect") this.connect() .then(() => resolve()) .catch((err) => reject(err)) } else resolve() }).then(() => { /* await WebSocket ready-state OPEN */ return new Promise((resolve, reject) => { const check = (k) => { if (k <= 0) reject(new Error("failed to await WebSocket ready-state OPEN")) else if (this._ws.readyState === WebSocket.CLOSED) reject(new Error("failed to send to WebSocket, already in ready-state CLOSED")) else if (this._ws.readyState === WebSocket.CLOSING) reject(new Error("failed to send to WebSocket, already in ready-state CLOSING")) else if (this._ws.readyState === WebSocket.CONNECTING) setTimeout(() => check(k - 1), 100) else resolve() } check(100) }) }).then(() => { /* perform the request */ return new Promise((resolve, reject) => { /* prepare the request */ let request = Object.assign({}, operation) request.query = printGraphQLQuery(request.query) if (this._args.opts.compress === true) request.query = compressGraphQLQuery(request.query) if (request.operationName === null) delete request.operationName if (Object.keys(request.variables).length === 0) delete request.variables if (Object.keys(request.extensions).length === 0) delete request.extensions request = this.hook("query:request", "pass", request) /* send the request */ this.log(2, `request: request: ${JSON.stringify(request)}`) const { frame } = this._wsf.send({ type: "GRAPHQL-REQUEST", data: request }) this.log(3, `request: request (framed): ${JSON.stringify(frame)}`) /* queue request and await response or error */ const fid = frame.fid this._tx[fid] = (response, error) => { delete this._tx[fid] if (response) resolve(response) else reject(error) } }) }).then((response) => { /* optionally automatically disconnect connection after request */ if (this._args.opts.keepalive > 0) { this.log(2, "request: (re)start auto-disconnect timer") if (this._to !== null) clearTimeout(this._to) this._to = setTimeout(() => { this.disconnect() }, this._args.opts.keepalive) } this.log(2, `request: response: ${JSON.stringify(response)}`) this.log(1, "request: end") return response }).then((response) => { /* pass response to other Apollo Link instances */ operation.setContext({ response }) /* pass response to caller */ observer.next(response) observer.complete() }).catch((err) => { /* pass error to caller */ observer.error(err) })) return () => { /* no-op: we cannot cancel the operation */ } }) } } /* API export */ module.exports = { ApolloClientWS }