├── .gitignore
├── LICENSE
├── README.md
├── example
├── .dockerignore
├── architecture.drawio.svg
├── docker-compose.yml
├── gateway-server
│ ├── .env.sample
│ ├── Dockerfile
│ ├── package.json
│ └── src
│ │ ├── index.js
│ │ ├── redis
│ │ └── index.js
│ │ └── services
│ │ ├── authors
│ │ ├── data.js
│ │ ├── index.js
│ │ ├── resolvers.js
│ │ └── typeDefs.js
│ │ └── posts
│ │ ├── data.js
│ │ ├── index.js
│ │ ├── resolvers.js
│ │ └── typeDefs.js
├── react-app
│ ├── .env.sample
│ ├── Dockerfile
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── logo192.png
│ │ ├── logo512.png
│ │ ├── manifest.json
│ │ └── robots.txt
│ └── src
│ │ ├── App.css
│ │ ├── App.js
│ │ ├── graphql
│ │ ├── apollo.js
│ │ ├── fragments.js
│ │ ├── mutations.js
│ │ ├── queries.js
│ │ └── subscriptions.js
│ │ ├── index.css
│ │ ├── index.js
│ │ └── pages
│ │ ├── AddPost.js
│ │ └── Home.js
└── subscriptions-server
│ ├── .env.sample
│ ├── Dockerfile
│ ├── package.json
│ └── src
│ ├── datasources
│ └── LIveBlogDataSource
│ │ └── index.js
│ ├── index.js
│ ├── redis
│ └── index.js
│ ├── resolvers.js
│ └── typeDefs.js
├── package-lock.json
├── package.json
├── src
├── datasources
│ └── GatewayDataSource
│ │ └── index.ts
├── index.ts
└── utils
│ ├── schema.ts
│ └── subscriptions.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # editor
2 | .vscode
3 |
4 | # dependencies
5 | node_modules
6 |
7 | # output
8 | dist
9 |
10 | # TypeScript
11 | *.tsbuildinfo
12 |
13 | # client
14 | client/build
15 |
16 | # logs
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
21 | # environment
22 | .env
23 | .env.local
24 | .env.development.local
25 | .env.test.local
26 | .env.production.local
27 |
28 | # misc
29 | .DS_Store
30 | .DS_Store?
31 | ._*
32 | .Spotlight-V100
33 | .Trashes
34 | ehthumbs.db
35 | *[Tt]humbs.db
36 | *.Trashes
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Apollo Graph, Inc. (Formerly Meteor Development Group, Inc.)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Using Subscriptions with a Federated Data Graph
2 |
3 | ## Update June 2023: [Federated subscriptions are now supported in GraphOS](https://www.apollographql.com/blog/announcement/backend/federated-subscriptions-in-graphos-real-time-data-at-scale/)! You can now use subscriptions in your supergraph without these sidecar solutions.
4 |
5 | This demonstration library shows how a decoupled subscription service can run alongside a federated data graph to provide real-time updates to a client. While the subscription service runs a separate non-federated Apollo Server, client applications do not need to perform any special handling of their subscription operations and may send those requests as they would to any GraphQL API that supports subscriptions. The subscription service's API may also specify return types for the `Subscription` fields that are defined in the federated data graph without explicitly redefining them in that service's type definitions.
6 |
7 | **In brief, the utilities contained within this library will allow you to:**
8 |
9 | - Create a decoupled, independently scalable subscriptions service to run alongside a unified data graph
10 | - Use types defined in your unified data graph as return types within the subscriptions service's type definitions (without manually redefining those types in the subscriptions service)
11 | - Publish messages to a shared pub/sub implementation from any subgraph service
12 | - Allow clients to write subscription operations just as they would if the `Subscription` fields were defined directly within the unified data graph itself
13 |
14 | ## Example Usage
15 |
16 | The following section outlines how to use the utilities included with this library. The following code is based on a complete working example that has been included in the `example` directory of this repository. Please reference the full example code for additional implementation details and context.
17 |
18 | ### Make an Executable Schema from Federated and Subscription Type Definitions
19 |
20 | The subscriptions service should only contain a definition for the `Subscription` object type, the types on this field may output any of the types defined in the federated data graph's schema:
21 |
22 | ```js
23 | // typeDefs.js (subscriptions service)
24 |
25 | import gql from "graphql-tag";
26 |
27 | export const typeDefs = gql`
28 | type Subscription {
29 | postAdded: Post
30 | }
31 | `;
32 | ```
33 |
34 | To make the federated data graph's types available to the subscription service, instantiate an `ApolloGateway` and call the `makeSubscriptionSchema` function in the gateway's `onSchemaLoadOrUpdate` method to combine its schema with the subscription service's type definitions and resolvers to make the complete executable schema.
35 |
36 | **Managed federation option:**
37 |
38 | ```js
39 | // index.js (subscriptions service)
40 | let schema;
41 | const gateway = new ApolloGateway();
42 |
43 | gateway.onSchemaLoadOrUpdate(schemaContext => {
44 | schema = makeSubscriptionSchema({
45 | gatewaySchema: schemaContext.apiSchema,
46 | typeDefs,
47 | resolvers
48 | });
49 | });
50 |
51 | await gateway.load({ apollo: getGatewayApolloConfig(apolloKey, graphVariant) });
52 | ```
53 |
54 | **Unmanaged federation option:**
55 |
56 | ```js
57 | // index.js (subscriptions service)
58 | let schema;
59 | const gateway = new ApolloGateway({
60 | serviceList: [
61 | /* Provide your service list here... */
62 | ],
63 | experimental_pollInterval = 36000;
64 | });
65 |
66 | gateway.onSchemaLoadOrUpdate(schemaContext => {
67 | schema = makeSubscriptionSchema({
68 | gatewaySchema: schemaContext.apiSchema,
69 | typeDefs,
70 | resolvers
71 | });
72 | });
73 |
74 | await gateway.load();
75 | ```
76 |
77 | Note that for unmanaged federation, we must set a poll interval to query the subgraph services for their schemas to detect a schema change. Polling the running endpoint for these SDLs is fairly blunt approach, so in production, a more computationally efficient approach would be preferable (or managed federation).
78 |
79 | ### Use an Apollo Data Source to Fetch Non-Payload Fields
80 |
81 | The subscription service can resolve fields that are included in a published message's payload, but it will need to reach out to the federated data graph to resolve additional non-payload fields. Using an Apollo data source subclassed from the provided `GatewayDataSource`, specific methods can be defined that fetch the non-payload fields by diffing the payload fields with the overall selection set. Optionally, headers (etc.) may be attached to the request to the federated data graph by providing a `willSendRequest` method:
82 |
83 | ```js
84 | // LiveBlogDataSource/index.js (subscriptions service)
85 |
86 | import { GatewayDataSource } from "federation-subscription-tools";
87 | import gql from "graphql-tag";
88 |
89 | export class LiveBlogDataSource extends GatewayDataSource {
90 | constructor(gatewayUrl) {
91 | super(gatewayUrl);
92 | }
93 |
94 | willSendRequest(request) {
95 | if (!request.headers) {
96 | request.headers = {};
97 | }
98 |
99 | request.headers["apollographql-client-name"] = "Subscriptions Service";
100 | request.headers["apollographql-client-version"] = "0.1.0";
101 |
102 | // Forwards the encoded token extracted from the `connectionParams` with
103 | // the request to the gateway
104 | request.headers.authorization = `Bearer ${this.context.token}`;
105 | }
106 |
107 | async fetchAndMergeNonPayloadPostData(postID, payload, info) {
108 | const selections = this.buildNonPayloadSelections(payload, info);
109 | const payloadData = Object.values(payload)[0];
110 |
111 | if (!selections) {
112 | return payloadData;
113 | }
114 |
115 | const Subscription_GetPost = gql`
116 | query Subscription_GetPost($id: ID!) {
117 | post(id: $id) {
118 | ${selections}
119 | }
120 | }
121 | `;
122 |
123 | try {
124 | const response = await this.query(Subscription_GetPost, {
125 | variables: { id: postID }
126 | });
127 | return this.mergeFieldData(payloadData, response.data.post);
128 | } catch (error) {
129 | console.error(error);
130 | }
131 | }
132 | }
133 |
134 | ```
135 |
136 | In the resolvers for the subscription field, the `fetchAndMergeNonPayloadPostData` method may be called to resolve all requested field data:
137 |
138 | ```js
139 | // resolvers.js (subscriptions service)
140 |
141 | const resolvers = {
142 | Subscription: {
143 | postAdded: {
144 | resolve(payload, args, { dataSources: { gatewayApi } }, info) {
145 | return gatewayApi.fetchAndMergeNonPayloadPostData(
146 | payload.postAdded.id,
147 | payload, // known field values
148 | info // contains the complete field selection set to diff
149 | );
150 | },
151 | subscribe(_, args) {
152 | return pubsub.asyncIterator(["POST_ADDED"]);
153 | }
154 | }
155 | }
156 | };
157 | ```
158 |
159 | In effect, this means that as long the resource that is used as the output type for any subscriptions field may be queried from the federated data graph, then this node may be used as an entry point to that data graph to resolve non-payload fields.
160 |
161 | For the gateway data source to be accessible in `Subscription` field resolvers, we must manually add it to the request context using the `addGatewayDataSourceToSubscriptionContext` function. Note that this example uses [graphql-ws](https://github.com/enisdenjo/graphql-ws) to serve the WebSocket-enabled endpoint for subscription operations. A sample implementation may be structured as follows:
162 |
163 | ```js
164 | // index.js (subscriptions service)
165 |
166 | const httpServer = http.createServer(function weServeSocketsOnly(_, res) {
167 | res.writeHead(404);
168 | res.end();
169 | });
170 |
171 | const wsServer = new ws.Server({
172 | server: httpServer,
173 | path: "/graphql"
174 | });
175 |
176 | useServer(
177 | {
178 | execute,
179 | subscribe,
180 | context: ctx => {
181 | // If a token was sent for auth purposes, retrieve it here
182 | const { token } = ctx.connectionParams;
183 |
184 | // Instantiate and initialize the GatewayDataSource subclass
185 | // (data source methods will be accessible on the `gatewayApi` key)
186 | const liveBlogDataSource = new LiveBlogDataSource(gatewayEndpoint);
187 | const dataSourceContext = addGatewayDataSourceToSubscriptionContext(
188 | ctx,
189 | liveBlogDataSource
190 | );
191 |
192 | // Return the complete context for the request
193 | return { token: token || null, ...dataSourceContext };
194 | },
195 | onSubscribe: (_ctx, msg) => {
196 | // Construct the execution arguments
197 | const args = {
198 | schema,
199 | operationName: msg.payload.operationName,
200 | document: parse(msg.payload.query),
201 | variableValues: msg.payload.variables
202 | };
203 |
204 | const operationAST = getOperationAST(args.document, args.operationName);
205 |
206 | // Stops the subscription and sends an error message
207 | if (!operationAST) {
208 | return [new GraphQLError("Unable to identify operation")];
209 | }
210 |
211 | // Handle mutation and query requests
212 | if (operationAST.operation !== "subscription") {
213 | return [
214 | new GraphQLError("Only subscription operations are supported")
215 | ];
216 | }
217 |
218 | // Validate the operation document
219 | const errors = validate(args.schema, args.document);
220 |
221 | if (errors.length > 0) {
222 | return errors;
223 | }
224 |
225 | // Ready execution arguments
226 | return args;
227 | }
228 | },
229 | wsServer
230 | );
231 |
232 | httpServer.listen({ port }, () => {
233 | console.log(
234 | `🚀 Subscriptions ready at ws://localhost:${port}${wsServer.options.path}`
235 | );
236 | });
237 | ```
238 |
239 | ## Try the Demo
240 |
241 | ### Installation & Set-up
242 |
243 | The full example code can be found in the `example` directory. To run the example, you'll need to create a new graph in Apollo Studio for the gateway, [configure rover](https://www.apollographql.com/docs/rover/configuring) with your `APOLLO_KEY`, and then push the two services' schemas:
244 |
245 | ```sh
246 | rover subgraph introspect http://localhost:4001 | rover subgraph publish blog@current --schema - --name authors --routing-url http://localhost:4001
247 | ```
248 |
249 | ```sh
250 | rover subgraph introspect http://localhost:4002 | rover subgraph publish blog@current --schema - --name posts --routing-url http://localhost:4002
251 | ```
252 |
253 | **Important!** The services for the authors and posts subgraphs will need to be running to fetch their schemas from the specified endpoints. You can quickly start up these services without the overhead of running a full `docker-compose` first by running `npm run server:authors` and `npm run server:posts` from the `example/gateway-server` directory (in two different terminal windows). Once the schemas have been successfully pushed to Apollo Studio, you can kill these processes.
254 |
255 | Next, add `.env` files to the server and client directories:
256 |
257 | 1. Add a `.env` file to the `example/gateway-server` directory using the `example/gateway-server/.env.sample` file as a template. Add your new `APOLLO_KEY` and `APOLLO_GRAPH_REF` as variables.
258 | 2. Add a `.env` file to the `example/subscriptions-server` directory using the `example/subscriptions-server/.env.sample` file as a template. Add the same Apollo API key as the `APOLLO_KEY` and `APOLLO_GRAPH_REF`.
259 | 3. Add a `.env` file to the `example/client` directory using the `example/client/.env.sample` file as a template.
260 |
261 | Finally, run `docker-compose up --build` from the `example` directory to start all services.
262 |
263 | TLDR;
264 |
265 | ```bash
266 | cp example/gateway-server/.env.sample example/gateway-server/.env
267 | cp example/subscriptions-server/.env.sample example/subscriptions-server/.env
268 | cp example/client/.env.sample example/client/.env
269 | docker-compose up --build
270 | ```
271 |
272 | The federated data graph endpoint may be accessed at [http://localhost:4000/graphql](http://localhost:4000/graphql).
273 |
274 | The subscriptions service WebSocket endpoint may be accessed at [ws://localhost:5000/graphql](ws://localhost:5000/graphql).
275 |
276 | A React app will be available at [http://localhost:3000](http://localhost:3000).
277 |
278 | ### Usage
279 |
280 | To see the post list in the client app update in real-time, add a new post at [http://localhost:3000/post/add](http://localhost:3000/post/add) or run the following mutation directly:
281 |
282 | ```graphql
283 | mutation AddPost {
284 | addPost(authorID: 1, content: "Hello, world!", title: "My Next Post") {
285 | id
286 | author {
287 | name
288 | }
289 | content
290 | publishedAt
291 | title
292 | }
293 | }
294 | ```
295 |
296 | ### Rationale
297 |
298 | The architecture demonstrated in this project seeks to provide a bridge to native `Subscription` operation support in Apollo Federation. This approach to subscriptions has the advantage of allowing the Apollo Gateway API to remain as the "stateless execution engine" of a federated data graph while offloading all subscription requests to a separate service, thus allowing the subscription service to be scaled independently of the gateway.
299 |
300 | To allow the `Subscription` fields to specify return types that are defined in gateway API only, the federated data graph's type definitions are merged with the subscription service's type definitions and resolvers in the gateway's `onSchemaChange` callback to avoid re-declaring these types explicitly here.
301 |
302 | ### Architectural Details
303 |
304 | #### Components
305 |
306 | Docker will start five different services with `docker-compose up`:
307 |
308 | **1. Gateway Server + Subgraph Services**
309 |
310 | This service contains the federated data graph. For simplicity's sake, two implementing services (for authors and posts) have been bundled with the gateway API in this service. Each implementing service connects to Redis as needed so it can publish events from mutations (the "pub" end of subscriptions). For example:
311 |
312 | ```js
313 | import { pubsub } from "./redis";
314 |
315 | export const resolvers = {
316 | // ...
317 | Mutation: {
318 | addPost(root, args, context, info) {
319 | const post = newPost();
320 | pubsub.publish("POST_ADDED", { postAdded: post });
321 | return post;
322 | }
323 | }
324 | };
325 | ```
326 |
327 | **2. Subscriptions Server**
328 |
329 | This service also connects to Redis to facilitate the "sub" end of the subscriptions. This service is where the `Subscription` type and related fields are defined. As a best practice, only define a `Subscription` type and applicable resolvers in this service.
330 |
331 | When sending subscription data to clients, the subscription service can't automatically resolve any data beyond what's provided in the published payload from the implementing service. This means that to resolve nested types (or any other fields that aren't immediately available in the payload object), the resolvers must be defined in the subscription services to fetch this data on a field-by-field basis.
332 |
333 | There are a number of possible approaches that could be taken here, but one recommended approach is to provide an Apollo data source with methods that automatically compare the fields included in the payload against the fields requested in the operation, then selectively query the necessary field data in a single request to the gateway, and finally combine the returned data with the with original payload data to fully resolve the request. For example:
334 |
335 | ```js
336 | import { pubsub } from "./redis";
337 |
338 | export const resolvers = {
339 | Subscription: {
340 | postAdded: {
341 | resolve(payload, args, { dataSources: { gatewayApi } }, info) {
342 | return gatewayApi.fetchAndMergeNonPayloadPostData(
343 | payload.postAdded.id,
344 | payload,
345 | info
346 | );
347 | },
348 | subscribe(_, args) {
349 | return pubsub.asyncIterator(["POST_ADDED"]);
350 | }
351 | }
352 | }
353 | };
354 | ```
355 |
356 | **3. Redis**
357 |
358 | A shared Redis instance is used to capture publications from the services behind the federated data graph as well as the subscriptions initiated in the subscriptions service, though other [`PubSub` implementations](https://www.apollographql.com/docs/apollo-server/data/subscriptions/#pubsub-implementations) could easily be supported. Note that an in-memory pub/sub implementation will not work because it cannot be shared between the separate gateway and subscription services.
359 |
360 | **4. React App**
361 |
362 | The React app contains a homepage with a list of posts as well as a form to add new posts. When a new post is added, the feed of posts on the homepage will be automatically updated.
363 |
364 | #### Diagram
365 |
366 | The architecture of the provided example may be visualized as follows:
367 |
368 | 
369 |
370 | ## Important Considerations
371 |
372 | **Subscriptions Must be Defined in a Single Service:**
373 |
374 | This solution requires all `Subscription` fields to be defined in a single, decoupled subscription service. This requirement may necessitate that ownership of this service is shared amongst teams that otherwise manage independent portions of the schema applicable to queries and mutations.
375 |
376 | **Synchronizing Event Labels:**
377 |
378 | Some level of coordination would be necessary to ensure that event labels (e.g. `POST_ADDED`) are synchronized between the implementing services that publish events and the subscription service that calls the `asyncIterator` method with these labels as arguments. Breaking changes may occur without such coordination.
379 |
--------------------------------------------------------------------------------
/example/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode
2 |
3 | .dockerignore
4 | docker-compose*
5 | **/Dockerfile*
6 |
7 | **/npm-debug.log*
8 | **/node_modules/
9 |
10 | tsconfig.tsbuildinfo
11 |
12 | .git
13 | .gitingore
14 |
15 | LICENSE
16 | README.md
--------------------------------------------------------------------------------
/example/architecture.drawio.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | redis:
5 | image: redis:5.0.9-alpine
6 | container_name: redis
7 | restart: always
8 | ports:
9 | - 6379:6379
10 | gateway_server:
11 | container_name: gateway_server
12 | restart: always
13 | build:
14 | context: ./gateway-server
15 | ports:
16 | - 4000:4000
17 | - 4001:4001
18 | - 4002:4002
19 | volumes:
20 | - ./gateway-server:/home/node/app
21 | - /home/node/app/node_modules
22 | depends_on:
23 | - redis
24 | env_file:
25 | - ./gateway-server/.env
26 | command: npm run server
27 | subscriptions_server:
28 | container_name: subscriptions_server
29 | restart: always
30 | build:
31 | context: ../
32 | dockerfile: example/subscriptions-server/Dockerfile
33 | ports:
34 | - 5000:5000
35 | volumes:
36 | - ../example/subscriptions-server:/home/node/app
37 | - ../:/home/node/federation-subscription-tools
38 | - /home/node/app/node_modules
39 | - /home/node/federation-subscription-tools/dist
40 | - /home/node/federation-subscription-tools/node_modules
41 | depends_on:
42 | - gateway_server
43 | - redis
44 | env_file:
45 | - ./subscriptions-server/.env
46 | command: npm run server
47 | react_app:
48 | container_name: react_app
49 | restart: always
50 | build:
51 | context: ./react-app
52 | volumes:
53 | - ./react-app:/usr/src/app
54 | - /usr/src/app/node_modules
55 | env_file:
56 | - ./react-app/.env
57 | ports:
58 | - 3000:3000
59 | stdin_open: true
60 | command: npm start
61 |
--------------------------------------------------------------------------------
/example/gateway-server/.env.sample:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 |
3 | APOLLO_KEY=
4 | APOLLO_GRAPH_REF=
5 | APOLLO_GRAPH_VARIANT=current
6 |
7 | GATEWAY_PORT=4000
8 | AUTHORS_SERVICE_PORT=4001
9 | AUTHORS_SERVICE_URL=http://localhost:4001
10 | POSTS_SERVICE_PORT=4002
11 | POSTS_SERVICE_URL=http://localhost:4002
12 |
13 | REDIS_HOST_ADDRESS=redis
14 | REDIS_PORT=6379
15 |
--------------------------------------------------------------------------------
/example/gateway-server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14.16-alpine
2 |
3 | ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
4 | ENV PATH=$PATH:/home/node/.npm-global/bin
5 |
6 | RUN mkdir -p /home/node/app/node_modules && \
7 | chown -R node:node /home/node/app
8 |
9 | WORKDIR /home/node/app
10 |
11 | USER node
12 |
13 | COPY package*.json ./
14 |
15 | RUN npm install --no-optional && npm cache clean --force
16 |
17 | COPY --chown=node:node . .
18 |
--------------------------------------------------------------------------------
/example/gateway-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "A demonstration federated data graph where the subgraphs publish subscription events to Redis pub/sub.",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "server": "concurrently -k npm:server:*",
8 | "server:authors": "nodemon -r esm -r dotenv/config ./src/services/authors/index.js",
9 | "server:posts": "nodemon -r esm -r dotenv/config ./src/services/posts/index.js",
10 | "server:gateway": "wait-on tcp:4001 tcp:4002 && nodemon -r esm -r dotenv/config ./src/index.js"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "@apollo/gateway": "^0.46.0",
17 | "@apollo/subgraph": "^0.3.1",
18 | "apollo-server": "^3.6.3",
19 | "concurrently": "^5.3.0",
20 | "dotenv": "^8.2.0",
21 | "esm": "^3.2.25",
22 | "graphql": "^15.5.0",
23 | "graphql-redis-subscriptions": "^2.3.1",
24 | "ioredis": "^4.19.4",
25 | "nodemon": "^2.0.7",
26 | "wait-on": "^5.2.1"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/example/gateway-server/src/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloGateway } from "@apollo/gateway";
2 | import { ApolloServer } from "apollo-server";
3 | import {
4 | ApolloServerPluginUsageReporting,
5 | ApolloServerPluginUsageReportingDisabled
6 | } from "apollo-server-core";
7 |
8 | const isProd = process.env.NODE_ENV === "production";
9 | const apolloKey = process.env.APOLLO_KEY;
10 |
11 | let gatewayOptions = {
12 | debug: isProd ? false : true
13 | };
14 |
15 | if (!apolloKey) {
16 | console.log("Head to https://studio.apollographql.com an create an account");
17 |
18 | gatewayOptions.serviceList = [
19 | { name: "authors", url: process.env.AUTHORS_SERVICE_URL },
20 | { name: "posts", url: process.env.POSTS_SERVICE_URL }
21 | ];
22 | }
23 |
24 | const apolloUsageReportingPlugin = apolloKey
25 | ? ApolloServerPluginUsageReporting()
26 | : ApolloServerPluginUsageReportingDisabled();
27 |
28 | const gateway = new ApolloGateway(gatewayOptions);
29 | const server = new ApolloServer({
30 | gateway,
31 | subscriptions: false,
32 | plugins: [apolloUsageReportingPlugin]
33 | });
34 |
35 | server.listen(process.env.GATEWAY_PORT).then(({ url }) => {
36 | console.log(`🚀 Gateway API running at ${url}`);
37 | });
38 |
--------------------------------------------------------------------------------
/example/gateway-server/src/redis/index.js:
--------------------------------------------------------------------------------
1 | import { RedisPubSub } from "graphql-redis-subscriptions";
2 | import Redis from "ioredis";
3 |
4 | export const redis = new Redis(
5 | process.env.REDIS_PORT,
6 | process.env.REDIS_HOST_ADDRESS
7 | );
8 |
9 | export const pubsub = new RedisPubSub({
10 | publisher: redis,
11 | subscriber: redis
12 | });
13 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/authors/data.js:
--------------------------------------------------------------------------------
1 | export const authors = [
2 | { id: 1, name: "Alice" },
3 | { id: 2, name: "Bob" }
4 | ];
5 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/authors/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { buildSubgraphSchema } from "@apollo/subgraph";
3 |
4 | import { resolvers } from "./resolvers";
5 | import { typeDefs } from "./typeDefs";
6 |
7 | const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
8 |
9 | const server = new ApolloServer({ schema });
10 |
11 | server.listen(process.env.AUTHORS_SERVICE_PORT).then(({ url }) => {
12 | console.log(`🚀 Authors service ready at ${url}`);
13 | });
14 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/authors/resolvers.js:
--------------------------------------------------------------------------------
1 | import { authors } from "./data";
2 |
3 | export const resolvers = {
4 | Author: {
5 | __resolveReference(reference, context, info) {
6 | return authors.find(author => author.id === parseInt(reference.id));
7 | }
8 | },
9 |
10 | Query: {
11 | author(parent, { id }, context, info) {
12 | return authors.find(author => author.id === parseInt(id));
13 | },
14 | authors(parent, args, context, info) {
15 | return authors;
16 | }
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/authors/typeDefs.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server";
2 |
3 | export const typeDefs = gql`
4 | type Author @key(fields: "id") {
5 | id: ID!
6 | name: String!
7 | # nomDePlum: String
8 | }
9 |
10 | extend type Query {
11 | author(id: ID!): Author
12 | authors: [Author]
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/posts/data.js:
--------------------------------------------------------------------------------
1 | export const posts = [
2 | {
3 | id: 1,
4 | authorID: 1,
5 | title: "The Big Event - What We Know So Far",
6 | content:
7 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vel ex lacinia, hendrerit lectus ullamcorper, pretium augue. Morbi ornare eu felis quis feugiat. In porta augue a erat viverra, vitae tincidunt mi ultrices.",
8 | publishedAt: "2020-09-09T19:31:24.000Z"
9 | },
10 | {
11 | id: 2,
12 | authorID: 2,
13 | title: "Breaking Update About What Happened",
14 | content:
15 | "Nunc eu fringilla ex, nec mattis ante. Donec maximus a purus id viverra. Curabitur nulla magna, aliquam vitae venenatis vel, feugiat sed nisl. Ut non varius est, ac faucibus nisl. Pellentesque iaculis orci nunc, dapibus lacinia ante pulvinar ut.",
16 | publishedAt: "2020-09-09T20:04:57.000Z"
17 | }
18 | ];
19 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/posts/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { buildSubgraphSchema } from "@apollo/subgraph";
3 |
4 | import { resolvers } from "./resolvers";
5 | import { typeDefs } from "./typeDefs";
6 |
7 | const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
8 |
9 | const server = new ApolloServer({ schema });
10 |
11 | server.listen(process.env.POSTS_SERVICE_PORT).then(({ url }) => {
12 | console.log(`🚀 Posts service ready at ${url}`);
13 | });
14 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/posts/resolvers.js:
--------------------------------------------------------------------------------
1 | import { pubsub } from "../../redis";
2 | import { posts } from "./data";
3 |
4 | const POST_ADDED = "POST_ADDED";
5 |
6 | export const resolvers = {
7 | Author: {
8 | posts(author, args, context, info) {
9 | return posts.filter(post => post.authorId === author.id);
10 | }
11 | },
12 |
13 | Post: {
14 | author(post) {
15 | return { __typename: "Author", id: post.authorID };
16 | }
17 | },
18 |
19 | Query: {
20 | post(root, { id }, context, info) {
21 | return posts.find(post => post.id === parseInt(id));
22 | },
23 | posts(root, args, context, info) {
24 | return posts;
25 | }
26 | },
27 |
28 | Mutation: {
29 | addPost(root, args, context, info) {
30 | const postID = posts.length + 1;
31 | const post = {
32 | ...args,
33 | id: postID,
34 | publishedAt: new Date().toISOString()
35 | };
36 |
37 | // Publish to `POST_ADDED` in the shared Redis instance
38 | pubsub.publish(POST_ADDED, { postAdded: post });
39 | posts.push(post);
40 | return post;
41 | }
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/example/gateway-server/src/services/posts/typeDefs.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-server";
2 |
3 | export const typeDefs = gql`
4 | type Post {
5 | id: ID!
6 | author: Author!
7 | content: String!
8 | publishedAt: String!
9 | title: String!
10 | }
11 |
12 | extend type Author @key(fields: "id") {
13 | id: ID! @external
14 | posts: [Post]
15 | }
16 |
17 | extend type Query {
18 | post(id: ID!): Post
19 | posts: [Post]
20 | }
21 |
22 | extend type Mutation {
23 | addPost(authorID: ID!, content: String, title: String): Post
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/example/react-app/.env.sample:
--------------------------------------------------------------------------------
1 | REACT_APP_GATEWAY_API_URL=http://localhost:4000/graphql
2 | REACT_APP_SUBSCRIPTIONS_API_URL=ws://localhost:5000/graphql
--------------------------------------------------------------------------------
/example/react-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14-alpine
2 |
3 | WORKDIR /usr/src/app
4 |
5 | COPY package*.json ./
6 |
7 | RUN npm install --no-optional && npm cache clean --force
8 |
9 | COPY . .
10 |
--------------------------------------------------------------------------------
/example/react-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.5.10",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/react": "^9.5.0",
9 | "@testing-library/user-event": "^7.2.1",
10 | "graphql": "^15.5.0",
11 | "graphql-ws": "^5.6.2",
12 | "moment": "^2.27.0",
13 | "react": "^16.13.1",
14 | "react-dom": "^16.13.1",
15 | "react-router-dom": "^5.2.0",
16 | "react-scripts": "3.4.3"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": "react-app"
26 | },
27 | "browserslist": {
28 | "production": [
29 | ">0.2%",
30 | "not dead",
31 | "not op_mini all"
32 | ],
33 | "development": [
34 | "last 1 chrome version",
35 | "last 1 firefox version",
36 | "last 1 safari version"
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/example/react-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apollosolutions/federation-subscription-tools/dce70a4f0be106f51fb28f8cf8bf93f545e19c73/example/react-app/public/favicon.ico
--------------------------------------------------------------------------------
/example/react-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Real-time Federation Demo
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/example/react-app/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apollosolutions/federation-subscription-tools/dce70a4f0be106f51fb28f8cf8bf93f545e19c73/example/react-app/public/logo192.png
--------------------------------------------------------------------------------
/example/react-app/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apollosolutions/federation-subscription-tools/dce70a4f0be106f51fb28f8cf8bf93f545e19c73/example/react-app/public/logo512.png
--------------------------------------------------------------------------------
/example/react-app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/example/react-app/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/example/react-app/src/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 1rem 2rem;
3 | }
4 |
--------------------------------------------------------------------------------
/example/react-app/src/App.js:
--------------------------------------------------------------------------------
1 | import { ApolloProvider } from "@apollo/client";
2 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
3 | import React from "react";
4 |
5 | import AddPost from "./pages/AddPost";
6 | import client from "./graphql/apollo";
7 | import Home from "./pages/Home";
8 |
9 | import "./App.css";
10 |
11 | function App() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default App;
26 |
--------------------------------------------------------------------------------
/example/react-app/src/graphql/apollo.js:
--------------------------------------------------------------------------------
1 | import { ApolloClient, HttpLink, InMemoryCache, split } from "@apollo/client";
2 | import { createClient } from "graphql-ws";
3 | import { getMainDefinition } from "@apollo/client/utilities";
4 | import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
5 |
6 | const wsLink = new GraphQLWsLink(
7 | createClient({
8 | url: process.env.REACT_APP_SUBSCRIPTIONS_API_URL,
9 | connectionParams: () => {
10 | // simulate an auth token sent from the client over the WS connection
11 | const token = "some-token";
12 | return { ...(token && { token }) };
13 | }
14 | })
15 | );
16 |
17 | const httpLink = new HttpLink({
18 | uri: process.env.REACT_APP_GATEWAY_API_URL
19 | });
20 |
21 | const link = split(
22 | ({ query }) => {
23 | const definition = getMainDefinition(query);
24 | return (
25 | definition.kind === "OperationDefinition" &&
26 | definition.operation === "subscription"
27 | );
28 | },
29 | wsLink,
30 | httpLink
31 | );
32 |
33 | const client = new ApolloClient({
34 | cache: new InMemoryCache(),
35 | link
36 | });
37 |
38 | export default client;
39 |
--------------------------------------------------------------------------------
/example/react-app/src/graphql/fragments.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | export const PostFields = gql`
4 | fragment PostFields on Post {
5 | author {
6 | id
7 | name
8 | }
9 | content
10 | id
11 | publishedAt
12 | title
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/example/react-app/src/graphql/mutations.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | import { PostFields } from "./fragments";
4 |
5 | export const AddPost = gql`
6 | mutation AddPost($authorID: ID!, $content: String!, $title: String!) {
7 | addPost(authorID: $authorID, content: $content, title: $title) {
8 | ...PostFields
9 | }
10 | }
11 | ${PostFields}
12 | `;
13 |
--------------------------------------------------------------------------------
/example/react-app/src/graphql/queries.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | import { PostFields } from "./fragments";
4 |
5 | export const GetPosts = gql`
6 | query GetPosts {
7 | posts {
8 | ...PostFields
9 | }
10 | }
11 | ${PostFields}
12 | `;
13 |
--------------------------------------------------------------------------------
/example/react-app/src/graphql/subscriptions.js:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client";
2 |
3 | import { PostFields } from "./fragments";
4 |
5 | export const PostAdded = gql`
6 | subscription PostAdded {
7 | postAdded {
8 | ...PostFields
9 | }
10 | }
11 | ${PostFields}
12 | `;
13 |
--------------------------------------------------------------------------------
/example/react-app/src/index.css:
--------------------------------------------------------------------------------
1 | /* Normalize.css
2 | ----------------------------------------------- */
3 |
4 | article,
5 | aside,
6 | details,
7 | figcaption,
8 | figure,
9 | footer,
10 | header,
11 | hgroup,
12 | nav,
13 | section,
14 | summary {
15 | display: block;
16 | }
17 | audio,
18 | canvas,
19 | video {
20 | display: inline-block;
21 | *display: inline;
22 | *zoom: 1;
23 | }
24 | audio:not([controls]) {
25 | display: none;
26 | height: 0;
27 | }
28 | [hidden] {
29 | display: none;
30 | }
31 | html {
32 | font-size: 100%;
33 | -webkit-text-size-adjust: 100%;
34 | -ms-text-size-adjust: 100%;
35 | }
36 | html,
37 | button,
38 | input,
39 | select,
40 | textarea {
41 | font-family: sans-serif;
42 | }
43 | body {
44 | margin: 0;
45 | }
46 | a:focus {
47 | outline: thin dotted;
48 | }
49 | a:active,
50 | a:hover {
51 | outline: 0;
52 | }
53 | h1 {
54 | font-size: 2em;
55 | margin: 0.67em 0;
56 | }
57 | h2 {
58 | font-size: 1.5em;
59 | margin: 0.83em 0;
60 | }
61 | h3 {
62 | font-size: 1.17em;
63 | margin: 1em 0;
64 | }
65 | h4 {
66 | font-size: 1em;
67 | margin: 1.33em 0;
68 | }
69 | h5 {
70 | font-size: 0.83em;
71 | margin: 1.67em 0;
72 | }
73 | h6 {
74 | font-size: 0.75em;
75 | margin: 2.33em 0;
76 | }
77 | abbr[title] {
78 | border-bottom: 1px dotted;
79 | }
80 | b,
81 | strong {
82 | font-weight: bold;
83 | }
84 | blockquote {
85 | margin: 1em 40px;
86 | }
87 | dfn {
88 | font-style: italic;
89 | }
90 | mark {
91 | background: #ff0;
92 | color: #000;
93 | }
94 | p,
95 | pre {
96 | margin: 1em 0;
97 | }
98 | code,
99 | kbd,
100 | pre,
101 | samp {
102 | font-family: monospace, serif;
103 | _font-family: "courier new", monospace;
104 | font-size: 1em;
105 | }
106 | pre {
107 | white-space: pre;
108 | white-space: pre-wrap;
109 | word-wrap: break-word;
110 | }
111 | q {
112 | quotes: none;
113 | }
114 | q:before,
115 | q:after {
116 | content: "";
117 | content: none;
118 | }
119 | small {
120 | font-size: 75%;
121 | }
122 | sub,
123 | sup {
124 | font-size: 75%;
125 | line-height: 0;
126 | position: relative;
127 | vertical-align: baseline;
128 | }
129 | sup {
130 | top: -0.5em;
131 | }
132 | sub {
133 | bottom: -0.25em;
134 | }
135 | dl,
136 | menu,
137 | ol,
138 | ul {
139 | margin: 1em 0;
140 | }
141 | dd {
142 | margin: 0 0 0 40px;
143 | }
144 | menu,
145 | ol,
146 | ul {
147 | padding: 0 0 0 40px;
148 | }
149 | nav ul,
150 | nav ol {
151 | list-style: none;
152 | list-style-image: none;
153 | }
154 | img {
155 | border: 0;
156 | -ms-interpolation-mode: bicubic;
157 | }
158 | svg:not(:root) {
159 | overflow: hidden;
160 | }
161 | figure {
162 | margin: 0;
163 | }
164 | form {
165 | margin: 0;
166 | }
167 | fieldset {
168 | border: 1px solid #c0c0c0;
169 | margin: 0 2px;
170 | padding: 0.35em 0.625em 0.75em;
171 | }
172 | legend {
173 | border: 0;
174 | padding: 0;
175 | white-space: normal;
176 | *margin-left: -7px;
177 | }
178 | button,
179 | input,
180 | select,
181 | textarea {
182 | font-size: 100%;
183 | margin: 0;
184 | vertical-align: baseline;
185 | *vertical-align: middle;
186 | }
187 | button,
188 | input {
189 | line-height: normal;
190 | }
191 | button,
192 | html input[type="button"],
193 | input[type="reset"],
194 | input[type="submit"] {
195 | -webkit-appearance: button;
196 | cursor: pointer;
197 | *overflow: visible;
198 | }
199 | button[disabled],
200 | input[disabled] {
201 | cursor: default;
202 | }
203 | input[type="checkbox"],
204 | input[type="radio"] {
205 | box-sizing: border-box;
206 | padding: 0;
207 | *height: 13px;
208 | *width: 13px;
209 | }
210 | input[type="search"] {
211 | -webkit-appearance: textfield;
212 | -moz-box-sizing: content-box;
213 | -webkit-box-sizing: content-box;
214 | box-sizing: content-box;
215 | }
216 | input[type="search"]::-webkit-search-cancel-button,
217 | input[type="search"]::-webkit-search-decoration {
218 | -webkit-appearance: none;
219 | }
220 | button::-moz-focus-inner,
221 | input::-moz-focus-inner {
222 | border: 0;
223 | padding: 0;
224 | }
225 | textarea {
226 | overflow: auto;
227 | vertical-align: top;
228 | }
229 | table {
230 | border-collapse: collapse;
231 | border-spacing: 0;
232 | }
233 |
--------------------------------------------------------------------------------
/example/react-app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import App from "./App";
5 |
6 | import "./index.css";
7 |
8 | ReactDOM.render(
9 |
10 |
11 | ,
12 | document.getElementById("root")
13 | );
14 |
--------------------------------------------------------------------------------
/example/react-app/src/pages/AddPost.js:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { useMutation } from "@apollo/client";
3 | import React, { useState } from "react";
4 |
5 | import { AddPost as AddPostMutation } from "../graphql/mutations";
6 |
7 | function AddPost() {
8 | const [content, setContent] = useState("");
9 | const [title, setTitle] = useState("");
10 | const [completedMessage, setCompletedMessage] = useState("");
11 |
12 | const [addPost] = useMutation(AddPostMutation, {
13 | onCompleted() {
14 | setContent("");
15 | setTitle("");
16 | setCompletedMessage("Your post was published!");
17 | }
18 | });
19 |
20 | return (
21 |
22 |
27 |
Add a New Post
28 |
65 |
66 | );
67 | }
68 |
69 | export default AddPost;
70 |
--------------------------------------------------------------------------------
/example/react-app/src/pages/Home.js:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { useQuery } from "@apollo/client";
3 | import moment from "moment";
4 | import React, { useEffect } from "react";
5 |
6 | import { GetPosts } from "../graphql/queries";
7 | import { PostAdded } from "../graphql/subscriptions";
8 |
9 | function Home() {
10 | const { data, loading, subscribeToMore } = useQuery(GetPosts);
11 |
12 | useEffect(() => {
13 | const unsubscribe = subscribeToMore({
14 | document: PostAdded,
15 | updateQuery: (prev, { subscriptionData }) => {
16 | if (!subscriptionData.data) {
17 | return prev;
18 | }
19 | return {
20 | posts: [...prev.posts, subscriptionData.data.postAdded]
21 | };
22 | }
23 | });
24 | return () => unsubscribe();
25 | }, [subscribeToMore]);
26 |
27 | if (loading) {
28 | return Loading...
;
29 | }
30 |
31 | return (
32 |
33 |
38 | {data?.posts?.length ? (
39 | [...data.posts]
40 | .filter(post => post !== null)
41 | .sort((a, b) =>
42 | Date.parse(b.publishedAt) > Date.parse(a.publishedAt) ? 1 : -1
43 | )
44 | .map(({ author, content, id, title, publishedAt }) => (
45 |
46 | {title}
47 | Post ID: {id}
48 | By {author.name}
49 | {moment(publishedAt).format("h:mm A MMM D, YYYY")}
50 | {content}
51 |
52 | ))
53 | ) : (
54 |
No posts available!
55 | )}
56 |
57 | );
58 | }
59 |
60 | export default Home;
61 |
--------------------------------------------------------------------------------
/example/subscriptions-server/.env.sample:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 |
3 | APOLLO_KEY=
4 | APOLLO_GRAPH_REF=
5 | APOLLO_GRAPH_VARIANT=current
6 | GATEWAY_ENDPOINT=http://gateway_server:4000/graphql
7 |
8 | AUTHORS_SERVICE_URL=http://gateway_server:4001
9 | POSTS_SERVICE_URL=http://gateway_server:4002
10 |
11 | REDIS_HOST_ADDRESS=redis
12 | REDIS_PORT=6379
13 |
14 | SUBSCRIPTIONS_SERVICE_PORT=5000
15 |
--------------------------------------------------------------------------------
/example/subscriptions-server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14.16-alpine
2 |
3 | ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
4 | ENV PATH=$PATH:/home/node/.npm-global/bin
5 |
6 | RUN mkdir -p /home/node/app/node_modules \
7 | /home/node/federation-subscription-tools/{dist,node_modules} && \
8 | chown -R node:node /home/node/app && \
9 | chown -R node:node /home/node/federation-subscription-tools
10 |
11 | USER node
12 |
13 | WORKDIR /home/node/federation-subscription-tools
14 |
15 | COPY package*.json tsconfig.json ./
16 |
17 | RUN npm install -g graphql@15.5.0 && npm install --no-optional && \
18 | npm cache clean --force && npm link && \
19 | npm link graphql
20 |
21 | COPY --chown=node:node . .
22 |
23 | RUN npm run compile
24 |
25 | WORKDIR /home/node/app
26 |
27 | COPY example/subscriptions-server/package*.json ./
28 |
29 | RUN npm install --no-optional && npm cache clean --force && \
30 | npm link federation-subscription-tools && npm link graphql
31 |
32 | COPY --chown=node:node example/subscriptions-server .
33 |
--------------------------------------------------------------------------------
/example/subscriptions-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "subscriptions-server",
3 | "version": "1.0.0",
4 | "description": "A demonstration subscriptions service for a federated data graph.",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "server": "nodemon -r esm -r dotenv/config ./src/index.js"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@apollo/gateway": "^0.46.0",
14 | "dotenv": "^8.2.0",
15 | "esm": "^3.2.25",
16 | "graphql-redis-subscriptions": "^2.3.1",
17 | "graphql-tag": "^2.11.0",
18 | "graphql-ws": "^5.6.2",
19 | "ioredis": "^4.19.4",
20 | "nodemon": "^2.0.7",
21 | "ws": "^7.4.4"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/example/subscriptions-server/src/datasources/LIveBlogDataSource/index.js:
--------------------------------------------------------------------------------
1 | import { GatewayDataSource } from "federation-subscription-tools";
2 | import gql from "graphql-tag";
3 |
4 | export class LiveBlogDataSource extends GatewayDataSource {
5 | constructor(gatewayUrl) {
6 | super(gatewayUrl);
7 | }
8 |
9 | willSendRequest(request) {
10 | if (!request.headers) {
11 | request.headers = {};
12 | }
13 |
14 | request.headers["apollographql-client-name"] = "Subscriptions Service";
15 | request.headers["apollographql-client-version"] = "0.1.0";
16 |
17 | // Forwards the encoded token extracted from the `connectionParams` with
18 | // the request to the gateway
19 | request.headers.authorization = `Bearer ${this.context.token}`;
20 | }
21 |
22 | async fetchAndMergeNonPayloadPostData(postID, payload, info) {
23 | const selections = this.buildNonPayloadSelections(payload, info);
24 | const payloadData = Object.values(payload)[0];
25 |
26 | if (!selections) {
27 | return payloadData;
28 | }
29 |
30 | const Subscription_GetPost = gql`
31 | query Subscription_GetPost($id: ID!) {
32 | post(id: $id) {
33 | ${selections}
34 | }
35 | }
36 | `;
37 |
38 | try {
39 | const response = await this.query(Subscription_GetPost, {
40 | variables: { id: postID }
41 | });
42 | return this.mergeFieldData(payloadData, response.data.post);
43 | } catch (error) {
44 | console.error(error);
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/example/subscriptions-server/src/index.js:
--------------------------------------------------------------------------------
1 | import http from "http";
2 |
3 | import {
4 | addGatewayDataSourceToSubscriptionContext,
5 | getGatewayApolloConfig,
6 | makeSubscriptionSchema
7 | } from "federation-subscription-tools";
8 | import { ApolloGateway } from "@apollo/gateway";
9 | import {
10 | execute,
11 | getOperationAST,
12 | GraphQLError,
13 | parse,
14 | subscribe,
15 | validate
16 | } from "graphql";
17 | import { useServer } from "graphql-ws/lib/use/ws";
18 | import ws from "ws";
19 |
20 | import { LiveBlogDataSource } from "./datasources/LiveBlogDataSource";
21 | import { resolvers } from "./resolvers";
22 | import { typeDefs } from "./typeDefs";
23 |
24 | (async () => {
25 | const apolloKey = process.env.APOLLO_KEY;
26 | const graphRef = process.env.APOLLO_GRAPH_REF;
27 | const gatewayEndpoint = process.env.GATEWAY_ENDPOINT;
28 | const isProd = process.env.NODE_ENV === "production";
29 | const port = process.env.SUBSCRIPTIONS_SERVICE_PORT;
30 |
31 | let apolloConfig;
32 | let schema;
33 |
34 | /**
35 | * Instantiate an instance of the Gateway
36 | */
37 | let gatewayOptions = {
38 | debug: isProd ? false : true
39 | };
40 |
41 | if (!apolloKey) {
42 | gatewayOptions.serviceList = [
43 | { name: "authors", url: process.env.AUTHORS_SERVICE_URL },
44 | { name: "posts", url: process.env.POSTS_SERVICE_URL }
45 | ];
46 | }
47 |
48 | const gateway = new ApolloGateway(gatewayOptions);
49 |
50 | gateway.onSchemaLoadOrUpdate(schemaContext => {
51 | schema = makeSubscriptionSchema({
52 | gatewaySchema: schemaContext.apiSchema,
53 | typeDefs,
54 | resolvers
55 | });
56 | });
57 |
58 | if (apolloKey) {
59 | apolloConfig = getGatewayApolloConfig(apolloKey, graphRef);
60 | } else {
61 | // For unmanaged federation, we must set a poll interval to query the
62 | // subgraph services for their schemas to detect a schema change. Polling
63 | // the running endpoint for these SDLs is fairly blunt approach, so in
64 | // production, a more computationally efficient approach would be
65 | // preferable (or managed federation).
66 | gateway.experimental_pollInterval = 36000;
67 | }
68 |
69 | await gateway.load({ ...(apolloConfig && { apollo: apolloConfig }) });
70 |
71 | /**
72 | * Expose GraphQL endpoint via WebSockets (for subscription operations only)
73 | */
74 | const httpServer = http.createServer(function weServeSocketsOnly(_, res) {
75 | res.writeHead(404);
76 | res.end();
77 | });
78 |
79 | const wsServer = new ws.Server({
80 | server: httpServer,
81 | path: "/graphql"
82 | });
83 |
84 | useServer(
85 | {
86 | execute,
87 | subscribe,
88 | context: ctx => {
89 | // If a token was sent for auth purposes, retrieve it here
90 | const { token } = ctx.connectionParams;
91 |
92 | // Instantiate and initialize the GatewayDataSource subclass
93 | // (data source methods will be accessible on the `gatewayApi` key)
94 | const liveBlogDataSource = new LiveBlogDataSource(gatewayEndpoint);
95 | const dataSourceContext = addGatewayDataSourceToSubscriptionContext(
96 | ctx,
97 | liveBlogDataSource
98 | );
99 |
100 | // Return the complete context for the request
101 | return { token: token || null, ...dataSourceContext };
102 | },
103 | onSubscribe: (_ctx, msg) => {
104 | // Construct the execution arguments
105 | const args = {
106 | schema,
107 | operationName: msg.payload.operationName,
108 | document: parse(msg.payload.query),
109 | variableValues: msg.payload.variables
110 | };
111 |
112 | const operationAST = getOperationAST(args.document, args.operationName);
113 |
114 | // Stops the subscription and sends an error message
115 | if (!operationAST) {
116 | return [new GraphQLError("Unable to identify operation")];
117 | }
118 |
119 | // Handle mutation and query requests
120 | if (operationAST.operation !== "subscription") {
121 | return [
122 | new GraphQLError("Only subscription operations are supported")
123 | ];
124 | }
125 |
126 | // Validate the operation document
127 | const errors = validate(args.schema, args.document);
128 |
129 | if (errors.length > 0) {
130 | return errors;
131 | }
132 |
133 | // Ready execution arguments
134 | return args;
135 | }
136 | },
137 | wsServer
138 | );
139 |
140 | httpServer.listen({ port }, () => {
141 | console.log(
142 | `🚀 Subscriptions ready at ws://localhost:${port}${wsServer.options.path}`
143 | );
144 | });
145 | })();
146 |
--------------------------------------------------------------------------------
/example/subscriptions-server/src/redis/index.js:
--------------------------------------------------------------------------------
1 | import { RedisPubSub } from "graphql-redis-subscriptions";
2 | import Redis from "ioredis";
3 |
4 | export const redis = new Redis(
5 | process.env.REDIS_PORT,
6 | process.env.REDIS_HOST_ADDRESS
7 | );
8 |
9 | export const pubsub = new RedisPubSub({
10 | publisher: redis,
11 | subscriber: redis
12 | });
13 |
--------------------------------------------------------------------------------
/example/subscriptions-server/src/resolvers.js:
--------------------------------------------------------------------------------
1 | import { pubsub } from "./redis";
2 |
3 | const POST_ADDED = "POST_ADDED";
4 |
5 | export const resolvers = {
6 | Subscription: {
7 | postAdded: {
8 | // The client may request `Post` fields that are not resolvable from the
9 | // payload data that was included in `pubsub.publish()`, so we must
10 | // provide some mechanism to fetch those additional fields when requested
11 | resolve(payload, args, { dataSources: { gatewayApi } }, info) {
12 | return gatewayApi.fetchAndMergeNonPayloadPostData(
13 | payload.postAdded.id,
14 | payload,
15 | info
16 | );
17 | },
18 | subscribe(_, args) {
19 | // Subscribe to `POST_ADDED` in the shared Redis instance
20 | return pubsub.asyncIterator([POST_ADDED]);
21 | }
22 | }
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/example/subscriptions-server/src/typeDefs.js:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag";
2 |
3 | export const typeDefs = gql`
4 | type Subscription {
5 | postAdded: Post
6 | }
7 | `;
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "federation-subscription-tools",
3 | "version": "0.2.0",
4 | "description": "A set of demonstration utilities to facilitate GraphQL subscription usage alongside a federated data graph.",
5 | "main": "dist/index.js",
6 | "types": "dist/types.d.ts",
7 | "keywords": [],
8 | "author": "",
9 | "license": "ISC",
10 | "scripts": {
11 | "compile": "tsc --build tsconfig.json",
12 | "compile:clean": "tsc --build tsconfig.json --clean",
13 | "watch": "tsc --build tsconfig.json --watch"
14 | },
15 | "dependencies": {
16 | "@apollo/client": "^3.5.8",
17 | "apollo-datasource": "^0.7.3",
18 | "apollo-server": "^3.6.2",
19 | "graphql": "^15.5.0",
20 | "graphql-parse-resolve-info": "4.12.0",
21 | "graphql-tools": "^8.2.0",
22 | "lodash": "^4.17.21",
23 | "node-fetch": "^2.6.1"
24 | },
25 | "devDependencies": {
26 | "@types/lodash": "^4.14.178",
27 | "@types/node-fetch": "^2.6.1",
28 | "typescript": "^4.5.5"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/datasources/GatewayDataSource/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ApolloError,
3 | AuthenticationError,
4 | ForbiddenError
5 | } from "apollo-server";
6 | import { createHttpLink, execute, from, toPromise } from "@apollo/client/core";
7 | import { DataSource, DataSourceConfig } from "apollo-datasource";
8 | import { DocumentNode } from "graphql";
9 | import { GraphQLOptions } from "apollo-server";
10 | import { onError } from "@apollo/client/link/error";
11 | import {
12 | FieldsByTypeName,
13 | parseResolveInfo,
14 | ResolveTree
15 | } from "graphql-parse-resolve-info";
16 | import { setContext } from "@apollo/client/link/context";
17 | import fetch from "node-fetch";
18 | import merge from "lodash/merge";
19 | export class GatewayDataSource extends DataSource {
20 | private gatewayURL;
21 | context!: TContext;
22 |
23 | constructor(gatewayURL: string) {
24 | super();
25 | this.gatewayURL = gatewayURL;
26 | }
27 |
28 | override initialize(config: DataSourceConfig): void {
29 | this.context = config.context;
30 | }
31 |
32 | // Creates an Apollo Client to query data from the gateway
33 | composeLinks() {
34 | const uri = this.resolveUri();
35 | return from([
36 | this.onErrorLink(),
37 | this.onRequestLink(),
38 | /* @ts-ignore-next-line */
39 | createHttpLink({ fetch, uri })
40 | ]);
41 | }
42 | didEncounterError(error: any) {
43 | const status = error.statusCode ? error.statusCode : null;
44 | const message = error.bodyText ? error.bodyText : null;
45 | let apolloError: ApolloError;
46 | switch (status) {
47 | case 401:
48 | apolloError = new AuthenticationError(message);
49 | break;
50 | case 403:
51 | apolloError = new ForbiddenError(message);
52 | break;
53 | case 502:
54 | apolloError = new ApolloError("Bad Gateway", status);
55 | break;
56 | default:
57 | apolloError = new ApolloError(message, status);
58 | }
59 | throw apolloError;
60 | }
61 | async query(query: DocumentNode, options: GraphQLOptions) {
62 | const link = this.composeLinks();
63 | try {
64 | const response = await toPromise(execute(link, { query, ...options }));
65 | return response;
66 | } catch (error) {
67 | this.didEncounterError(error);
68 | }
69 | }
70 | resolveUri() {
71 | const gatewayURL = this.gatewayURL;
72 | if (!gatewayURL) {
73 | throw new ApolloError(
74 | "Cannot make request to GraphQL API, missing gatewayURL"
75 | );
76 | }
77 | return gatewayURL;
78 | }
79 | onRequestLink() {
80 | return setContext(request => {
81 | if (typeof (this as any).willSendRequest === "function") {
82 | (this as any).willSendRequest(request);
83 | }
84 | return request;
85 | });
86 | }
87 | onErrorLink() {
88 | return onError(({ graphQLErrors, networkError }) => {
89 | if (graphQLErrors) {
90 | graphQLErrors.map(graphqlError =>
91 | console.error(`[GraphQL error]: ${graphqlError.message}`)
92 | );
93 | }
94 | if (networkError) {
95 | console.log(`[Network Error]: ${networkError}`);
96 | }
97 | });
98 | }
99 | // Utils that support diffing payload fields with operation field selections
100 | addDelimiter(a: string, b: string) {
101 | return a ? `${a}.${b}` : b;
102 | }
103 | isObject(val: any) {
104 | return typeof val === "object" && !Array.isArray(val) && val !== null;
105 | }
106 | isFieldObject(obj: any) {
107 | return (
108 | this.isObject(obj) &&
109 | obj.hasOwnProperty("args") &&
110 | obj.hasOwnProperty("alias") &&
111 | obj.hasOwnProperty("name")
112 | );
113 | }
114 | fieldPathsAsStrings(obj: { [key: string]: any }) {
115 | const paths = (obj = {}, head = ""): string[] => {
116 | return Object.entries(obj).reduce(
117 | (acc: string[], [key, value]: [string, any]) => {
118 | let fullPath = this.addDelimiter(head, key);
119 | return this.isObject(value)
120 | ? acc.concat(key, paths(value, fullPath))
121 | : acc.concat(fullPath);
122 | },
123 | []
124 | );
125 | };
126 | return paths(obj);
127 | }
128 | fieldPathsAsMapFromResolveInfo(resolveInfo: FieldsByTypeName | ResolveTree) {
129 | // Construct entries-like array of field paths their corresponding name, alias, and args
130 | const paths = (obj = {}, head = ""): [string, any][] => {
131 | return Object.entries(obj).reduce(
132 | (acc: [string, any][], [key, value]: [string, any]) => {
133 | let fullPath = this.addDelimiter(head, key);
134 | if (
135 | this.isFieldObject(value) &&
136 | Object.keys(value.fieldsByTypeName).length === 0
137 | ) {
138 | const { alias, args, name } = value;
139 | return acc.concat([[fullPath, { alias, args, name }]]);
140 | } else if (this.isFieldObject(value)) {
141 | const { alias, args, name } = value;
142 | return acc.concat(
143 | [[fullPath, { alias, args, name }]],
144 | paths(value, fullPath)
145 | );
146 | } else if (this.isObject(value)) {
147 | return acc.concat(paths(value, fullPath));
148 | }
149 | return acc.concat([[fullPath, null]]);
150 | },
151 | []
152 | );
153 | };
154 | const resolveInfoFields = paths(resolveInfo);
155 | // Filter field paths and construct an object from entries
156 | return Object.fromEntries(
157 | resolveInfoFields
158 | .filter(([_, options]) => options)
159 | .map(([path, { alias, args, name }]) => {
160 | const pathParts = path.split(".");
161 | const noTypeNames = pathParts.forEach((part, i) => {
162 | if (pathParts[i - 1] === "fieldsByTypeName") {
163 | pathParts.splice(i - 1, 2);
164 | }
165 | });
166 | let keptOptions = {
167 | ...(name !== alias && { alias }),
168 | ...(Object.keys(args).length && { args })
169 | };
170 | return [
171 | pathParts.join("."),
172 | Object.keys(keptOptions).length ? keptOptions : null
173 | ];
174 | })
175 | );
176 | }
177 | buildSelection(selection, pathString, pathParts, fieldPathMap, index) {
178 | let formattedSelection = selection;
179 | let options;
180 | let parentOptions;
181 | if (pathParts.length > 1 && index < pathParts.length - 1) {
182 | const parentPathString = pathParts.slice(0, index + 1).join(".");
183 | parentOptions = fieldPathMap[parentPathString];
184 | } else {
185 | options = fieldPathMap[pathString];
186 | }
187 | if (parentOptions) {
188 | if (parentOptions.alias) {
189 | formattedSelection = `${parentOptions.alias}: ${formattedSelection}`;
190 | }
191 | if (parentOptions.args) {
192 | // Stringify object, remove outer brackets, then remove double quotes before colon
193 | const formattedArgs = JSON.stringify(parentOptions.args)
194 | .slice(1, -1)
195 | .replace(/"([^"]+)":/g, "$1:");
196 | formattedSelection = `${formattedSelection}(${formattedArgs})`;
197 | }
198 | } else if (options) {
199 | if (options.alias) {
200 | formattedSelection = `${options.alias}: ${formattedSelection}`;
201 | }
202 | if (options.args) {
203 | const formattedArgs = JSON.stringify(options.args)
204 | .slice(1, -1)
205 | .replace(/"([^"]+)":/g, "$1:");
206 | formattedSelection = `${formattedSelection}(${formattedArgs})`;
207 | }
208 | }
209 | return formattedSelection;
210 | }
211 | // This function checks the fields that were included in the payload against
212 | // the fields that were requested in the subscription operation from the
213 | // client and then builds up a string of field selections to fetch on the
214 | // subscription return type from the gateway to supplement the payload data and
215 | // in order to fully resolve the operation
216 | buildNonPayloadSelections(payload, info) {
217 | const resolveInfo = parseResolveInfo(info);
218 | const payloadFieldPaths = this.fieldPathsAsStrings(
219 | payload[resolveInfo?.name as string]
220 | );
221 | const operationFields = resolveInfo
222 | ? this.fieldPathsAsMapFromResolveInfo(resolveInfo)
223 | : {};
224 | const operationFieldPaths = Object.keys(operationFields);
225 | return operationFieldPaths
226 | .filter(path => !payloadFieldPaths.includes(path))
227 | .reduce((acc, curr, i, arr) => {
228 | const pathParts = curr.split(".");
229 | let selections = "";
230 | pathParts.forEach((part, j) => {
231 | // Is this a top-level field that will be accounted for when nested
232 | // children are added to the selection?
233 | const hasSubFields = !!arr.slice(i + 1).find(item => {
234 | const itemParts = item.split(".");
235 | itemParts.pop();
236 | const rejoinedItem = itemParts.join(".");
237 | return rejoinedItem === curr;
238 | });
239 | if (hasSubFields) {
240 | return;
241 | }
242 | const sel = this.buildSelection(
243 | part,
244 | curr,
245 | pathParts,
246 | operationFields,
247 | j
248 | );
249 | if (j === 0) {
250 | selections = `${sel} `;
251 | } else if (j === 1) {
252 | selections = `${selections}{ ${sel} } `;
253 | } else {
254 | const char = -(j - 2) - j;
255 | selections = `${selections.slice(
256 | 0,
257 | char
258 | )}{ ${sel} } ${selections.slice(char)}`;
259 | }
260 | });
261 | return acc + selections;
262 | }, "");
263 | }
264 | // Deep merges the values of payload fields with non-payload fields to
265 | // compose the overall response from the subscription field resolver
266 | mergeFieldData(payloadFieldData, nonPayloadFieldData) {
267 | return merge(payloadFieldData, nonPayloadFieldData);
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { addGatewayDataSourceToSubscriptionContext } from "./utils/subscriptions";
2 | export { GatewayDataSource } from "./datasources/GatewayDataSource";
3 | export { getGatewayApolloConfig, makeSubscriptionSchema } from "./utils/schema";
4 |
--------------------------------------------------------------------------------
/src/utils/schema.ts:
--------------------------------------------------------------------------------
1 | import { createHash } from "crypto";
2 |
3 | import { makeExecutableSchema } from "graphql-tools";
4 | import { gql } from "graphql-tag";
5 | import { DocumentNode, printSchema } from "graphql";
6 |
7 | export function getGatewayApolloConfig(key: string, graphRef: string) {
8 | return {
9 | key,
10 | graphRef,
11 | keyHash: createHash("sha512").update(key).digest("hex")
12 | };
13 | }
14 |
15 | export function makeSubscriptionSchema({
16 | gatewaySchema,
17 | typeDefs,
18 | resolvers
19 | }: any) {
20 | if (!typeDefs || !resolvers) {
21 | throw new Error(
22 | "Both `typeDefs` and `resolvers` are required to make the executable subscriptions schema."
23 | );
24 | }
25 |
26 | const gatewayTypeDefs = gatewaySchema
27 | ? gql(printSchema(gatewaySchema))
28 | : undefined;
29 |
30 | return makeExecutableSchema({
31 | typeDefs: [
32 | ...((gatewayTypeDefs && [gatewayTypeDefs]) as DocumentNode[]),
33 | typeDefs
34 | ],
35 | resolvers
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/subscriptions.ts:
--------------------------------------------------------------------------------
1 | export function addGatewayDataSourceToSubscriptionContext(
2 | context,
3 | gatewayDataSource
4 | ) {
5 | gatewayDataSource.initialize({ context, cache: undefined });
6 | return { dataSources: { gatewayApi: gatewayDataSource } };
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "./src",
4 | "outDir": "./dist",
5 | "composite": true,
6 | "target": "esnext",
7 | "module": "commonjs",
8 | "moduleResolution": "node",
9 | "esModuleInterop": true,
10 | "sourceMap": true,
11 | "declaration": true,
12 | "declarationMap": true,
13 | "removeComments": true,
14 | "strict": true,
15 | "noImplicitAny": false,
16 | "noImplicitReturns": false,
17 | "noFallthroughCasesInSwitch": true,
18 | "noUnusedParameters": false,
19 | "noUnusedLocals": false,
20 | "forceConsistentCasingInFileNames": true
21 | },
22 | "include": ["./src/**/*.ts"],
23 | "exclude": ["node_modules", "**/example/*"]
24 | }
25 |
--------------------------------------------------------------------------------