Skip to content

Commit

Permalink
Merge pull request #1578 from MichalLytek/subscriptions-upgrade
Browse files Browse the repository at this point in the history
Migrate subscriptions engine to graphql-yoga
  • Loading branch information
MichalLytek committed Jan 3, 2024
2 parents 0ad04ab + f5aa0d0 commit a088c25
Show file tree
Hide file tree
Showing 44 changed files with 833 additions and 12,492 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@

- **Breaking Change**: expose shim as a package entry point `type-graphql/shim` (and `/node_modules/type-graphql/build/typings/shim.ts`)
- **Breaking Change**: update `graphql-js` peer dependency to `^16.8.1`
- **Breaking Change**: use `@graphql-yoga` instead of `graphql-subscriptions` as the subscriptions engine
- **Breaking Change**: require providing `PubSub` implementation into `buildSchema` option when using `@Subscription`
- **Breaking Change**: remove `@PubSub` in favor of directly importing created `PubSub` implementation
- **Breaking Change**: remove `Publisher` and `PubSubEngine` types
- **Breaking Change**: rename interface `ResolverFilterData` into `SubscriptionHandlerData` and `ResolverTopicData` into `SubscribeResolverData`
- support defining directives on `@Field` of `@Args`
- support defining directives on inline `@Arg`
- allow passing custom validation function as `validateFn` option of `@Arg` and `@Args` decorators
- add support for dynamic topic id function in `@Subscription` decorator option

## v2.0.0-beta.3

Expand Down
69 changes: 69 additions & 0 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: Migration Guide
sidebar_label: v1.x -> v2.0
---

> This chapter contains migration guide, that will help you upgrade your codebase from using old Typegraphql `v1.x` into the newest `v2.0` release.
>
> If you just started using TypeGraphQL and you have `v2.0` installed, you can skip this chapter and go straight into the "Advanced guides" section.
## Subscriptions

The new `v2.0` release contains a bunch of breaking changes related to the GraphQL subscriptions feature.

