diff --git a/package.json b/package.json
index de03ae419e..06c0918952 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,7 @@
"dev": "./scripts/dev",
"lint": "./scripts/lint",
"make-assets": "./scripts/make-assets",
- "prettier": "prettier 'src/**/*.[tj]s'",
+ "prettier": "prettier 'src/**/*.[tj]s' 'postgraphiql/src/**/*.[tj]s'",
"prettier:fix": "yarn prettier --write",
"prettier:check": "yarn prettier --list-different",
"test": "./scripts/test",
@@ -48,6 +48,7 @@
"@types/jsonwebtoken": "<7.2.1",
"@types/koa": "^2.0.44",
"@types/pg": "^7.4.10",
+ "@types/ws": "^6.0.1",
"body-parser": "^1.15.2",
"chalk": "^1.1.3",
"commander": "^2.19.0",
@@ -62,7 +63,9 @@
"pg-connection-string": "^0.1.3",
"pg-sql2": "^2.2.1",
"postgraphile-core": "4.3.1",
- "tslib": "^1.5.0"
+ "subscriptions-transport-ws": "^0.9.15",
+ "tslib": "^1.5.0",
+ "ws": "^6.1.3"
},
"devDependencies": {
"@babel/core": "7.0.0",
diff --git a/postgraphiql/package.json b/postgraphiql/package.json
index b145c1dc05..a1c60c76c6 100644
--- a/postgraphiql/package.json
+++ b/postgraphiql/package.json
@@ -7,8 +7,8 @@
"graphiql": "npm:@benjie/graphiql@0.12.1-a701b68f61a315280ba3e292275757f37e004ad5",
"graphiql-explorer": "^0.3.6",
"graphql": "^14.0.2",
- "react": "^15.3.2",
- "react-dom": "^15.3.2",
+ "react": "^16.8.1",
+ "react-dom": "^16.8.1",
"react-scripts": "^2.1.3"
},
"browserslist": [
diff --git a/postgraphiql/src/components/PostGraphiQL.js b/postgraphiql/src/components/PostGraphiQL.js
index 499ca4e089..d5982c97f7 100644
--- a/postgraphiql/src/components/PostGraphiQL.js
+++ b/postgraphiql/src/components/PostGraphiQL.js
@@ -1,15 +1,24 @@
import React from 'react';
import GraphiQL from 'graphiql';
+import { parse } from 'graphql';
import GraphiQLExplorer from 'graphiql-explorer';
import StorageAPI from 'graphiql/dist/utility/StorageAPI';
import './postgraphiql.css';
import { buildClientSchema, introspectionQuery, isType, GraphQLObjectType } from 'graphql';
+import { SubscriptionClient } from 'subscriptions-transport-ws';
+
+const isSubscription = ({ query }) =>
+ parse(query).definitions.some(
+ definition =>
+ definition.kind === 'OperationDefinition' && definition.operation === 'subscription',
+ );
const {
POSTGRAPHILE_CONFIG = {
graphqlUrl: 'http://localhost:5000/graphql',
streamUrl: 'http://localhost:5000/graphql/stream',
enhanceGraphiql: true,
+ subscriptions: true,
},
} = window;
@@ -65,6 +74,13 @@ class ExplorerWrapper extends React.PureComponent {
}
}
+const l = window.location;
+const websocketUrl = POSTGRAPHILE_CONFIG.graphqlUrl.match(/^https?:/)
+ ? POSTGRAPHILE_CONFIG.graphqlUrl.replace(/^http/, 'ws')
+ : `ws${l.protocol === 'https:' ? 's' : ''}://${l.hostname}${
+ l.port !== 80 && l.port !== 443 ? ':' + l.port : ''
+ }${POSTGRAPHILE_CONFIG.graphqlUrl}`;
+
/**
* The standard GraphiQL interface wrapped with some PostGraphile extensions.
* Including a JWT setter and live schema udpate capabilities.
@@ -81,11 +97,60 @@ class PostGraphiQL extends React.PureComponent {
headersText: '{\n"Authorization": null\n}\n',
headersTextValid: true,
explorerIsOpen: this._storage.get('explorerIsOpen') === 'false' ? false : true,
+ haveActiveSubscription: false,
+ socketStatus:
+ POSTGRAPHILE_CONFIG.enhanceGraphiql && POSTGRAPHILE_CONFIG.subscriptions ? 'pending' : null,
};
+ subscriptionsClient =
+ POSTGRAPHILE_CONFIG.enhanceGraphiql && POSTGRAPHILE_CONFIG.subscriptions
+ ? new SubscriptionClient(websocketUrl, {
+ reconnect: true,
+ connectionParams: () => this.getHeaders() || {},
+ })
+ : null;
+
+ activeSubscription = null;
+
componentDidMount() {
// Update the schema for the first time. Log an error if we fail.
- this.updateSchema().catch(error => console.error(error)); // tslint:disable-line no-console
+ this.updateSchema();
+
+ if (this.subscriptionsClient) {
+ const unlisten1 = this.subscriptionsClient.on('connected', () => {
+ this.setState({ socketStatus: 'connected', error: null });
+ });
+ const unlisten2 = this.subscriptionsClient.on('disconnected', () => {
+ this.setState({ socketStatus: 'disconnected' });
+ });
+ const unlisten3 = this.subscriptionsClient.on('connecting', () => {
+ this.setState({ socketStatus: 'connecting' });
+ });
+ const unlisten4 = this.subscriptionsClient.on('reconnected', () => {
+ this.setState({ socketStatus: 'reconnected', error: null });
+ setTimeout(() => {
+ this.setState(state =>
+ state.socketStatus === 'reconnected' ? { socketStatus: 'connected' } : {},
+ );
+ }, 5000);
+ });
+ const unlisten5 = this.subscriptionsClient.on('reconnecting', () => {
+ this.setState({ socketStatus: 'reconnecting' });
+ });
+ const unlisten6 = this.subscriptionsClient.on('error', error => {
+ // tslint:disable-next-line no-console
+ console.error('Client connection error', error);
+ this.setState({ error: new Error('Subscriptions client connection error') });
+ });
+ this.unlistenSubscriptionsClient = () => {
+ unlisten1();
+ unlisten2();
+ unlisten3();
+ unlisten4();
+ unlisten5();
+ unlisten6();
+ };
+ }
// If we were given a `streamUrl`, we want to construct an `EventSource`
// and add listeners.
@@ -97,11 +162,9 @@ class PostGraphiQL extends React.PureComponent {
eventSource.addEventListener(
'change',
() => {
- this.updateSchema()
- .then(() => console.log('PostGraphile: Schema updated')) // tslint:disable-line no-console
- .catch(error => console.error(error)); // tslint:disable-line no-console
+ this.updateSchema();
},
- false
+ false,
);
// Add event listeners that just log things in the console.
@@ -110,15 +173,19 @@ class PostGraphiQL extends React.PureComponent {
() => {
// tslint:disable-next-line no-console
console.log('PostGraphile: Listening for server sent events');
+ this.setState({ error: null });
this.updateSchema();
},
- false
+ false,
);
eventSource.addEventListener(
'error',
- // tslint:disable-next-line no-console
- () => console.log('PostGraphile: Failed to connect to server'),
- false
+ error => {
+ // tslint:disable-next-line no-console
+ console.error('PostGraphile: Failed to connect to event stream', error);
+ this.setState({ error: new Error('Failed to connect to event stream') });
+ },
+ false,
);
// Store our event source so we can unsubscribe later.
@@ -127,16 +194,25 @@ class PostGraphiQL extends React.PureComponent {
}
componentWillUnmount() {
+ if (this.unlistenSubscriptionsClient) this.unlistenSubscriptionsClient();
// Close out our event source so we get no more events.
this._eventSource.close();
this._eventSource = null;
}
+ cancelSubscription = () => {
+ if (this.activeSubscription !== null) {
+ this.activeSubscription.unsubscribe();
+ this.setState({
+ haveActiveSubscription: false,
+ });
+ }
+ };
+
/**
- * Executes a GraphQL query with some extra information then the standard
- * parameters. Namely a JWT which may be added as an `Authorization` header.
+ * Get the user editable headers as an object
*/
- async executeQuery(graphQLParams) {
+ getHeaders() {
const { headersText } = this.state;
let extraHeaders;
try {
@@ -149,6 +225,15 @@ class PostGraphiQL extends React.PureComponent {
} catch (e) {
// Do nothing
}
+ return extraHeaders;
+ }
+
+ /**
+ * Executes a GraphQL query with some extra information then the standard
+ * parameters. Namely a JWT which may be added as an `Authorization` header.
+ */
+ async executeQuery(graphQLParams) {
+ const extraHeaders = this.getHeaders();
const response = await fetch(POSTGRAPHILE_CONFIG.graphqlUrl, {
method: 'POST',
headers: Object.assign(
@@ -156,7 +241,7 @@ class PostGraphiQL extends React.PureComponent {
Accept: 'application/json',
'Content-Type': 'application/json',
},
- extraHeaders
+ extraHeaders,
),
credentials: 'same-origin',
body: JSON.stringify(graphQLParams),
@@ -167,6 +252,42 @@ class PostGraphiQL extends React.PureComponent {
return result;
}
+ /**
+ * Routes the request either to the subscriptionClient or to executeQuery.
+ */
+ fetcher = graphQLParams => {
+ this.cancelSubscription();
+ if (isSubscription(graphQLParams) && this.subscriptionsClient) {
+ return {
+ subscribe: observer => {
+ observer.next('Waiting for subscription to yield dataโฆ');
+
+ // Hack because GraphiQL logs `[object Object]` on error otherwise
+ const oldError = observer.error;
+ observer.error = function(error) {
+ let stack;
+ try {
+ stack = JSON.stringify(error, null, 2);
+ } catch (e) {
+ stack = error.message || error;
+ }
+ oldError.call(this, {
+ stack,
+ ...error,
+ });
+ };
+
+ const subscription = this.subscriptionsClient.request(graphQLParams).subscribe(observer);
+ this.setState({ haveActiveSubscription: true });
+ this.activeSubscription = subscription;
+ return subscription;
+ },
+ };
+ } else {
+ return this.executeQuery(graphQLParams);
+ }
+ };
+
/**
* When we recieve an event signaling a change for the schema, we must rerun
* our introspection query and notify the user of the results.
@@ -187,11 +308,16 @@ class PostGraphiQL extends React.PureComponent {
// Do some hacky stuff to GraphiQL.
this._updateGraphiQLDocExplorerNavStack(schema);
- } catch (e) {
+
+ // tslint:disable-next-line no-console
+ console.log('PostGraphile: Schema updated');
+ this.setState({ error: null });
+ } catch (error) {
// tslint:disable-next-line no-console
console.error('Error occurred when updating the schema:');
// tslint:disable-next-line no-console
- console.error(e);
+ console.error(error);
+ this.setState({ error });
}
}
@@ -304,7 +430,7 @@ class PostGraphiQL extends React.PureComponent {
window.prettier.format(editor.getValue(), {
parser: 'graphql',
plugins: window.prettierPlugins,
- })
+ }),
);
} else {
return this.graphiql.handlePrettifyQuery();
@@ -324,11 +450,78 @@ class PostGraphiQL extends React.PureComponent {
this._storage.set(
'explorerIsOpen',
// stringify so that storage API will store the state (it deletes key if value is false)
- JSON.stringify(this.state.explorerIsOpen)
- )
+ JSON.stringify(this.state.explorerIsOpen),
+ ),
);
};
+ renderSocketStatus() {
+ const { socketStatus, error } = this.state;
+ if (socketStatus === null) {
+ return null;
+ }
+ const icon =
+ {
+ connecting: '๐ค',
+ reconnecting: '๐',
+ connected: '๐',
+ reconnected: '๐
',
+ disconnected: 'โน๏ธ',
+ }[socketStatus] || '๐';
+ const tick = (
+
+ );
+ const cross = (
+
+ );
+ const decoration =
+ {
+ connecting: null,
+ reconnecting: null,
+ connected: tick,
+ reconnected: tick,
+ disconnected: cross,
+ }[socketStatus] || null;
+ const color =
+ {
+ connected: 'green',
+ reconnected: 'green',
+ connecting: 'orange',
+ reconnecting: 'orange',
+ disconnected: 'red',
+ }[socketStatus] || 'gray';
+ const svg = (
+
+ );
+ return (
+ <>
+ {error ? (
+
this.setState({ error: null })}
+ >
+
+ {'โ ๏ธ'}
+
+
+ ) : null}
+
+
+ {svg || icon}
+
+
+ >
+ );
+ }
+
render() {
const { schema } = this.state;
const sharedProps = {
@@ -337,7 +530,7 @@ class PostGraphiQL extends React.PureComponent {
this.graphiql = ref;
},
schema: schema,
- fetcher: params => this.executeQuery(params),
+ fetcher: this.fetcher,
};
if (!POSTGRAPHILE_CONFIG.enhanceGraphiql) {
return ;
@@ -370,6 +563,7 @@ class PostGraphiQL extends React.PureComponent {
+ {this.renderSocketStatus()}
- this.setState({
- headersText: e.target.value,
- headersTextValid: isValidJSON(e.target.value),
- })
+ this.setState(
+ {
+ headersText: e.target.value,
+ headersTextValid: isValidJSON(e.target.value),
+ },
+ () => {
+ if (this.state.headersTextValid && this.subscriptionsClient) {
+ // Reconnect to websocket with new headers
+ this.subscriptionsClient.close(false, true);
+ }
+ },
+ )
}
>
diff --git a/postgraphiql/src/components/postgraphiql.css b/postgraphiql/src/components/postgraphiql.css
index 0802f53523..dbcee992ff 100644
--- a/postgraphiql/src/components/postgraphiql.css
+++ b/postgraphiql/src/components/postgraphiql.css
@@ -54,3 +54,7 @@
.resultWrap {
width: 5rem;
}
+
+.postgraphiql-container .toolbar {
+ align-items: center;
+}
diff --git a/postgraphiql/yarn.lock b/postgraphiql/yarn.lock
index 404168b209..fc7c966276 100644
--- a/postgraphiql/yarn.lock
+++ b/postgraphiql/yarn.lock
@@ -1343,7 +1343,7 @@ arrify@^1.0.0, arrify@^1.0.1:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
-asap@~2.0.3, asap@~2.0.6:
+asap@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
@@ -2500,11 +2500,6 @@ core-js@2.5.7, core-js@^2.4.0, core-js@^2.5.0:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
integrity sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==
-core-js@^1.0.0:
- version "1.2.7"
- resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
- integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=
-
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -2561,15 +2556,6 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
-create-react-class@^15.6.0:
- version "15.6.3"
- resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036"
- integrity sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==
- dependencies:
- fbjs "^0.8.9"
- loose-envify "^1.3.1"
- object-assign "^4.1.1"
-
cross-fetch@2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-2.2.2.tgz#a47ff4f7fc712daba8f6a695a11c948440d45723"
@@ -3232,13 +3218,6 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
-encoding@^0.1.11:
- version "0.1.12"
- resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
- integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=
- dependencies:
- iconv-lite "~0.4.13"
-
end-of-stream@^1.0.0, end-of-stream@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
@@ -3811,19 +3790,6 @@ fb-watchman@^2.0.0:
dependencies:
bser "^2.0.0"
-fbjs@^0.8.9:
- version "0.8.17"
- resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
- integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=
- dependencies:
- core-js "^1.0.0"
- isomorphic-fetch "^2.1.1"
- loose-envify "^1.0.0"
- object-assign "^4.1.0"
- promise "^7.1.1"
- setimmediate "^1.0.5"
- ua-parser-js "^0.7.18"
-
figgy-pudding@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790"
@@ -4731,7 +4697,7 @@ iconv-lite@0.4.23:
dependencies:
safer-buffer ">= 2.1.2 < 3"
-iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -5233,7 +5199,7 @@ is-root@2.0.0:
resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.0.0.tgz#838d1e82318144e5a6f77819d90207645acc7019"
integrity sha512-F/pJIk8QD6OX5DNhRB7hWamLsUilmkDGho48KbgZ6xg/lmAZXHxzXQ91jzB3yRSw5kdQGGGc4yz8HYhTYIMWPg==
-is-stream@^1.0.1, is-stream@^1.1.0:
+is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
@@ -5306,14 +5272,6 @@ isobject@^3.0.0, isobject@^3.0.1:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-isomorphic-fetch@^2.1.1:
- version "2.2.1"
- resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
- integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=
- dependencies:
- node-fetch "^1.0.1"
- whatwg-fetch ">=0.10.0"
-
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -6616,14 +6574,6 @@ node-fetch@2.1.2:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5"
integrity sha1-q4hOjn5X44qUR1POxwb3iNF2i7U=
-node-fetch@^1.0.1:
- version "1.7.3"
- resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
- integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==
- dependencies:
- encoding "^0.1.11"
- is-stream "^1.0.1"
-
node-forge@0.7.5:
version "0.7.5"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df"
@@ -7974,13 +7924,6 @@ promise@8.0.2:
dependencies:
asap "~2.0.6"
-promise@^7.1.1:
- version "7.3.1"
- resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
- integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==
- dependencies:
- asap "~2.0.3"
-
prompts@^0.1.9:
version "0.1.14"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-0.1.14.tgz#a8e15c612c5c9ec8f8111847df3337c9cbd443b2"
@@ -7989,7 +7932,7 @@ prompts@^0.1.9:
kleur "^2.0.1"
sisteransi "^0.1.1"
-prop-types@^15.5.10, prop-types@^15.6.2:
+prop-types@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
@@ -8199,15 +8142,15 @@ react-dev-utils@^7.0.1:
strip-ansi "4.0.0"
text-table "0.2.0"
-react-dom@^15.3.2:
- version "15.6.2"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.2.tgz#41cfadf693b757faf2708443a1d1fd5a02bef730"
- integrity sha1-Qc+t9pO3V/rycIRDodH9WgK+9zA=
+react-dom@^16.8.1:
+ version "16.8.1"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.1.tgz#ec860f98853d09d39bafd3a6f1e12389d283dbb4"
+ integrity sha512-N74IZUrPt6UiDjXaO7UbDDFXeUXnVhZzeRLy/6iqqN1ipfjrhR60Bp5NuBK+rv3GMdqdIuwIl22u1SYwf330bg==
dependencies:
- fbjs "^0.8.9"
loose-envify "^1.1.0"
- object-assign "^4.1.0"
- prop-types "^15.5.10"
+ object-assign "^4.1.1"
+ prop-types "^15.6.2"
+ scheduler "^0.13.1"
react-error-overlay@^5.1.2:
version "5.1.2"
@@ -8269,16 +8212,15 @@ react-scripts@^2.1.3:
optionalDependencies:
fsevents "1.2.4"
-react@^15.3.2:
- version "15.6.2"
- resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72"
- integrity sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=
+react@^16.8.1:
+ version "16.8.1"
+ resolved "https://registry.yarnpkg.com/react/-/react-16.8.1.tgz#ae11831f6cb2a05d58603a976afc8a558e852c4a"
+ integrity sha512-wLw5CFGPdo7p/AgteFz7GblI2JPOos0+biSoxf1FPsGxWQZdN/pj6oToJs1crn61DL3Ln7mN86uZ4j74p31ELQ==
dependencies:
- create-react-class "^15.6.0"
- fbjs "^0.8.9"
loose-envify "^1.1.0"
- object-assign "^4.1.0"
- prop-types "^15.5.10"
+ object-assign "^4.1.1"
+ prop-types "^15.6.2"
+ scheduler "^0.13.1"
read-pkg-up@^1.0.1:
version "1.0.1"
@@ -8748,6 +8690,14 @@ saxes@^3.1.4:
dependencies:
xmlchars "^1.3.1"
+scheduler@^0.13.1:
+ version "0.13.1"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.1.tgz#1a217df1bfaabaf4f1b92a9127d5d732d85a9591"
+ integrity sha512-VJKOkiKIN2/6NOoexuypwSrybx13MY7NSy9RNt8wPvZDMRT1CW6qlpF5jXRToXNHz3uWzbm2elNpZfXfGPqP9A==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+
schema-utils@^0.4.4, schema-utils@^0.4.5:
version "0.4.7"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187"
@@ -8854,7 +8804,7 @@ set-value@^2.0.0:
is-plain-object "^2.0.3"
split-string "^3.0.1"
-setimmediate@^1.0.4, setimmediate@^1.0.5:
+setimmediate@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
@@ -9629,11 +9579,6 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-ua-parser-js@^0.7.18:
- version "0.7.19"
- resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
- integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==
-
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
@@ -10055,7 +10000,7 @@ whatwg-fetch@2.0.4:
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"
integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==
-whatwg-fetch@3.0.0, whatwg-fetch@>=0.10.0:
+whatwg-fetch@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==
diff --git a/src/interfaces.ts b/src/interfaces.ts
index e0e4746e38..5b68db735e 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -5,6 +5,7 @@ import { PluginHookFn } from './postgraphile/pluginHook';
import { Pool } from 'pg';
import { Plugin } from 'postgraphile-core';
import jwt = require('jsonwebtoken');
+import { EventEmitter } from 'events';
/**
* A narrower type than `any` that wonโt swallow errors from assumptions about
@@ -42,6 +43,9 @@ export interface PostGraphileOptions {
// `DROP SCHEMA postgraphile_watch CASCADE;`
/* @middlewareOnly */
watchPg?: boolean;
+ // [EXPERIMENTAL] Enable GraphQL websocket transport support for subscriptions (you still need a subscriptions plugin currently)
+ /* @middlewareOnly */
+ subscriptions?: boolean;
// The default Postgres role to use. If no role was provided in a provided
// JWT token, this role will be used.
pgDefaultRole?: string;
@@ -95,7 +99,7 @@ export interface PostGraphileOptions {
// effect.
/* @middlewareOnly */
handleErrors?: ((
- errors: Array
,
+ errors: ReadonlyArray,
req: IncomingMessage,
res: ServerResponse,
) => Array);
@@ -237,6 +241,14 @@ export interface PostGraphileOptions {
[propName: string]: any;
}
+export interface CreateRequestHandlerOptions extends PostGraphileOptions {
+ // The actual GraphQL schema we will use.
+ getGqlSchema: () => Promise;
+ // A Postgres client pool we use to connect Postgres clients.
+ pgPool: Pool;
+ _emitter: EventEmitter;
+}
+
export interface GraphQLFormattedErrorExtended {
message: string;
locations: ReadonlyArray | void;
@@ -271,4 +283,10 @@ export interface HttpRequestHandler {
moreOptions: any,
fn: (ctx: mixed) => any,
) => Promise;
+ options: CreateRequestHandlerOptions;
+ handleErrors: ((
+ errors: ReadonlyArray,
+ req: IncomingMessage,
+ res: ServerResponse,
+ ) => Array);
}
diff --git a/src/postgraphile/cli.ts b/src/postgraphile/cli.ts
index e8ef160618..87a6ec2564 100755
--- a/src/postgraphile/cli.ts
+++ b/src/postgraphile/cli.ts
@@ -23,6 +23,7 @@ import debugFactory = require('debug');
import { mixed } from '../interfaces';
import * as manifest from '../../package.json';
import sponsors = require('../../sponsors.json');
+import { enhanceHttpServerWithSubscriptions } from './http/subscriptions';
// tslint:disable-next-line no-any
function isString(str: any): str is string {
@@ -103,6 +104,10 @@ program
'a Postgres schema to be introspected. Use commas to define multiple schemas',
(option: string) => option.split(','),
)
+ .option(
+ '-S, --subscriptions',
+ '[EXPERIMENTAL] Enable GraphQL websocket transport support for subscriptions (you still need a subscriptions plugin currently)',
+ )
.option(
'-w, --watch',
'automatically updates your GraphQL schema when your database schema changes (NOTE: requires DB superuser to install `postgraphile_watch` schema)',
@@ -376,6 +381,7 @@ const overridesFromOptions = {};
const {
demo: isDemo = false,
connection: pgConnectionString,
+ subscriptions,
watch: watchPg,
schema: dbSchema,
host: hostname = 'localhost',
@@ -552,6 +558,7 @@ const postgraphileOptions = pluginHook(
jwtRole,
jwtVerifyOptions,
pgDefaultRole,
+ subscriptions,
watchPg,
showErrorStack,
extendedErrors,
@@ -668,6 +675,10 @@ if (noServer) {
server.timeout = serverTimeout;
}
+ if (postgraphileOptions.subscriptions) {
+ enhanceHttpServerWithSubscriptions(server, middleware);
+ }
+
pluginHook('cli:server:created', server, {
options: postgraphileOptions,
middleware,
@@ -713,12 +724,14 @@ if (noServer) {
[
`GraphQL API: ${chalk.underline.bold.blue(
`http://${hostname}:${actualPort}${graphqlRoute}`,
- )}`,
+ )}` + (postgraphileOptions.subscriptions ? ' (subscriptions enabled)' : ''),
!disableGraphiql &&
`GraphiQL GUI/IDE: ${chalk.underline.bold.blue(
`http://${hostname}:${actualPort}${graphiqlRoute}`,
)}` + (enhanceGraphiql ? '' : ` (enhance with '--enhance-graphiql')`),
- `Postgres connection: ${chalk.underline.magenta(safeConnectionString)}`,
+ `Postgres connection: ${chalk.underline.magenta(safeConnectionString)}${
+ postgraphileOptions.watchPg ? ' (watching)' : ''
+ }`,
`Postgres schema(s): ${schemas.map(schema => chalk.magenta(schema)).join(', ')}`,
`Documentation: ${chalk.underline(
`https://graphile.org/postgraphile/introduction/`,
diff --git a/src/postgraphile/http/createPostGraphileHttpRequestHandler.ts b/src/postgraphile/http/createPostGraphileHttpRequestHandler.ts
index 67065a94de..977885d855 100644
--- a/src/postgraphile/http/createPostGraphileHttpRequestHandler.ts
+++ b/src/postgraphile/http/createPostGraphileHttpRequestHandler.ts
@@ -15,7 +15,7 @@ import { extendedFormatError } from '../extendedFormatError';
import { IncomingMessage, ServerResponse } from 'http';
import { isKoaApp, middleware as koaMiddleware } from './koaMiddleware';
import { pluginHookFromOptions } from '../pluginHook';
-import { HttpRequestHandler, PostGraphileOptions, mixed } from '../../interfaces';
+import { HttpRequestHandler, mixed, CreateRequestHandlerOptions } from '../../interfaces';
import setupServerSentEvents from './setupServerSentEvents';
import withPostGraphileContext from '../withPostGraphileContext';
import { Context as KoaContext } from 'koa';
@@ -28,8 +28,6 @@ import finalHandler = require('finalhandler');
import bodyParser = require('body-parser');
import LRU = require('lru-cache');
import crypto = require('crypto');
-import { Pool } from 'pg';
-import { EventEmitter } from 'events';
/**
* The favicon file in `Buffer` format. We can send a `Buffer` directly to the
@@ -44,6 +42,7 @@ import favicon from '../../assets/favicon.ico';
* will use a regular expression to replace some variables.
*/
import baseGraphiqlHtml from '../../assets/graphiql.html';
+import { enhanceHttpServerWithSubscriptions } from './subscriptions';
/**
* When writing JSON to the browser, we need to be careful that it doesn't get
@@ -67,14 +66,6 @@ function safeJSONStringify(obj: {}) {
const shouldOmitAssets = process.env.POSTGRAPHILE_OMIT_ASSETS === '1';
// Used by `createPostGraphileHttpRequestHandler`
-export interface CreateRequestHandlerOptions extends PostGraphileOptions {
- // The actual GraphQL schema we will use.
- getGqlSchema: () => Promise;
- // A Postgres client pool we use to connect Postgres clients.
- pgPool: Pool;
- _emitter: EventEmitter;
-}
-
const calculateQueryHash = (queryString: string): string =>
crypto
.createHash('sha1')
@@ -413,9 +404,24 @@ export default function createPostGraphileHttpRequestHandler(
graphqlUrl: `${externalUrlBase}${graphqlRoute}`,
streamUrl: options.watchPg ? `${externalUrlBase}${graphqlRoute}/stream` : null,
enhanceGraphiql: options.enhanceGraphiql,
+ subscriptions: !!options.subscriptions,
})};\n `,
)
: null;
+
+ if (options.subscriptions) {
+ const server = req && req.connection && req.connection['server'];
+ if (!server) {
+ // tslint:disable-next-line no-console
+ console.warn(
+ "Failed to find server to add websocket listener to, you'll need to call `enhanceHttpServerWithSubscriptions` manually",
+ );
+ } else {
+ // Relying on this means that a normal request must come in before an
+ // upgrade attempt. It's better to call it manually.
+ enhanceHttpServerWithSubscriptions(server, middleware);
+ }
+ }
}
const isGraphqlRoute = pathname === graphqlRoute;
@@ -860,6 +866,8 @@ export default function createPostGraphileHttpRequestHandler(
middleware.formatError = formatError;
middleware.pgPool = pgPool;
middleware.withPostGraphileContextFromReqRes = withPostGraphileContextFromReqRes;
+ middleware.handleErrors = handleErrors;
+ middleware.options = options;
const hookedMiddleware = pluginHook('postgraphile:middleware', middleware, {
options,
diff --git a/src/postgraphile/http/setupServerSentEvents.ts b/src/postgraphile/http/setupServerSentEvents.ts
index ca7704937c..cbd3abe7a7 100644
--- a/src/postgraphile/http/setupServerSentEvents.ts
+++ b/src/postgraphile/http/setupServerSentEvents.ts
@@ -1,7 +1,7 @@
/* tslint:disable:no-any */
import { PassThrough } from 'stream';
import { IncomingMessage, ServerResponse } from 'http';
-import { CreateRequestHandlerOptions } from './createPostGraphileHttpRequestHandler';
+import { CreateRequestHandlerOptions } from '../../interfaces';
export default function setupServerSentEvents(
req: IncomingMessage,
diff --git a/src/postgraphile/http/subscriptions.ts b/src/postgraphile/http/subscriptions.ts
new file mode 100644
index 0000000000..1c1e28c616
--- /dev/null
+++ b/src/postgraphile/http/subscriptions.ts
@@ -0,0 +1,222 @@
+import { Server, ServerResponse } from 'http';
+import { HttpRequestHandler, mixed } from '../../interfaces';
+import { subscribe, ExecutionResult } from 'graphql';
+import { RequestHandler, Request, Response } from 'express';
+import * as WebSocket from 'ws';
+import { SubscriptionServer, ConnectionContext } from 'subscriptions-transport-ws';
+import parseUrl = require('parseurl');
+
+interface Deferred extends Promise {
+ resolve: (input?: T | PromiseLike | undefined) => void;
+ reject: (error: Error) => void;
+}
+
+function lowerCaseKeys(obj: object): object {
+ return Object.keys(obj).reduce((memo, key) => {
+ memo[key.toLowerCase()] = obj[key];
+ return memo;
+ }, {});
+}
+
+function deferred(): Deferred {
+ let resolve: (input?: T | PromiseLike | undefined) => void;
+ let reject: (error: Error) => void;
+ const promise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+ // tslint:disable-next-line prefer-object-spread
+ return Object.assign(promise, {
+ // @ts-ignore This isn't used before being defined.
+ resolve,
+ // @ts-ignore This isn't used before being defined.
+ reject,
+ });
+}
+
+export async function enhanceHttpServerWithSubscriptions(
+ websocketServer: Server,
+ postgraphileMiddleware: HttpRequestHandler,
+) {
+ if (websocketServer['__postgraphileSubscriptionsEnabled']) {
+ return;
+ }
+ websocketServer['__postgraphileSubscriptionsEnabled'] = true;
+ const {
+ options,
+ getGraphQLSchema,
+ withPostGraphileContextFromReqRes,
+ handleErrors,
+ } = postgraphileMiddleware;
+ const graphqlRoute = options.graphqlRoute || '/graphql';
+
+ const schema = await getGraphQLSchema();
+
+ const keepalivePromisesByContextKey: { [contextKey: string]: Deferred | null } = {};
+
+ const contextKey = (ws: WebSocket, opId: string) => ws['postgraphileId'] + '|' + opId;
+
+ const releaseContextForSocketAndOpId = (ws: WebSocket, opId: string) => {
+ const promise = keepalivePromisesByContextKey[contextKey(ws, opId)];
+ if (promise) {
+ promise.resolve();
+ keepalivePromisesByContextKey[contextKey(ws, opId)] = null;
+ }
+ };
+
+ const addContextForSocketAndOpId = (context: mixed, ws: WebSocket, opId: string) => {
+ releaseContextForSocketAndOpId(ws, opId);
+ const promise = deferred();
+ promise['context'] = context;
+ keepalivePromisesByContextKey[contextKey(ws, opId)] = promise;
+ return promise;
+ };
+
+ const applyMiddleware = async (
+ middlewares: Array = [],
+ req: Request,
+ res: Response,
+ ) => {
+ for (const middleware of middlewares) {
+ // TODO: add Koa support
+ await new Promise((resolve, reject) => {
+ middleware(req, res, err => (err ? reject(err) : resolve()));
+ });
+ }
+ };
+
+ const reqResFromSocket = async (socket: WebSocket) => {
+ const req = socket['__postgraphileReq'];
+ if (!req) {
+ throw new Error('req could not be extracted');
+ }
+ let dummyRes = socket['__postgraphileRes'];
+ if (req.res) {
+ throw new Error(
+ "Please get in touch with Benjie; we weren't expecting req.res to be present but we want to reserve it for future usage.",
+ );
+ }
+ if (!dummyRes) {
+ dummyRes = new ServerResponse(req);
+ dummyRes.writeHead = (statusCode: number, _statusMessage: never, headers: never) => {
+ if (statusCode && statusCode > 200) {
+ // tslint:disable-next-line no-console
+ console.error(
+ `Something used 'writeHead' to write a '${statusCode}' error for websockets - check the middleware you're passing!`,
+ );
+ socket.close();
+ } else if (headers) {
+ // tslint:disable-next-line no-console
+ console.error(
+ "Passing headers to 'writeHead' is not supported with websockets currently - check the middleware you're passing",
+ );
+ socket.close();
+ }
+ };
+ await applyMiddleware(options.websocketMiddlewares || options.middlewares, req, dummyRes);
+ socket['__postgraphileRes'] = dummyRes;
+ }
+ return { req, res: dummyRes };
+ };
+
+ const getContext = (socket: WebSocket, opId: string) => {
+ return new Promise((resolve, reject) => {
+ reqResFromSocket(socket)
+ .then(({ req, res }) =>
+ withPostGraphileContextFromReqRes(req, res, { singleStatement: true }, context => {
+ const promise = addContextForSocketAndOpId(context, socket, opId);
+ resolve(promise['context']);
+ return promise;
+ }),
+ )
+ .then(null, reject);
+ });
+ };
+
+ const wss = new WebSocket.Server({ noServer: true });
+
+ let socketId = 0;
+
+ websocketServer.on('upgrade', (req, socket, head) => {
+ // TODO: this will not support mounting postgraphile at a subpath right now...
+ const { pathname = '' } = parseUrl(req) || {};
+ const isGraphqlRoute = pathname === graphqlRoute;
+ if (isGraphqlRoute) {
+ wss.handleUpgrade(req, socket, head, ws => {
+ wss.emit('connection', ws, req);
+ });
+ }
+ });
+
+ SubscriptionServer.create(
+ {
+ schema,
+ execute: () => {
+ throw new Error('Only subscriptions are allowed over websocket transport');
+ },
+ subscribe,
+ onConnect(
+ connectionParams: object,
+ _socket: WebSocket,
+ connectionContext: ConnectionContext,
+ ) {
+ const { socket, request } = connectionContext;
+ socket['postgraphileId'] = ++socketId;
+ if (!request) {
+ throw new Error('No request!');
+ }
+ const normalizedConnectionParams = lowerCaseKeys(connectionParams);
+ request['connectionParams'] = connectionParams;
+ request['normalizedConnectionParams'] = normalizedConnectionParams;
+ socket['__postgraphileReq'] = request;
+ if (!request.headers.authorization && normalizedConnectionParams['authorization']) {
+ /*
+ * Enable JWT support through connectionParams.
+ *
+ * For other headers you'll need to do this yourself for security
+ * reasons (e.g. we don't want to allow overriding of Origin /
+ * Referer / etc)
+ */
+ request.headers.authorization = String(normalizedConnectionParams['authorization']);
+ }
+
+ socket['postgraphileHeaders'] = {
+ ...normalizedConnectionParams,
+ // The original headers must win (for security)
+ ...request.headers,
+ };
+ },
+ // tslint:disable-next-line no-any
+ async onOperation(message: any, params: any, socket: WebSocket) {
+ const opId = message.id;
+ const context = await getContext(socket, opId);
+
+ // Override schema (for --watch)
+ params.schema = await getGraphQLSchema();
+
+ Object.assign(params.context, context);
+
+ const { req, res } = await reqResFromSocket(socket);
+ const formatResponse = (response: ExecutionResult) => {
+ if (response.errors) {
+ response.errors = handleErrors(response.errors, req, res);
+ }
+ return response;
+ };
+ params.formatResponse = formatResponse;
+ return options.pluginHook
+ ? options.pluginHook('postgraphile:ws:onOperation', params, {
+ message,
+ params,
+ socket,
+ options,
+ })
+ : params;
+ },
+ onOperationComplete(socket: WebSocket, opId: string) {
+ releaseContextForSocketAndOpId(socket, opId);
+ },
+ },
+ wss,
+ );
+}
diff --git a/src/postgraphile/pluginHook.ts b/src/postgraphile/pluginHook.ts
index 2985e8b5eb..8e483da52e 100644
--- a/src/postgraphile/pluginHook.ts
+++ b/src/postgraphile/pluginHook.ts
@@ -4,6 +4,7 @@ import { HttpRequestHandler, PostGraphileOptions } from '../interfaces';
import { WithPostGraphileContextFn } from './withPostGraphileContext';
import { version } from '../../package.json';
import * as graphql from 'graphql';
+import { ExecutionParams } from 'subscriptions-transport-ws';
export type HookFn = (arg: T, context: {}) => T;
export type PluginHookFn = (
@@ -51,6 +52,7 @@ export interface PostGraphilePlugin {
'postgraphile:httpParamsList'?: HookFn>;
'postgraphile:validationRules'?: HookFn>; // AVOID THIS where possible; use 'postgraphile:validationRules:static' instead.
'postgraphile:middleware'?: HookFn;
+ 'postgraphile:ws:onOperation'?: HookFn;
withPostGraphileContext?: HookFn;
}
diff --git a/src/postgraphile/withPostGraphileContext.ts b/src/postgraphile/withPostGraphileContext.ts
index fc7fbe2670..84cb042202 100644
--- a/src/postgraphile/withPostGraphileContext.ts
+++ b/src/postgraphile/withPostGraphileContext.ts
@@ -1,6 +1,6 @@
import createDebugger = require('debug');
import jwt = require('jsonwebtoken');
-import { Pool, PoolClient } from 'pg';
+import { Pool, PoolClient, QueryConfig, QueryResult } from 'pg';
import { ExecutionResult, DocumentNode, OperationDefinitionNode, Kind } from 'graphql';
import * as sql from 'pg-sql2';
import { $$pgClient } from '../postgres/inventory/pgClientFromContext';
@@ -21,6 +21,7 @@ export type WithPostGraphileContextFn = (
jwtVerifyOptions?: jwt.VerifyOptions;
pgDefaultRole?: string;
pgSettings?: { [key: string]: mixed };
+ singleStatement?: boolean;
},
callback: (context: mixed) => Promise,
) => Promise;
@@ -56,6 +57,7 @@ const withDefaultPostGraphileContext: WithPostGraphileContextFn = async (
queryDocumentAst?: DocumentNode;
operationName?: string;
pgForceTransaction?: boolean;
+ singleStatement?: boolean;
},
callback: (context: mixed) => Promise,
): Promise => {
@@ -71,6 +73,7 @@ const withDefaultPostGraphileContext: WithPostGraphileContextFn = async (
queryDocumentAst,
operationName,
pgForceTransaction,
+ singleStatement,
} = options;
let operation: OperationDefinitionNode | void;
@@ -103,71 +106,123 @@ const withDefaultPostGraphileContext: WithPostGraphileContextFn = async (
pgSettings,
});
+ const sqlSettings: Array = [];
+ if (localSettings.length > 0) {
+ // Later settings should win, so we're going to loop backwards and not
+ // add settings for keys we've already seen.
+ const seenKeys: Array = [];
+ // TODO:perf: looping backwards is slow
+ for (let i = localSettings.length - 1; i >= 0; i--) {
+ const [key, value] = localSettings[i];
+ if (seenKeys.indexOf(key) < 0) {
+ seenKeys.push(key);
+ // Make sure that the third config is always `true` so that we are only
+ // ever setting variables on the transaction.
+ // Also, we're using `unshift` to undo the reverse-looping we're doing
+ sqlSettings.unshift(sql.fragment`set_config(${sql.value(key)}, ${sql.value(value)}, true)`);
+ }
+ }
+ }
+
+ const sqlSettingsQuery =
+ sqlSettings.length > 0 ? sql.compile(sql.query`select ${sql.join(sqlSettings, ', ')}`) : null;
+
// If we can avoid transactions, we get greater performance.
const needTransaction =
pgForceTransaction ||
- localSettings.length > 0 ||
+ !!sqlSettingsQuery ||
(operationType !== 'query' && operationType !== 'subscription');
// Now we've caught as many errors as we can at this stage, let's create a DB connection.
+ async function withAuthenticatedPgClient(
+ fn: (pgClient: PoolClient) => Promise,
+ ): Promise {
+ // Connect a new Postgres client
+ const pgClient = await pgPool.connect();
+
+ // Enhance our Postgres client with debugging stuffs.
+ if (
+ (debugPg.enabled || debugPgError.enabled || debugPgNotice.enabled) &&
+ !pgClient[$$pgClientOrigQuery]
+ ) {
+ debugPgClient(pgClient);
+ }
- // Connect a new Postgres client
- const pgClient = await pgPool.connect();
-
- // Enhance our Postgres client with debugging stuffs.
- if (
- (debugPg.enabled || debugPgError.enabled || debugPgNotice.enabled) &&
- !pgClient[$$pgClientOrigQuery]
- ) {
- debugPgClient(pgClient);
- }
+ // Begin our transaction, if necessary.
+ if (needTransaction) {
+ await pgClient.query('begin');
+ }
- // Begin our transaction, if necessary.
- if (needTransaction) {
- await pgClient.query('begin');
- }
+ try {
+ // If there is at least one local setting, load it into the database.
+ if (sqlSettingsQuery) {
+ await pgClient.query(sqlSettingsQuery);
+ }
- try {
- // If there is at least one local setting, load it into the database.
- if (needTransaction && localSettings.length !== 0) {
- // Later settings should win, so we're going to loop backwards and not
- // add settings for keys we've already seen.
- const seenKeys: Array = [];
-
- const sqlSettings: Array = [];
- for (let i = localSettings.length - 1; i >= 0; i--) {
- const [key, value] = localSettings[i];
- if (seenKeys.indexOf(key) < 0) {
- seenKeys.push(key);
- // Make sure that the third config is always `true` so that we are only
- // ever setting variables on the transaction.
- // Also, we're using `unshift` to undo the reverse-looping we're doing
- sqlSettings.unshift(
- sql.fragment`set_config(${sql.value(key)}, ${sql.value(value)}, true)`,
- );
+ // Use the client, wait for it to be finished with, then go to 'finally'
+ return await fn(pgClient);
+ } finally {
+ // Cleanup our Postgres client by ending the transaction and releasing
+ // the client back to the pool. Always do this even if the query fails.
+ try {
+ if (needTransaction) {
+ await pgClient.query('commit');
}
+ } finally {
+ pgClient.release();
}
-
- const query = sql.compile(sql.query`select ${sql.join(sqlSettings, ', ')}`);
-
- await pgClient.query(query);
}
+ }
+ if (singleStatement) {
+ // TODO:v5: remove this workaround
+ /*
+ * This is a workaround for subscriptions; the GraphQL context is allocated
+ * for the entire duration of the subscription, however hogging a pgClient
+ * for more than a few milliseconds (let alone hours!) is a no-no. So we
+ * fake a PG client that will set up the transaction each time `query` is
+ * called. It's a very thin/dumb wrapper, so it supports nothing but
+ * `query`.
+ */
+ const fakePgClient = {
+ query(
+ textOrQueryOptions?: string | QueryConfig,
+ values?: mixed,
+ cb?: void,
+ ): Promise {
+ if (!textOrQueryOptions) {
+ throw new Error('Incompatible call to singleStatement - no statement passed?');
+ } else if (typeof textOrQueryOptions === 'object') {
+ if (values || cb) {
+ throw new Error('Incompatible call to singleStatement - expected no callback');
+ }
+ } else if (typeof textOrQueryOptions !== 'string') {
+ throw new Error('Incompatible call to singleStatement - bad query');
+ } else if (values && !Array.isArray(values)) {
+ throw new Error('Incompatible call to singleStatement - bad values');
+ } else if (cb) {
+ throw new Error('Incompatible call to singleStatement - expected to return promise');
+ }
+ // Generate an authenticated client on the fly
+ return withAuthenticatedPgClient(pgClient =>
+ // @ts-ignore
+ pgClient.query(textOrQueryOptions, values),
+ );
+ },
+ };
- return await callback({
- [$$pgClient]: pgClient,
+ return callback({
+ [$$pgClient]: fakePgClient,
pgRole,
jwtClaims,
});
- } finally {
- // Cleanup our Postgres client by ending the transaction and releasing
- // the client back to the pool. Always do this even if the query fails.
- try {
- if (needTransaction) {
- await pgClient.query('commit');
- }
- } finally {
- pgClient.release();
- }
+ } else {
+ return withAuthenticatedPgClient(pgClient =>
+ callback({
+ [$$pgClient]: pgClient,
+ pgRole,
+ jwtClaims,
+ }),
+ );
}
};
diff --git a/yarn.lock b/yarn.lock
index 3407cc44c2..56c4fa8fd1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -869,6 +869,14 @@
"@types/express-serve-static-core" "*"
"@types/mime" "*"
+"@types/ws@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.1.tgz#ca7a3f3756aa12f62a0a62145ed14c6db25d5a28"
+ integrity sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==
+ dependencies:
+ "@types/events" "*"
+ "@types/node" "*"
+
"@webassemblyjs/ast@1.5.13":
version "1.5.13"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.5.13.tgz#81155a570bd5803a30ec31436bc2c9c0ede38f25"
@@ -1286,6 +1294,11 @@ async-each@^1.0.0:
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
integrity sha1-GdOGodntxufByF04iu28xW0zYC0=
+async-limiter@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
+ integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
+
async@^1.4.0, async@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
@@ -1492,6 +1505,11 @@ babylon@^6.18.0:
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+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"
@@ -2745,6 +2763,11 @@ event-stream@~3.3.0:
stream-combiner "~0.0.4"
through "~2.3.1"
+eventemitter3@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163"
+ integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA==
+
events@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
@@ -4052,7 +4075,7 @@ istanbul-reports@^1.3.0:
dependencies:
handlebars "^4.0.3"
-iterall@^1.2.2:
+iterall@^1.2.1, iterall@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==
@@ -6789,6 +6812,17 @@ style-loader@^0.23.0:
loader-utils "^1.1.0"
schema-utils "^0.4.5"
+subscriptions-transport-ws@^0.9.15:
+ version "0.9.15"
+ resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.15.tgz#68a8b7ba0037d8c489fb2f5a102d1494db297d0d"
+ integrity sha512-f9eBfWdHsePQV67QIX+VRhf++dn1adyC/PZHP6XI5AfKnZ4n0FW+v5omxwdHVpd4xq2ZijaHEcmlQrhBY79ZWQ==
+ dependencies:
+ backo2 "^1.0.2"
+ eventemitter3 "^3.1.0"
+ iterall "^1.2.1"
+ symbol-observable "^1.0.4"
+ ws "^5.2.0"
+
superagent@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-2.3.0.tgz#703529a0714e57e123959ddefbce193b2e50d115"
@@ -6832,6 +6866,11 @@ supports-color@^5.2.0, supports-color@^5.3.0, supports-color@^5.4.0:
dependencies:
has-flag "^3.0.0"
+symbol-observable@^1.0.4:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+ integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
+
symbol-tree@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
@@ -7531,6 +7570,20 @@ write-file-atomic@^2.0.0:
imurmurhash "^0.1.4"
signal-exit "^3.0.2"
+ws@^5.2.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"
+ integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==
+ dependencies:
+ async-limiter "~1.0.0"
+
+ws@^6.1.3:
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.3.tgz#d2d2e5f0e3c700ef2de89080ebc0ac6e1bf3a72d"
+ integrity sha512-tbSxiT+qJI223AP4iLfQbkbxkwdFcneYinM2+x46Gx2wgvbaOMO36czfdfVUBRTHvzAMRhDd98sA5d/BuWbQdg==
+ dependencies:
+ async-limiter "~1.0.0"
+
xdg-basedir@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"