bookworm-smart-assistant/skills/graphql-architect/references/subscriptions.md

511 lines
12 KiB
Markdown

# GraphQL Subscriptions
## Basic Subscription Setup
```typescript
// schema.graphql
type Subscription {
postCreated: Post!
postUpdated(id: ID!): Post!
commentAdded(postId: ID!): Comment!
userOnline: User!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
}
// server.ts
import { createServer } from 'http';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer(
{
schema,
context: async (ctx, msg, args) => {
// Extract auth from connection params
const token = ctx.connectionParams?.authorization;
const user = token ? await verifyToken(token) : null;
return { user };
},
},
wsServer
);
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);
```
## PubSub Implementation
```typescript
// pubsub.ts
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
// In-memory (development only)
import { PubSub } from 'graphql-subscriptions';
export const pubsub = new PubSub();
// Redis (production)
const options = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
retryStrategy: (times: number) => Math.min(times * 50, 2000),
};
export const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
// Strongly typed event names
export const EVENTS = {
POST_CREATED: 'POST_CREATED',
POST_UPDATED: 'POST_UPDATED',
COMMENT_ADDED: 'COMMENT_ADDED',
USER_ONLINE: 'USER_ONLINE',
} as const;
```
## Subscription Resolvers
```typescript
import { withFilter } from 'graphql-subscriptions';
import { pubsub, EVENTS } from './pubsub';
const resolvers = {
Subscription: {
// Simple subscription
postCreated: {
subscribe: () => pubsub.asyncIterator([EVENTS.POST_CREATED]),
},
// Filtered subscription
postUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.POST_UPDATED]),
(payload, variables) => {
// Only send updates for specific post
return payload.postUpdated.id === variables.id;
}
),
},
// Filtered with authorization
commentAdded: {
subscribe: withFilter(
(parent, args, context) => {
// Check auth before subscribing
if (!context.user) {
throw new Error('Unauthorized');
}
return pubsub.asyncIterator([EVENTS.COMMENT_ADDED]);
},
async (payload, variables, context) => {
// Filter by post and check permissions
if (payload.commentAdded.postId !== variables.postId) {
return false;
}
// Check if user has access to post
const post = await context.dataSources.posts.findById(
variables.postId
);
return post && post.isPublic || post.authorId === context.user.id;
}
),
},
// Complex subscription with multiple filters
userOnline: {
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.USER_ONLINE]),
(payload, variables, context) => {
// Only notify friends
return context.user.friends.includes(payload.userOnline.id);
}
),
},
},
Mutation: {
createPost: async (parent, args, context) => {
const post = await context.dataSources.posts.create(args.input);
// Publish event
await pubsub.publish(EVENTS.POST_CREATED, {
postCreated: post,
});
return post;
},
updatePost: async (parent, args: { id: string; input: any }, context) => {
const post = await context.dataSources.posts.update(
args.id,
args.input
);
await pubsub.publish(EVENTS.POST_UPDATED, {
postUpdated: post,
});
return post;
},
addComment: async (parent, args, context) => {
const comment = await context.dataSources.comments.create(args.input);
await pubsub.publish(EVENTS.COMMENT_ADDED, {
commentAdded: comment,
});
return comment;
},
},
};
```
## Advanced Filtering
```typescript
// Type-safe payload
interface PostCreatedPayload {
postCreated: Post;
tags: string[];
isPublic: boolean;
}
const resolvers = {
Subscription: {
postCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.POST_CREATED]),
async (
payload: PostCreatedPayload,
variables: { tags?: string[]; authorId?: string },
context: Context
) => {
// Filter by tags
if (variables.tags && variables.tags.length > 0) {
const hasMatchingTag = payload.tags.some(tag =>
variables.tags!.includes(tag)
);
if (!hasMatchingTag) return false;
}
// Filter by author
if (variables.authorId) {
if (payload.postCreated.authorId !== variables.authorId) {
return false;
}
}
// Check permissions
if (!payload.isPublic) {
return (
context.user?.id === payload.postCreated.authorId ||
context.user?.isAdmin
);
}
return true;
}
),
},
},
};
```
## Connection Management
```typescript
import { useServer } from 'graphql-ws/lib/use/ws';
const wsServer = useServer(
{
schema,
// Connection lifecycle
onConnect: async (ctx) => {
console.log('Client connected');
const token = ctx.connectionParams?.authorization;
if (!token) {
throw new Error('Missing auth token');
}
const user = await verifyToken(token);
if (!user) {
throw new Error('Invalid token');
}
return { user };
},
onDisconnect: (ctx, code, reason) => {
console.log('Client disconnected', code, reason);
},
// Subscription lifecycle
onSubscribe: async (ctx, msg) => {
console.log('Client subscribed', msg.payload.operationName);
// Rate limiting
const subscriptionCount = getUserSubscriptionCount(ctx.user.id);
if (subscriptionCount >= 10) {
throw new Error('Too many subscriptions');
}
return { ctx, msg };
},
onComplete: (ctx, msg) => {
console.log('Subscription completed', msg.id);
},
// Keep-alive
connectionInitWaitTimeout: 10000,
// Context per subscription
context: async (ctx, msg, args) => {
const user = ctx.extra.user;
return {
user,
dataSources: createDataSources(),
subscriptionId: msg.id,
};
},
},
wsServer
);
```
## Subscription Patterns
```typescript
// Pattern 1: Entity updates
type Subscription {
entityUpdated(id: ID!): Entity!
}
// Pattern 2: Collection updates
type Subscription {
entityAdded: Entity!
entityDeleted: ID!
}
// Pattern 3: Stream of events
type Subscription {
events(types: [EventType!]): Event!
}
// Pattern 4: Live query (with intervals)
type Subscription {
liveQuery(query: String!): [SearchResult!]!
}
// resolvers.ts
const resolvers = {
Subscription: {
// Live query implementation
liveQuery: {
subscribe: async function* (parent, args, context) {
while (true) {
const results = await context.dataSources.search(args.query);
yield { liveQuery: results };
await new Promise(resolve => setTimeout(resolve, 5000));
}
},
},
},
};
```
## Error Handling
```typescript
const resolvers = {
Subscription: {
postCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator([EVENTS.POST_CREATED]),
async (payload, variables, context) => {
try {
// Check permissions
if (!context.user) {
throw new GraphQLError('Unauthorized', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return true;
} catch (error) {
// Log error but don't propagate to client
console.error('Subscription filter error:', error);
return false;
}
}
),
// Resolve subscription payload
resolve: (payload) => {
try {
return payload.postCreated;
} catch (error) {
throw new GraphQLError('Failed to resolve subscription', {
extensions: { code: 'SUBSCRIPTION_RESOLVE_ERROR' },
});
}
},
},
},
};
```
## Client Usage
```typescript
// Apollo Client setup
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql',
});
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
authorization: `Bearer ${token}`,
},
})
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
// Subscribe to events
const subscription = client
.subscribe({
query: gql`
subscription OnPostCreated {
postCreated {
id
title
author {
username
}
}
}
`,
})
.subscribe({
next: (data) => console.log('New post:', data),
error: (err) => console.error('Subscription error:', err),
complete: () => console.log('Subscription completed'),
});
// Unsubscribe
subscription.unsubscribe();
```
## Scaling Subscriptions
```typescript
// Use Redis for multi-instance deployments
import { RedisPubSub } from 'graphql-redis-subscriptions';
// Horizontal scaling pattern
const pubsub = new RedisPubSub({
publisher: new Redis(redisConfig),
subscriber: new Redis(redisConfig),
// Channel prefix for isolation
publisherPrefix: 'graphql:pub:',
subscriberPrefix: 'graphql:sub:',
});
// Connection limit per instance
const MAX_CONNECTIONS_PER_INSTANCE = 10000;
// Load balancing with sticky sessions
// Ensure same user connects to same server instance
// for connection state management
```
## Subscription Best Practices
1. **Authentication**: Always validate auth in onConnect and filters
2. **Authorization**: Check permissions in withFilter
3. **Rate Limiting**: Limit subscriptions per user
4. **Filtering**: Use withFilter for server-side filtering
5. **Cleanup**: Always clean up subscriptions on disconnect
6. **Scaling**: Use Redis PubSub for multi-instance deployments
7. **Error Handling**: Gracefully handle errors in filters and resolvers
8. **Testing**: Test subscription lifecycle and filtering
9. **Monitoring**: Track active connections and subscription count
10. **Performance**: Avoid N+1 in subscription resolvers