Skip to content

Commit 01d8188

Browse files
committed
fix: use graphql-ws for subscriptions
this package is better maintained than subscriptions-transport-ws and has an esm version however, it will not work with graphql-playground until graphql/graphql-playground#1295 lands
1 parent 13a34d7 commit 01d8188

File tree

8 files changed

+554
-508
lines changed

8 files changed

+554
-508
lines changed

backend/package.json

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,17 @@
4545
"@graphql-tools/utils": "^8.6.1",
4646
"ajv": "^8.10.0",
4747
"apollo-server-core": "^3.6.3",
48-
"apollo-server-fastify": "^3.6.3",
48+
"apollo-server-express": "^3.6.3",
4949
"delay": "^5.0.0",
5050
"env-var": "^7.1.1",
51-
"fastify": "^3.27.1",
52-
"fastify-plugin": "^3.0.1",
53-
"fastify-websocket": "^4.0.0",
51+
"express": "^4.17.3",
5452
"graphql": "^16.3.0",
55-
"graphql-subscriptions": "2.0.0",
53+
"graphql-subscriptions": "^2.0.0",
54+
"graphql-ws": "^5.6.0",
5655
"humanize-duration": "^3.27.1",
5756
"make-promises-safe": "^5.1.0",
5857
"nanoid": "^3.2.0",
5958
"pino": "^7.6.5",
60-
"subscriptions-transport-ws": "^0.11.0",
6159
"ws": "^8.4.2"
6260
}
6361
}

backend/src/graphql/modules/game.configuration/resolvers.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ const resolvers: Resolvers = {
3030
},
3131
Subscription: {
3232
gameConfig: {
33-
subscribe: async () =>
34-
pubsub.asyncIterableIterator(['GAME_STATE_UPDATED']),
35-
},
33+
resolve: () => gameConfig,
34+
subscribe: async () => ({
35+
[Symbol.asyncIterator]() {
36+
return pubsub.asyncIterator('GAME_STATE_UPDATED');
37+
}
38+
})
39+
}
3640
},
3741
};
3842

backend/src/plugins/health.ts

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
1-
import { FastifyPluginCallback } from 'fastify';
2-
import fp from 'fastify-plugin';
1+
import type { WebSocketServer } from 'ws';
2+
import type { Request, Response } from 'express';
3+
34
import { uptime } from 'process';
45
import humanize from 'humanize-duration';
56
import log from '../log';
67

8+
// import { createRequire } from 'module'
9+
// const require = createRequire(import.meta.url);
10+
711
export interface HealthPluginOptions {
812
version: string;
913
}
1014

11-
const healthPlugin: FastifyPluginCallback<HealthPluginOptions> = (
12-
server,
13-
options,
14-
done
15-
) => {
16-
log.info('mounting health plugin');
17-
server.route({
18-
method: 'GET',
19-
url: '/health',
20-
handler: async () => {
21-
return {
22-
connectedClients: server.websocketServer.clients.size,
23-
status: 'ok',
24-
uptime: humanize(uptime() * 1000),
25-
serverTs: new Date().toJSON(),
26-
version: options.version,
27-
};
28-
},
29-
});
30-
31-
done();
32-
};
33-
34-
export default fp(healthPlugin);
15+
export function healthCheck(wsServer: WebSocketServer) {
16+
log.info('installing health middleware');
17+
return async (_req: Request, res: Response) => {
18+
// `wsServer` is a reference to the WebSocketServer instance
19+
const connectedClients = wsServer.clients.size;
20+
// if ts-node doesn't speak `require`, we'll bump `module` and use `createRequire`
21+
// eslint-disable-next-line @typescript-eslint/no-var-requires
22+
const { version } = require('../../package.json');
23+
res.json({
24+
connectedClients,
25+
status: 'ok',
26+
uptime: humanize(uptime() * 1000),
27+
serverTs: new Date().toJSON(),
28+
version,
29+
})
30+
};
31+
}

backend/src/server.ts

Lines changed: 54 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
/* eslint-disable @typescript-eslint/no-var-requires */
1+
import type { GraphQLSchema } from 'graphql';
22

