apollo-client-ws
Version:
GraphQL WebSocket Network Interface for Apollo Client
509 lines (420 loc) • 18.6 kB
JavaScript
/*
** 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.
*/
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.ApolloClientWS = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(_dereq_,module,exports){
"use strict";
var _apolloLink = _dereq_("apollo-link");
var _ws = _interopRequireDefault(_dereq_("ws"));
var _websocketFramed = _interopRequireDefault(_dereq_("websocket-framed"));
var _printer = _dereq_("graphql/language/printer");
var _graphqlQueryCompress = _interopRequireDefault(_dereq_("graphql-query-compress"));
var _latching = _interopRequireDefault(_dereq_("latching"));
var _eventemitter = _interopRequireDefault(_dereq_("eventemitter3"));
var _ducky = _interopRequireDefault(_dereq_("ducky"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/*
** 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 */
/* Apollo Client Link Interface for WebSocket Communication */
class ApolloClientWS extends _apolloLink.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.default.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.default();
/* provide event emitter sub-system */
this._emitter = new _eventemitter.default();
}
/* 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 ("node" === "browser") ws = new _ws.default(this._args.uri, this._args.opts.protocols);else {
const opts = this.hook("connect:options", "pass", {});
ws = new _ws.default(this._args.uri, this._args.opts.protocols, opts);
}
/* configure binary transfer */
ws.binaryType = "node" === "browser" ? "arraybuffer" : "nodebuffer";
/* create a new WebSocket-Framed wrapper */
const wsf = new _websocketFramed.default(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.default.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 === _ws.default.CLOSED) reject(new Error("failed to send to WebSocket, already in ready-state CLOSED"));else if (this._ws.readyState === _ws.default.CLOSING) reject(new Error("failed to send to WebSocket, already in ready-state CLOSING"));else if (this._ws.readyState === _ws.default.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 _apolloLink.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 === _ws.default.CLOSED) reject(new Error("failed to send to WebSocket, already in ready-state CLOSED"));else if (this._ws.readyState === _ws.default.CLOSING) reject(new Error("failed to send to WebSocket, already in ready-state CLOSING"));else if (this._ws.readyState === _ws.default.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 = (0, _printer.print)(request.query);
if (this._args.opts.compress === true) request.query = (0, _graphqlQueryCompress.default)(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
};
},{"apollo-link":"apollo-link","ducky":"ducky","eventemitter3":"eventemitter3","graphql-query-compress":"graphql-query-compress","graphql/language/printer":"graphql/language/printer","latching":"latching","websocket-framed":"websocket-framed","ws":"ws"}]},{},[1])(1)
});