In previous releases, this feature was build upon the [`graphql-subscriptions`](https://github.com/apollographql/graphql-subscriptions) package and it's `PubSub` system.
However, it's become unmaintained in the last years and some alternatives has been developed in the meantime.

So since `v2.0`, TypeGraphQL relies on the new [`@graphql-yoga/subscriptions`](https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions) package which is built on top of latest ECMAScript features. It also has own `PubSub` implementation which works in a similar fashion, but has a slightly different API.

We did out best to hide under the hood all the differences between the APIs of those packages, but some breaking changes had to occurred in the TypeGraphQL API.

### The `pubSub` option of `buildSchema`

It is now required to pass the `PubSub` instance as the config option of `buildSchema` function.
Previously, you could omit it and rely on the default one created by TypeGraphQL.

The reason for this change is that `@graphql-yoga/subscriptions` package allows to create a type-safe `PubSub` instance via the [generic `createPubSub` function](https://the-guild.dev/graphql/yoga-server/v2/features/subscriptions#topics), so you can add type info about the topics and params required while using `.publish()` method.

Simple example of the new API:

```ts
import { buildSchema } from "type-graphql";
import { createPubSub } from "@graphql-yoga/subscriptions";

export const pubSub = createPubSub<{
NOTIFICATIONS: [NotificationPayload];
DYNAMIC_ID_TOPIC: [number, NotificationPayload];
}>();

const schema = await buildSchema({
resolver,
pubSub,
});
```

Be aware that you can use any `PubSub` system you want, not only the `graphql-yoga` one.
The only requirement is to comply with the exported `PubSub` interface - having proper `.subscribe()` and `.publish()` methods.

### No `@PubSub` decorator

The consequence of not having automatically created, default `PubSub` instance, is that you don't need access to the internally-created `PubSub` instance.

Hence, the `@PubSub` decorator was removed - please use dependency injection system if you don't want to have a hardcoded import. The corresponding `Publisher` type was also removed as it was not needed anymore.

### Renamed and removed types

There was some inconsistency in naming of the decorator option functions argument types, which was unified in the `v2.0` release.

If you reference those types in your code (`filter` or `subscribe` decorator option functions), make sure you update your type annotation and imports to the new name.

- `ResolverFilterData` -> `SubscriptionHandlerData`
- `ResolverTopicData` -> `SubscribeResolverData`

Also, apart from the `Publisher` type mentioned above, the `PubSubEngine` type has been removed and is no longer exported from the package.

### Topic with Dynamic ID

As TypeGraphQL uses `@graphql-yoga/subscriptions` under the hood, it also aims to use its features. And one of the extension to the old `PubSub` system used in `v1.x` is ability to not only use dynamic topics but a topic with a dynamic id.

You can read more about this new feature in [subscription docs](./subscriptions.md#topic-with-dynamic-id).
97 changes: 60 additions & 37 deletions docs/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: Subscriptions

GraphQL can be used to perform reads with queries and writes with mutations.
However, oftentimes clients want to get updates pushed to them from the server when data they care about changes.
To support that, GraphQL has a third operation: subscription. TypeGraphQL of course has great support for subscription, using the [graphql-subscriptions](https://github.com/apollographql/graphql-subscriptions) package created by [Apollo GraphQL](https://www.apollographql.com).
To support that, GraphQL has a third operation: subscription. TypeGraphQL of course has great support for subscription, using the [`@graphql-yoga/subscriptions`](https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions) package created by [`The Guild`](https://the-guild.dev/).

## Creating Subscriptions

Expand All @@ -30,7 +30,7 @@ class SampleResolver {
@Subscription({
topics: "NOTIFICATIONS", // Single topic
topics: ["NOTIFICATIONS", "ERRORS"] // Or topics array
topics: ({ args, payload, context }) => args.topic // Or dynamic topic function
topics: ({ args, context }) => args.topic // Or dynamic topic function
})
newNotification(): Notification {
// ...
Expand All @@ -56,7 +56,7 @@ class SampleResolver {

We can also provide a custom subscription logic which might be useful, e.g. if we want to use the Prisma subscription functionality or something similar.

All we need to do is to use the `subscribe` option which should be a function that returns an `AsyncIterator`. Example using Prisma client subscription feature:
All we need to do is to use the `subscribe` option which should be a function that returns an `AsyncIterable` or a `Promise<AsyncIterable>`. Example using Prisma 1 subscription feature:

```ts
class SampleResolver {
Expand All @@ -72,7 +72,7 @@ class SampleResolver {
}
```

> Be aware that we can't mix the `subscribe` option with the `topics` and `filter` options. If the filtering is still needed, we can use the [`withFilter` function](https://github.com/apollographql/graphql-subscriptions#filters) from the `graphql-subscriptions` package.
> Be aware that we can't mix the `subscribe` option with the `topics` and `filter` options. If the filtering is still needed, we can use the [`filter` and `map` helpers](https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions#filter-and-map-values) from the `@graphql-yoga/subscriptions` package.
Now we can implement the subscription resolver. It will receive the payload from a triggered topic of the pubsub system using the `@Root()` decorator. There, we can transform it to the returned shape.

Expand Down Expand Up @@ -116,10 +116,34 @@ class SampleResolver {
}
```

We use the `@PubSub()` decorator to inject the `pubsub` into our method params.
There we can trigger the topics and send the payload to all topic subscribers.
First, we need to create the `PubSub` instance. In most cases, we call `createPubSub()` function from `@graphql-yoga/subscriptions` package. Optionally, we can define the used topics and payload type using the type argument, e.g.:

```ts
import { createPubSub } from "@graphql-yoga/subscriptions";

export const pubSub = createPubSub<{
NOTIFICATIONS: [NotificationPayload];
DYNAMIC_ID_TOPIC: [number, NotificationPayload];
}>();
```

Then, we need to register the `PubSub` instance in the `buildSchema()` function options:

```ts
import { buildSchema } from "type-graphql";
import { pubSub } from "./pubsub";

const schema = await buildSchema({
resolver,
pubSub,
});
```

Finally, we can use the created `PubSub` instance to trigger the topics and send the payload to all topic subscribers:

```ts
import { pubSub } from "./pubsub";

class SampleResolver {
// ...
@Mutation(returns => Boolean)
Expand All @@ -128,51 +152,49 @@ class SampleResolver {
await this.commentsRepository.save(comment);
// Trigger subscriptions topics
const payload: NotificationPayload = { message: input.content };
await pubSub.publish("NOTIFICATIONS", payload);
pubSub.publish("NOTIFICATIONS", payload);
return true;
}
}
```

For easier testability (mocking/stubbing), we can also inject the `publish` method by itself bound to a selected topic.
This is done by using the `@PubSub("TOPIC_NAME")` decorator and the `Publisher<TPayload>` type:
And that's it! Now all subscriptions attached to the `NOTIFICATIONS` topic will be triggered when performing the `addNewComment` mutation.

## Topic with dynamic ID

The idea of this feature is taken from the `@graphql-yoga/subscriptions` that is used under the hood.
Basically, sometimes you only want to emit and listen for events for a specific entity (e.g. user or product). Dynamic topic ID lets you declare topics scoped to a special identifier, e.g.:

```ts
class SampleResolver {
// ...
@Mutation(returns => Boolean)
async addNewComment(
@Arg("comment") input: CommentInput,
@PubSub("NOTIFICATIONS") publish: Publisher<NotificationPayload>,
) {
const comment = this.commentsService.createNew(input);
await this.commentsRepository.save(comment);
// Trigger subscriptions topics
await publish({ message: input.content });
return true;
@Resolver()
class NotificationResolver {
@Subscription({
topics: "NOTIFICATIONS",
topicId: ({ context }) => context.userId,
})
newNotification(@Root() { message }: NotificationPayload): Notification {
return { message, date: new Date() };
}
}
```

And that's it! Now all subscriptions attached to the `NOTIFICATIONS` topic will be triggered when performing the `addNewComment` mutation.
Then in your mutation or services, you need to pass the topic id as the second parameter:

## Using a custom PubSub system
```ts
pubSub.publish("NOTIFICATIONS", userId, { id, message });
```

By default, TypeGraphQL uses a simple `PubSub` system from `graphql-subscriptions` which is based on EventEmitter.
This solution has a big drawback in that it will work correctly only when we have a single instance (process) of our Node.js app.
> Be aware that this feature must be supported by the pubsub system of your choice.
> If you decide to use something different than `createPubSub()` from `@graphql-yoga/subscriptions`, the second argument might be treated as a payload, not dynamic topic id.
For better scalability we'll want to use one of the [`PubSub implementations`](https://github.com/apollographql/graphql-subscriptions#pubsub-implementations) backed by an external store like Redis with the [`graphql-redis-subscriptions`](https://github.com/davidyaha/graphql-redis-subscriptions) package.
## Using a custom PubSub system

All we need to do is create an instance of PubSub according to the package instructions and then provide it to the TypeGraphQL `buildSchema` options:
While TypeGraphQL uses the `@graphql-yoga/subscriptions` package under the hood to handle subscription, there's no requirement to use that implementation of `PubSub`.

```ts
const myRedisPubSub = getConfiguredRedisPubSub();
In fact, you can use any pubsub system you want, not only the `graphql-yoga` one.
The only requirement is to comply with the exported `PubSub` interface - having proper `.subscribe()` and `.publish()` methods.

const schema = await buildSchema({
resolvers: [ExampleResolver],
pubSub: myRedisPubSub,
});
```
This is especially helpful for production usage, where we can't rely on the in-memory event emitter, so that we [use distributed pubsub](https://the-guild.dev/graphql/yoga-server/docs/features/subscriptions#distributed-pubsub-for-production).

## Creating a Subscription Server

Expand All @@ -183,7 +205,8 @@ However, beginning in Apollo Server 3, subscriptions are not supported by the "b

## Examples

See how subscriptions work in a [simple example](https://github.com/MichalLytek/type-graphql/tree/master/examples/simple-subscriptions).
See how subscriptions work in a [simple example](https://github.com/MichalLytek/type-graphql/tree/master/examples/simple-subscriptions). You can see there, how simple is setting up GraphQL subscriptions using `graphql-yoga` package.

For production usage, it's better to use something more scalable like a Redis-based pubsub system - [a working example is also available](https://github.com/MichalLytek/type-graphql/tree/master/examples/redis-subscriptions).
However, to launch this example you need to have a running instance of Redis and you might have to modify the example code to provide your connection parameters.
<!-- FIXME: restore when redis example is upgraded -->
<!-- For production usage, it's better to use something more scalable like a Redis-based pubsub system - [a working example is also available](https://github.com/MichalLytek/type-graphql/tree/master/examples/redis-subscriptions).
However, to launch this example you need to have a running instance of Redis and you might have to modify the example code to provide your connection parameters. -->
3 changes: 3 additions & 0 deletions examples/redis-subscriptions/comment.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export class Comment {

export interface NewCommentPayload {
recipeId: string;

dateString: string; // Limitation of Redis payload serialization

content: string;

nickname?: string;
}
65 changes: 8 additions & 57 deletions examples/redis-subscriptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,82 +2,33 @@ import "reflect-metadata";
import "dotenv/config";
import http from "node:http";
import path from "node:path";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
import { ApolloServerPluginLandingPageLocalDefault } from "@apollo/server/plugin/landingPage/default";
import bodyParser from "body-parser";
import cors from "cors";
import express from "express";
import { RedisPubSub } from "graphql-redis-subscriptions";
import { useServer } from "graphql-ws/lib/use/ws";
import Redis from "ioredis";
import { createYoga } from "graphql-yoga";
import { buildSchema } from "type-graphql";
import { WebSocketServer } from "ws";
import { pubSub } from "./pubsub";
import { RecipeResolver } from "./recipe.resolver";

async function bootstrap() {
// Create Redis-based pub-sub
const pubSub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL!, {
retryStrategy: times => Math.max(times * 100, 3000),
}),
subscriber: new Redis(process.env.REDIS_URL!, {
retryStrategy: times => Math.max(times * 100, 3000),
}),
});

// Build TypeGraphQL executable schema
const schema = await buildSchema({
// Array of resolvers
resolvers: [RecipeResolver],
// Create 'schema.graphql' file with schema definition in current directory
emitSchemaFile: path.resolve(__dirname, "schema.graphql"),
// Provide Redis-based instance of pub-sub
// Publish/Subscribe
pubSub,
validate: false,
});

// Create an Express app and HTTP server
// The WebSocket server and the ApolloServer will be attached to this HTTP server
const app = express();
const httpServer = http.createServer(app);

// Create WebSocket server using the HTTP server
const wsServer = new WebSocketServer({
server: httpServer,
path: "/graphql",
});
// Save the returned server's info so it can be shutdown later
const serverCleanup = useServer({ schema }, wsServer);

// Create GraphQL server
const server = new ApolloServer({
const yoga = createYoga({
schema,
csrfPrevention: true,
cache: "bounded",
plugins: [
// Proper shutdown for the HTTP server.
ApolloServerPluginDrainHttpServer({ httpServer }),
// Proper shutdown for the WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
ApolloServerPluginLandingPageLocalDefault({ embed: true }),
],
graphqlEndpoint: "/graphql",
});

// Start server
await server.start();
app.use("/graphql", cors<cors.CorsRequest>(), bodyParser.json(), expressMiddleware(server));
// Create server
const httpServer = http.createServer(yoga);

// Now that the HTTP server is fully set up, we can listen to it
// Start server
httpServer.listen(4000, () => {
console.log(`GraphQL server ready at http://localhost:4000/graphql`);
});
Expand Down
26 changes: 26 additions & 0 deletions examples/redis-subscriptions/pubsub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createRedisEventTarget } from "@graphql-yoga/redis-event-target";
import { createPubSub } from "@graphql-yoga/subscription";
import { Redis } from "ioredis";
import { type NewCommentPayload } from "./comment.type";

const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL env variable is not defined");
}

export const enum Topic {
NEW_COMMENT = "NEW_COMMENT",
}

export const pubSub = createPubSub<{
[Topic.NEW_COMMENT]: [NewCommentPayload];
}>({
eventTarget: createRedisEventTarget({
publishClient: new Redis(redisUrl, {
retryStrategy: times => Math.max(times * 100, 3000),
}),
subscribeClient: new Redis(redisUrl, {
retryStrategy: times => Math.max(times * 100, 3000),
}),
}),
});
Loading

0 comments on commit a088c25

Please sign in to comment.