Skip to content

Commit 870ed34

Browse files
feat: GraphQL Subscriptions with WebSocket, SSE, and Auto Mode (#57)
* feat: add WebSocket subscription support for GraphQL - Add WebSocket handlers for GraphQL Yoga and Apollo Server - Implement graphql-ws protocol for real-time subscriptions - Add SubscriptionsConfig type and endpoint configuration - Auto-enable experimental websocket flag when subscriptions enabled - Add playground examples with PubSub chat implementation Usage: ```typescript // nitro.config.ts graphql: { framework: 'graphql-yoga', subscriptions: { enabled: true, }, } ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat: implement GraphQL subscription client with WebSocket and SSE support * feat: implement GraphQL subscription support with cross-platform WebSocket and SSE * feat: add GraphQL subscription support with countdown, greetings, and server time * feat: enhance GraphQL subscription support with authentication and connection parameters * feat: add intentional close handling for subscription connections * feat: enhance subscription handling with duplicate check and connection state management * feat: enhance WebSocket client with keep-alive pings and reconnection handling * feat: implement graceful shutdown for subscriptions with complete message handling * refactor: update subscription handling and introduce multiplexing support - Changed import paths for Vue composables in subscriptions.vue to use sdk.ts. - Enhanced subscribe-client.ts to support multiplexing with createSession() for shared connections. - Updated subscription client methods to return SubscriptionHandle instead of SubscriptionClient. - Added new useSubscriptionSession hook for managing multiplexed subscriptions. - Deprecated generateNuxtSubscriptionComposables function; noted that composables are now generated directly in sdk.ts. - Improved error handling and connection management in subscription logic. * refactor: improve subscription session handling and connection management - Updated `useSubscriptionSession` to maintain reactivity by subscribing to session state changes. - Refactored multiplexing logic in `multiplexing-test.vue` to use a single session object for all composables. - Enhanced connection management in `subscribe-client.ts` with a Timer Manager for better handling of timeouts and intervals. - Introduced keep-alive functionality to maintain WebSocket connections. - Improved error handling and state notifications for subscriptions. - Added state change callbacks to `SubscriptionSession` for better reactivity. - Updated client code generation to include detailed JSDoc comments for subscription composables. * refactor: streamline GraphQL subscription client configuration and improve code organization - Simplified the subscription client template for easier customization. - Reorganized imports and exports in subscription-related files for clarity. - Updated type generation to include subscription composables and external services. - Enhanced the tsdown configuration to include subscription-related files. * feat: add unified transport support for subscriptions with WebSocket, SSE, and auto mode * refactor: enhance SSE handling by introducing dedicated event listener for subscription data * refactor: enhance TypeScript configuration handling and remove deprecated subscription composables generation * chore: update dependencies in pnpm-workspace.yaml - Bump @antfu/eslint-config from ^6.3.0 to ^6.6.1 - Update @apollo/subgraph from ^2.12.1 to ^2.12.2 - Upgrade @nuxt/kit and @nuxt/schema from ^4.2.1 to ^4.2.2 - Update @types/node from ^24.10.1 to ^24.10.3 - Bump nuxt from ^4.2.1 to ^4.2.2 - Upgrade oxc-parser from ^0.101.0 to ^0.102.0 - Update tailwindcss from ^4.1.17 to ^4.1.18 - Bump tsdown from ^0.16.8 to ^0.17.2 - Update vue-router from ^4.6.3 to ^4.6.4 * refactor: update subscription client configuration and improve client instance export * refactor: change type of buildSubgraphSchema to any for flexibility * refactor: update version to 1.7.0-beta.0 and enhance subscription handling in client type generation * chore: bump version to 1.7.0-beta.1 * chore: bump version to 1.7.0-beta.2 and refactor subscription method generation * refactor: remove redundant sse shorthand from TransportOptions Remove the `sse: boolean` property from TransportOptions interface to simplify the API. Users should now use `transport: 'sse'` instead. Changes: - Remove `sse` property from TransportOptions (subscribe/index.ts) - Simplify resolveTransport() function - Remove `sse` property from UseSubscriptionOptions (client-codegen.ts) - Update transportOptions object construction 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * chore: bump version to 1.7.0-beta.5 * feat: implement graceful WebSocket shutdown for Apollo Server and GraphQL Yoga * feat(dependencies): add vitepress-plugin-llms and update libc specifications for various packages --------- Co-authored-by: Claude <[email protected]>
1 parent 21ecd91 commit 870ed34

32 files changed

+4624
-18
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// This file is auto-generated once by nitro-graphql for quick start
2+
// You can modify this file to customize the subscription client
3+
// The implementation comes from nitro-graphql/subscribe - you'll get updates automatically!
4+
//
5+
// Usage Examples:
6+
// ---------------
7+
// Basic subscription:
8+
// subscriptionClient.subscribe(query, variables, onData, onError)
9+
//
10+
// Multiplexed subscriptions (shared connection):
11+
// const session = subscriptionClient.createSession()
12+
// session.subscribe(query1, vars1, onData1)
13+
// session.subscribe(query2, vars2, onData2)
14+
//
15+
// With authentication:
16+
// Edit connectionParams below to add auth headers
17+
18+
import type {
19+
ConnectionState,
20+
SubscriptionClient,
21+
SubscriptionClientConfig,
22+
SubscriptionHandle,
23+
SubscriptionSession,
24+
} from 'nitro-graphql/subscribe'
25+
import { createSubscriptionClient } from 'nitro-graphql/subscribe'
26+
27+
// Re-export types for convenience
28+
export type { ConnectionState, SubscriptionClient, SubscriptionHandle, SubscriptionSession }
29+
30+
// Configure the subscription client
31+
// Customize these settings according to your needs
32+
const config: SubscriptionClientConfig = {
33+
// WebSocket endpoint (default: '/api/graphql/ws')
34+
wsEndpoint: '/api/graphql/ws',
35+
36+
// Connection timeout in ms (default: 10000)
37+
connectionTimeoutMs: 10000,
38+
39+
// Max reconnection attempts (default: 5)
40+
maxRetries: 5,
41+
42+
// Authentication params sent with connection_init
43+
// Can be a function for dynamic values (e.g., JWT tokens)
44+
// connectionParams: () => ({
45+
// authorization: `Bearer ${getToken()}`,
46+
// }),
47+
}
48+
49+
// Export configured client instance
50+
export const subscriptionClient = createSubscriptionClient(config)

playgrounds/nitro/nitro.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default defineNitroConfig({
77
compatibilityDate: '2025-07-01',
88
graphql: {
99
framework: 'graphql-yoga',
10+
subscriptions: {
11+
enabled: true,
12+
},
1013
},
1114
esbuild: {
1215
options: {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { CHANNELS, pubsub } from './pubsub'
2+
3+
interface Message {
4+
id: string
5+
channel: string
6+
content: string
7+
author: string
8+
createdAt: string
9+
}
10+
11+
interface UserEvent {
12+
type: 'JOINED' | 'LEFT' | 'TYPING' | 'STOPPED_TYPING'
13+
channel: string
14+
username: string
15+
timestamp: string
16+
}
17+
18+
// Mutations
19+
export const chatMutations = defineMutation({
20+
sendMessage: (_parent, { channel, content, author }: { channel: string, content: string, author: string }) => {
21+
const message: Message = {
22+
id: crypto.randomUUID(),
23+
channel,
24+
content,
25+
author,
26+
createdAt: new Date().toISOString(),
27+
}
28+
29+
// Publish to subscribers
30+
pubsub.publish(CHANNELS.MESSAGE(channel), message)
31+
32+
return message
33+
},
34+
35+
joinChannel: (_parent, { channel, username }: { channel: string, username: string }) => {
36+
const event: UserEvent = {
37+
type: 'JOINED',
38+
channel,
39+
username,
40+
timestamp: new Date().toISOString(),
41+
}
42+
43+
pubsub.publish(CHANNELS.USER_EVENT(channel), event)
44+
return event
45+
},
46+
47+
leaveChannel: (_parent, { channel, username }: { channel: string, username: string }) => {
48+
const event: UserEvent = {
49+
type: 'LEFT',
50+
channel,
51+
username,
52+
timestamp: new Date().toISOString(),
53+
}
54+
55+
pubsub.publish(CHANNELS.USER_EVENT(channel), event)
56+
return event
57+
},
58+
59+
startTyping: (_parent, { channel, username }: { channel: string, username: string }) => {
60+
const event: UserEvent = {
61+
type: 'TYPING',
62+
channel,
63+
username,
64+
timestamp: new Date().toISOString(),
65+
}
66+
67+
pubsub.publish(CHANNELS.USER_EVENT(channel), event)
68+
return event
69+
},
70+
71+
stopTyping: (_parent, { channel, username }: { channel: string, username: string }) => {
72+
const event: UserEvent = {
73+
type: 'STOPPED_TYPING',
74+
channel,
75+
username,
76+
timestamp: new Date().toISOString(),
77+
}
78+
79+
pubsub.publish(CHANNELS.USER_EVENT(channel), event)
80+
return event
81+
},
82+
})
83+
84+
// Subscriptions
85+
export const chatSubscriptions = defineSubscription({
86+
onMessage: {
87+
subscribe: (_parent, { channel }: { channel: string }) => {
88+
return {
89+
[Symbol.asyncIterator]: () => pubsub.asyncIterator<Message>(CHANNELS.MESSAGE(channel)),
90+
}
91+
},
92+
resolve: (payload: Message) => payload,
93+
},
94+
95+
onUserEvent: {
96+
subscribe: (_parent, { channel }: { channel: string }) => {
97+
return {
98+
[Symbol.asyncIterator]: () => pubsub.asyncIterator<UserEvent>(CHANNELS.USER_EVENT(channel)),
99+
}
100+
},
101+
resolve: (payload: UserEvent) => payload,
102+
},
103+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Simple in-memory PubSub implementation
2+
type Subscriber<T> = (data: T) => void
3+
4+
class PubSub {
5+
private subscribers: Map<string, Set<Subscriber<any>>> = new Map()
6+
7+
subscribe<T>(channel: string, callback: Subscriber<T>): () => void {
8+
if (!this.subscribers.has(channel)) {
9+
this.subscribers.set(channel, new Set())
10+
}
11+
this.subscribers.get(channel)!.add(callback)
12+
13+
// Return unsubscribe function
14+
return () => {
15+
this.subscribers.get(channel)?.delete(callback)
16+
}
17+
}
18+
19+
publish<T>(channel: string, data: T): void {
20+
const subs = this.subscribers.get(channel)
21+
if (subs) {
22+
for (const callback of subs) {
23+
callback(data)
24+
}
25+
}
26+
}
27+
28+
// Create an async iterator for subscriptions
29+
asyncIterator<T>(channel: string): AsyncIterableIterator<T> {
30+
const queue: T[] = []
31+
let resolve: ((value: IteratorResult<T>) => void) | null = null
32+
let done = false
33+
34+
const unsubscribe = this.subscribe<T>(channel, (data) => {
35+
if (resolve) {
36+
resolve({ value: data, done: false })
37+
resolve = null
38+
}
39+
else {
40+
queue.push(data)
41+
}
42+
})
43+
44+
return {
45+
[Symbol.asyncIterator]() {
46+
return this
47+
},
48+
async next(): Promise<IteratorResult<T>> {
49+
if (done) {
50+
return { value: undefined, done: true }
51+
}
52+
if (queue.length > 0) {
53+
return { value: queue.shift()!, done: false }
54+
}
55+
return new Promise((res) => {
56+
resolve = res
57+
})
58+
},
59+
async return(): Promise<IteratorResult<T>> {
60+
done = true
61+
unsubscribe()
62+
return { value: undefined, done: true }
63+
},
64+
}
65+
}
66+
}
67+
68+
// Global singleton
69+
export const pubsub = new PubSub()
70+
71+
// Channel names
72+
export const CHANNELS = {
73+
MESSAGE: (channel: string) => `message:${channel}`,
74+
USER_EVENT: (channel: string) => `user_event:${channel}`,
75+
}

playgrounds/nitro/server/graphql/schema.graphql

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,63 @@ type Mutation {
3434
email: String! @validate(pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
3535
age: Int! @validate(min: 18, max: 100)
3636
): User
37-
37+
3838
updateBio(
3939
userId: ID!
4040
bio: String! @validate(maxLength: 500) @transform(trim: true)
4141
): User
42+
43+
"""Send a message to a channel"""
44+
sendMessage(channel: String!, content: String!, author: String!): Message!
45+
46+
"""Join a channel"""
47+
joinChannel(channel: String!, username: String!): UserEvent!
48+
49+
"""Leave a channel"""
50+
leaveChannel(channel: String!, username: String!): UserEvent!
51+
52+
"""Send typing indicator"""
53+
startTyping(channel: String!, username: String!): UserEvent!
54+
55+
"""Stop typing indicator"""
56+
stopTyping(channel: String!, username: String!): UserEvent!
57+
}
58+
59+
type Subscription {
60+
"""Simple counter that increments every second"""
61+
countdown(from: Int!): Int!
62+
63+
"""Receives a greeting message every 2 seconds"""
64+
greetings: String!
65+
66+
"""Current server time updated every second"""
67+
serverTime: DateTime!
68+
69+
"""Listen for new messages in a channel"""
70+
onMessage(channel: String!): Message!
71+
72+
"""Listen for user events (join/leave/typing)"""
73+
onUserEvent(channel: String!): UserEvent!
74+
}
75+
76+
type Message {
77+
id: ID!
78+
channel: String!
79+
content: String!
80+
author: String!
81+
createdAt: DateTime!
82+
}
83+
84+
type UserEvent {
85+
type: UserEventType!
86+
channel: String!
87+
username: String!
88+
timestamp: DateTime!
89+
}
90+
91+
enum UserEventType {
92+
JOINED
93+
LEFT
94+
TYPING
95+
STOPPED_TYPING
4296
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export const subscriptionResolvers = defineSubscription({
2+
countdown: {
3+
async* subscribe(_parent, { from }: { from: number }) {
4+
for (let i = from; i >= 0; i--) {
5+
yield { countdown: i }
6+
await new Promise(resolve => setTimeout(resolve, 1000))
7+
}
8+
},
9+
},
10+
11+
greetings: {
12+
async* subscribe() {
13+
const greetings = ['Hello!', 'Merhaba!', 'Bonjour!', 'Hola!', 'Ciao!', 'Hallo!']
14+
let index = 0
15+
16+
while (true) {
17+
yield { greetings: greetings[index % greetings.length] }
18+
index++
19+
await new Promise(resolve => setTimeout(resolve, 2000))
20+
21+
// Stop after 10 messages for demo
22+
if (index >= 10)
23+
break
24+
}
25+
},
26+
},
27+
28+
serverTime: {
29+
async* subscribe() {
30+
for (let i = 0; i < 30; i++) {
31+
yield { serverTime: new Date().toISOString() }
32+
await new Promise(resolve => setTimeout(resolve, 1000))
33+
}
34+
},
35+
},
36+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// This file is auto-generated once by nitro-graphql for quick start
2+
// You can modify this file according to your needs
3+
//
4+
// === Query/Mutation Client ===
5+
export * from './ofetch'
6+
7+
// === Subscription Composables (Vue) ===
8+
// Export useCountdown, useGreetings, useSubscriptionSession, etc.
9+
export * from './sdk'
10+
11+
// === Subscription Client (low-level) ===
12+
// Export subscriptionClient for direct WebSocket access
13+
export * from './subscribe'

0 commit comments

Comments
 (0)