Skip to content

Commit eca6e43

Browse files
committed
feat: implement GraphQLSubscriptionHandler
1 parent d7dd3ac commit eca6e43

File tree

4 files changed

+213
-10
lines changed

4 files changed

+213
-10
lines changed

src/core/graphql.ts

+19-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DocumentNode, OperationTypeNode } from 'graphql'
1+
import { DocumentNode, OperationTypeNode } from 'graphql'
22
import {
33
ResponseResolver,
44
RequestHandlerOptions,
@@ -13,6 +13,12 @@ import {
1313
GraphQLQuery,
1414
} from './handlers/GraphQLHandler'
1515
import type { Path } from './utils/matching/matchRequestUrl'
16+
import {
17+
GraphQLPubsub,
18+
GraphQLInternalPubsub,
19+
createGraphQLSubscriptionHandler,
20+
GraphQLSubscriptionHandler,
21+
} from './handlers/GraphQLSubscriptionHandler'
1622

1723
export interface TypedDocumentNode<
1824
Result = { [key: string]: any },
@@ -119,29 +125,33 @@ export interface GraphQLHandlers {
119125
}
120126

121127
const standardGraphQLHandlers: GraphQLHandlers = {
122-
query: createScopedGraphQLHandler('query' as OperationTypeNode, '*'),
123-
mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, '*'),
128+
query: createScopedGraphQLHandler(OperationTypeNode.QUERY, '*'),
129+
mutation: createScopedGraphQLHandler(OperationTypeNode.MUTATION, '*'),
124130
operation: createGraphQLOperationHandler('*'),
125131
}
126132

127133
export interface GraphQLLink extends GraphQLHandlers {
134+
pubsub: GraphQLPubsub
135+
128136
/**
129137
* Intercepts a GraphQL subscription by its name.
130138
*
131139
* @example
132140
* graphql.subscription('OnPostAdded', resolver)
133141
*/
134-
subscription: GraphQLRequestHandler
142+
subscription: GraphQLSubscriptionHandler
135143
}
136144

