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 = ( + + + {decoration} + + ); + 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"