3-
import { ApolloServer } from 'apollo-server-fastify';
3+
import express from 'express';
4+
import http from 'http';
5+
import path from 'path';
6+
7+
import { ApolloServer } from 'apollo-server-express';
48
import {
59
ApolloServerPluginDrainHttpServer,
610
ApolloServerPluginLandingPageGraphQLPlayground,
711
} from 'apollo-server-core';
8-
import { ApolloServerPlugin } from 'apollo-server-plugin-base';
9-
import { execute, GraphQLSchema, subscribe } from 'graphql';
10-
import { createServer } from 'http';
11-
import fastify, { FastifyInstance } from 'fastify';
12-
import { SubscriptionServer } from 'subscriptions-transport-ws';
12+
13+
import { WebSocketServer } from 'ws';
14+
import { useServer } from 'graphql-ws/lib/use/ws';
15+
1316
import {
1417
FASTIFY_LOG_ENABLED,
1518
GRAPHQL_ENDPOINT,
@@ -18,73 +21,59 @@ import {
1821
NODE_ENV,
1922
WS_MAX_PAYLOAD,
2023
} from './config';
21-
import { getWsAddressFromServer } from './utils';
22-
23-
// const { version } = require('../package.json');
24-
25-
function ApolloServerPluginDrainSubscriptionServer(
26-
subscriptionServer: SubscriptionServer
27-
): ApolloServerPlugin {
28-
return {
29-
async serverWillStart() {
30-
return {
31-
async drainServer() {
32-
subscriptionServer.close();
33-
},
34-
};
35-
},
36-
};
37-
}
24+
import { healthCheck } from './plugins/health';
25+
import { AddressInfo } from 'net';
26+
3827

39-
export default async function startServer(
40-
schema: GraphQLSchema
41-
): Promise<FastifyInstance> {
42-
const app = fastify({ logger: NODE_ENV === 'dev' || FASTIFY_LOG_ENABLED });
43-
44-
const subscriptionServer = SubscriptionServer.create(
45-
{
46-
schema,
47-
execute,
48-
subscribe,
49-
onConnect(
50-
connectionParams: unknown,
51-
webSocket: unknown,
52-
context: unknown
53-
) {
54-
app.log.info('Connected!', connectionParams, webSocket, context);
55-
},
56-
onDisconnect(webSocket: unknown, context: unknown) {
57-
app.log.info('Disconnected!', webSocket, context);
58-
},
59-
},
60-
{ server: app.server, path: GRAPHQL_ENDPOINT }
61-
);
28+
export default async function startApolloServer(schema: GraphQLSchema) {
29+
// TODO: replace fastify logging with something appropriate
30+
const app = express();
6231

32+
const httpServer = http.createServer(app);
6333
const server = new ApolloServer({
6434
schema,
6535
plugins: [
66-
ApolloServerPluginDrainSubscriptionServer(subscriptionServer),
67-
ApolloServerPluginDrainHttpServer({ httpServer: app.server }),
68-
ApolloServerPluginLandingPageGraphQLPlayground({
69-
endpoint: GRAPHQL_ENDPOINT,
70-
subscriptionEndpoint: GRAPHQL_ENDPOINT,
71-
}),
36+
ApolloServerPluginDrainHttpServer({ httpServer }),
37+
// ApolloServerPluginLandingPageGraphQLPlayground({
38+
// endpoint: GRAPHQL_ENDPOINT,
39+
// subscriptionEndpoint: GRAPHQL_ENDPOINT,
40+
// }),
7241
],
7342
});
7443

75-
try {
76-
await server.start();
44+
const wsServer = new WebSocketServer({
45+
server: httpServer,
46+
path: GRAPHQL_ENDPOINT,
47+
});
7748

78-
app.register(server.createHandler());
79-
await app.listen(HTTP_PORT, HTTP_ADDRESS);
49+
wsServer.on('listening', function() {
50+
const { port, family, address } = wsServer.address() as AddressInfo;
51+
console.log(`🚀 Subscriptions ready at ws://${address}:${port}${GRAPHQL_ENDPOINT} - ${family}`);
52+
})
8053

81-
app.log.info(
82-
`🚀 Server ready at http://${HTTP_ADDRESS}:${HTTP_PORT}${server.graphqlPath}`
83-
);
54+
app.get('/health', healthCheck(wsServer));
55+
app.get('/graphql', (_, res) => {
56+
res.sendFile(path.join(__dirname, 'views', 'graphiql', 'index.html'))
57+
})
58+
59+
useServer({ schema,
60+
// season to taste...
61+
onConnect: (ctx) => console.log('Connected', ctx),
62+
onSubscribe: (ctx, msg) => console.log('Subscribe', { ctx, msg }),
63+
onNext: (ctx, msg, args, result) => console.debug('Next', { ctx, msg, args, result }),
64+
onError: (ctx, msg, errors) => console.error('Error', { ctx, msg, errors }),
65+
onComplete: (ctx, msg) => console.log('Completed!', { ctx, msg }),
66+
onDisconnect: (ctx, msg, args) => console.log('Disconnected!', ctx, msg, args),
67+
}, wsServer);
8468

85-
return app;
86-
} catch (err) {
87-
app.log.error(err);
88-
process.exit(1);
89-
}
69+
await server.start();
70+
71+
server.applyMiddleware({ app });
72+
73+
await new Promise<void>(resolve => httpServer.listen(HTTP_PORT, HTTP_ADDRESS, resolve));
74+
75+
console.log(`🚀 HTTP Server ready at http://${HTTP_ADDRESS}:${HTTP_PORT}${server.graphqlPath}`);
76+
77+
return app
9078
}
79+

backend/src/views/graphiql/index.html

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<!--
2+
* Copyright (c) 2021 GraphQL Contributors
3+
* All rights reserved.
4+
*
5+
* This code is licensed under the MIT license.
6+
* Use it however you wish.
7+
-->
8+
<!DOCTYPE html>
9+
<html>
10+
<head>
11+
<style>
12+
body {
13+
height: 100%;
14+
margin: 0;
15+
width: 100%;
16+
overflow: hidden;
17+
}
18+
19+
#graphiql {
20+
height: 100vh;
21+
}
22+
</style>
23+
24+
<!--
25+
This GraphiQL example depends on Promise and AsyncIterator, which are available in
26+
modern browsers, but can be "polyfilled" for older browsers.
27+
GraphiQL itself depends on React DOM.
28+
If you do not want to rely on a CDN, you can host these files locally or
29+
include them directly in your favored resource bunder.
30+
-->
31+
<script
32+
crossorigin
33+
src="https://unpkg.com/react@16/umd/react.development.js"
34+
></script>
35+
<script
36+
crossorigin
37+
src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"
38+
></script>
39+
40+
<!--
41+
These two files can be found in the npm module, however you may wish to
42+
copy them directly into your environment, or perhaps include them in your
43+
favored resource bundler.
44+
-->
45+
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
46+
</head>
47+
48+
<body>
49+
<div id="graphiql">Loading...</div>
50+
<script
51+
src="https://unpkg.com/graphiql/graphiql.min.js"
52+
type="application/javascript"
53+
></script>
54+
<script
55+
src="https://unpkg.com/graphql-ws/umd/graphql-ws.min.js"
56+
type="application/javascript"
57+
></script>
58+
59+
<script>
60+
const wsClient = graphqlWs.createClient({
61+
url: "ws://0.0.0.0:8080/graphql",
62+
lazy: false, // connect as soon as the page opens
63+
});
64+
65+
function subscribe(payload) {
66+
let deferred = null;
67+
const pending = [];
68+
let throwMe = null,
69+
done = false;
70+
const dispose = wsClient.subscribe(payload, {
71+
next: (data) => {
72+
pending.push(data);
73+
deferred?.resolve(false);
74+
},
75+
error: (err) => {
76+
if (err instanceof Error) {
77+
throwMe = err;
78+
} else if (err instanceof CloseEvent) {
79+
throwMe = new Error(`Socket closed with event ${err.code} ${err.reason || ""}`.trim());
80+
} else {
81+
// GraphQLError[]
82+
throwMe = new Error(err.map(({ message }) => message).join(", "));
83+
}
84+
deferred?.reject(throwMe);
85+
},
86+
complete: () => {
87+
done = true;
88+
deferred?.resolve(true);
89+
},
90+
});
91+
return {
92+
[Symbol.asyncIterator]() {
93+
return this;
94+
},
95+
async next() {
96+
if (done) return { done: true, value: undefined };
97+
if (throwMe) throw throwMe;
98+
if (pending.length) return { value: pending.shift() };
99+
return (await new Promise(
100+
(resolve, reject) => (deferred = { resolve, reject })
101+
))
102+
? { done: true, value: undefined }
103+
: { value: pending.shift() };
104+
},
105+
async return() {
106+
dispose();
107+
return { done: true, value: undefined };
108+
},
109+
};
110+
}
111+
112+
ReactDOM.render(
113+
React.createElement(GraphiQL, {
114+
fetcher: subscribe,
115+
defaultVariableEditorOpen: true,
116+
}),
117+
document.getElementById("graphiql")
118+
);
119+
</script>
120+
</body>
121+
</html>

frontend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"main": "index.html",
33
"private": true,
4-
"name": "apollo-elements-app",
4+
"name": "frontend",
55
"version": "1.0.0",
66
"type": "module",
77
"author": "",
88
"license": "ISC",
99
"description": "Apollo Elements App",
1010
"scripts": {
11-
"start": "run-p start:*",
11+
"start": "concurrently 'yarn workspace frontend start:codegen' 'yarn workspace frontend start:serve'",
1212
"start:codegen": "graphql-codegen --watch",
1313
"start:serve": "wds --watch --open",
1414
"lint": "eslint",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"frontend"
77
],
88
"scripts": {
9-
"dev": "concurrently \"yarn generate:types --watch\" \"yarn backend:dev\"",
9+
"dev": "concurrently \"yarn generate:types --watch\" \"yarn backend:dev\" \"yarn workspace frontend start\"",
1010
"generate:types": "graphql-codegen"
1111
},
1212
"devDependencies": {

0 commit comments

Comments
 (0)