137145
function createGraphQLLink(url: Path): GraphQLLink {
146+
const internalPubSub = new GraphQLInternalPubsub(url)
147+
138148
return {
139-
query: createScopedGraphQLHandler('query' as OperationTypeNode, url),
140-
mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, url),
141-
subscription: createScopedGraphQLHandler(
142-
'subscription' as OperationTypeNode,
143-
url,
149+
query: createScopedGraphQLHandler(OperationTypeNode.QUERY, url),
150+
mutation: createScopedGraphQLHandler(OperationTypeNode.MUTATION, url),
151+
subscription: createGraphQLSubscriptionHandler(
152+
internalPubSub.webSocketLink,
144153
),
154+
pubsub: internalPubSub.pubsub,
145155
operation: createGraphQLOperationHandler(url),
146156
}
147157
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { DocumentNode, OperationTypeNode } from 'graphql'
2+
import type {
3+
GraphQLHandlerNameSelector,
4+
GraphQLQuery,
5+
GraphQLVariables,
6+
} from './GraphQLHandler'
7+
import type { Path } from '../utils/matching/matchRequestUrl'
8+
import { parseDocumentNode } from '../utils/internal/parseGraphQLRequest'
9+
import { WebSocketLink, ws } from '../ws'
10+
import { WebSocketHandler } from './WebSocketHandler'
11+
import { jsonParse } from '../utils/internal/jsonParse'
12+
import type { TypedDocumentNode } from '../graphql'
13+
14+
export interface GraphQLPubsub {
15+
/**
16+
* A WebSocket handler to intercept GraphQL subscription events.
17+
*/
18+
handler: WebSocketHandler
19+
20+
/**
21+
* Publishes the given payload to all GraphQL subscriptions.
22+
*/
23+
publish: (payload: { data?: Record<string, unknown> }) => void
24+
}
25+
26+
export class GraphQLInternalPubsub {
27+
public pubsub: GraphQLPubsub
28+
public webSocketLink: WebSocketLink
29+
private subscriptions: Set<string>
30+
31+
constructor(public readonly url: Path) {
32+
this.subscriptions = new Set()
33+
34+
/**
35+
* @fixme This isn't nice.
36+
* This is here to translate HTTP URLs from `graphql.link` to a WS URL.
37+
* Works for strings but won't work for RegExp.
38+
*/
39+
const webSocketUrl =
40+
typeof url === 'string' ? url.replace(/^http/, 'ws') : url
41+
42+
/**
43+
* @todo Support `log: false` not to print logs from the underlying WS handler.
44+
*/
45+
this.webSocketLink = ws.link(webSocketUrl)
46+
47+
const webSocketHandler = this.webSocketLink.addEventListener(
48+
'connection',
49+
({ client }) => {
50+
client.addEventListener('message', (event) => {
51+
if (typeof event.data !== 'string') {
52+
return
53+
}
54+
55+
const message = jsonParse(event.data)
56+
57+
if (!message) {
58+
return
59+
}
60+
61+
switch (message.type) {
62+
case 'connection_init': {
63+
client.send(JSON.stringify({ type: 'connection_ack' }))
64+
break
65+
}
66+
67+
case 'subscribe': {
68+
this.subscriptions.add(message.id)
69+
break
70+
}
71+
72+
case 'complete': {
73+
this.subscriptions.delete(message.id)
74+
break
75+
}
76+
}
77+
})
78+
},
79+
)
80+
81+
this.pubsub = {
82+
handler: webSocketHandler,
83+
publish: (payload) => {
84+
for (const subscriptionId of this.subscriptions) {
85+
this.webSocketLink.broadcast(
86+
this.createSubscriptionMessage({
87+
id: subscriptionId,
88+
payload,
89+
}),
90+
)
91+
}
92+
},
93+
}
94+
}
95+
96+
private createSubscriptionMessage(args: { id: string; payload: unknown }) {
97+
return JSON.stringify({
98+
id: args.id,
99+
type: 'next',
100+
payload: args.payload,
101+
})
102+
}
103+
}
104+
105+
export type GraphQLSubscriptionHandler = <
106+
Query extends GraphQLQuery = GraphQLQuery,
107+
Variables extends GraphQLVariables = GraphQLVariables,
108+
>(
109+
operationName:
110+
| GraphQLHandlerNameSelector
111+
| DocumentNode
112+
| TypedDocumentNode<Query, Variables>,
113+
resolver: (info: GraphQLSubscriptionHandlerInfo<Variables>) => void,
114+
) => WebSocketHandler
115+
116+
export interface GraphQLSubscriptionHandlerInfo<
117+
Variables extends GraphQLVariables,
118+
> {
119+
operationName: string
120+
query: string
121+
variables: Variables
122+
}
123+
124+
export function createGraphQLSubscriptionHandler(
125+
webSocketLink: WebSocketLink,
126+
): GraphQLSubscriptionHandler {
127+
return (operationName, resolver) => {
128+
const webSocketHandler = webSocketLink.addEventListener(
129+
'connection',
130+
({ client }) => {
131+
client.addEventListener('message', async (event) => {
132+
if (typeof event.data !== 'string') {
133+
return
134+
}
135+
136+
const message = jsonParse(event.data)
137+
138+
if (
139+
message != null &&
140+
'type' in message &&
141+
message.type === 'subscribe'
142+
) {
143+
const { parse } = await import('graphql')
144+
const document = parse(message.payload.query)
145+
const node = parseDocumentNode(document)
146+
147+
if (
148+
node.operationType === OperationTypeNode.SUBSCRIPTION &&
149+
node.operationName === operationName
150+
) {
151+
/**
152+
* @todo Add the path parameters from the pubsub URL.
153+
*/
154+
resolver({
155+
operationName: node.operationName,
156+
query: message.payload.query,
157+
variables: message.payload.variables,
158+
})
159+
}
160+
}
161+
})
162+
},
163+
)
164+
165+
return webSocketHandler
166+
}
167+
}

src/core/handlers/common.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1 +1,17 @@
11
export type HandlerKind = 'RequestHandler' | 'EventHandler'
2+
3+
export interface HandlerOptions {
4+
once?: boolean
5+
}
6+
7+
export abstract class HandlerProtocol {
8+
static cache: WeakMap<any, any> = new WeakMap()
9+
10+
constructor(private __kind: HandlerKind) {}
11+
12+
abstract parse(args: any): Promise<any>
13+
14+
abstract predicate(args: any): Promise<boolean>
15+
16+
abstract run(args: any): Promise<any>
17+
}

test/typings/graphql.test-d.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { it, expectTypeOf } from 'vitest'
22
import { parse } from 'graphql'
3-
import { graphql, HttpResponse, passthrough } from 'msw'
3+
import { graphql, GraphQLRequestHandler, HttpResponse, passthrough } from 'msw'
44

55
it('graphql mutation can be used without variables generic type', () => {
66
graphql.mutation('GetUser', () => {
@@ -198,3 +198,13 @@ it('graphql mutation cannot extract variable and reponse types', () => {
198198
})
199199
})
200200
})
201+
202+
/**
203+
* Subscriptions.
204+
*/
205+
it('exposes a "subscription" method only on a GraphQL link', () => {
206+
expectTypeOf(graphql).not.toHaveProperty('subscription')
207+
expectTypeOf(
208+
graphql.link('http://localhost:4000').subscription,
209+
).toEqualTypeOf<GraphQLRequestHandler>()
210+
})

0 commit comments

Comments
 (0)