Skip to content

Commit e24e6db

Browse files
committed
feat: support query type, subscription-level publish
1 parent cce5115 commit e24e6db

File tree

3 files changed

+117
-27
lines changed

3 files changed

+117
-27
lines changed

src/core/graphql.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,7 @@ function createGraphQLLink(url: Path): GraphQLLink {
148148
return {
149149
query: createScopedGraphQLHandler(OperationTypeNode.QUERY, url),
150150
mutation: createScopedGraphQLHandler(OperationTypeNode.MUTATION, url),
151-
subscription: createGraphQLSubscriptionHandler(
152-
internalPubSub.webSocketLink,
153-
),
151+
subscription: createGraphQLSubscriptionHandler(internalPubSub),
154152
pubsub: internalPubSub.pubsub,
155153
operation: createGraphQLOperationHandler(url),
156154
}

src/core/handlers/GraphQLSubscriptionHandler.ts

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,46 @@ export interface GraphQLPubsub {
2020
/**
2121
* Publishes the given payload to all GraphQL subscriptions.
2222
*/
23-
publish: (payload: { data?: Record<string, unknown> }) => void
23+
publish: (
24+
payload: { data?: Record<string, unknown> },
25+
predicate?: (args: {
26+
subscription: GraphQLWebSocketSubscriptionWithId
27+
}) => boolean,
28+
) => void
29+
}
30+
31+
type GraphQLWebSocketOutgoingMessage =
32+
| {
33+
type: 'connection_init'
34+
}
35+
| {
36+
type: 'subscribe'
37+
id: string
38+
payload: GraphQLWebSocketSubscription
39+
}
40+
| {
41+
type: 'complete'
42+
id: string
43+
}
44+
45+
interface GraphQLWebSocketSubscription {
46+
query: string
47+
variables: Record<string, unknown>
48+
extensions: Array<any>
49+
}
50+
51+
interface GraphQLWebSocketSubscriptionWithId
52+
extends GraphQLWebSocketSubscription {
53+
id: string
2454
}
2555

2656
export class GraphQLInternalPubsub {
2757
public pubsub: GraphQLPubsub
2858
public webSocketLink: WebSocketLink
29-
private subscriptions: Set<string>
59+
private subscriptions: Map<string, GraphQLWebSocketSubscriptionWithId>
3060

3161
constructor(public readonly url: Path) {
32-
this.subscriptions = new Set()
62+
this.subscriptions = new Map()
3363

3464
/**
3565
* @fixme This isn't nice.
@@ -52,7 +82,7 @@ export class GraphQLInternalPubsub {
5282
return
5383
}
5484

55-
const message = jsonParse(event.data)
85+
const message = jsonParse<GraphQLWebSocketOutgoingMessage>(event.data)
5686

5787
if (!message) {
5888
return
@@ -65,7 +95,10 @@ export class GraphQLInternalPubsub {
6595
}
6696

6797
case 'subscribe': {
68-
this.subscriptions.add(message.id)
98+
this.subscriptions.set(message.id, {
99+
...message.payload,
100+
id: message.id,
101+
})
69102
break
70103
}
71104

@@ -80,14 +113,16 @@ export class GraphQLInternalPubsub {
80113

81114
this.pubsub = {
82115
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-
)
116+
publish: (payload, predicate = () => true) => {
117+
for (const [, subscription] of this.subscriptions) {
118+
if (predicate({ subscription })) {
119+
this.webSocketLink.broadcast(
120+
this.createSubscriptionMessage({
121+
id: subscription.id,
122+
payload,
123+
}),
124+
)
125+
}
91126
}
92127
},
93128
}
@@ -110,30 +145,32 @@ export type GraphQLSubscriptionHandler = <
110145
| GraphQLHandlerNameSelector
111146
| DocumentNode
112147
| TypedDocumentNode<Query, Variables>,
113-
resolver: (info: GraphQLSubscriptionHandlerInfo<Variables>) => void,
148+
resolver: (info: GraphQLSubscriptionHandlerInfo<Query, Variables>) => void,
114149
) => WebSocketHandler
115150

116151
export interface GraphQLSubscriptionHandlerInfo<
152+
Query extends GraphQLQuery,
117153
Variables extends GraphQLVariables,
118154
> {
119155
operationName: string
120156
query: string
121157
variables: Variables
158+
pubsub: GraphQLSubscriptionHandlerPubsub<Query>
122159
}
123160

124161
export function createGraphQLSubscriptionHandler(
125-
webSocketLink: WebSocketLink,
162+
internalPubsub: GraphQLInternalPubsub,
126163
): GraphQLSubscriptionHandler {
127164
return (operationName, resolver) => {
128-
const webSocketHandler = webSocketLink.addEventListener(
165+
const webSocketHandler = internalPubsub.webSocketLink.addEventListener(
129166
'connection',
130167
({ client }) => {
131168
client.addEventListener('message', async (event) => {
132169
if (typeof event.data !== 'string') {
133170
return
134171
}
135172

136-
const message = jsonParse(event.data)
173+
const message = jsonParse<GraphQLWebSocketOutgoingMessage>(event.data)
137174

138175
if (
139176
message != null &&
@@ -148,13 +185,19 @@ export function createGraphQLSubscriptionHandler(
148185
node.operationType === OperationTypeNode.SUBSCRIPTION &&
149186
node.operationName === operationName
150187
) {
188+
const pubsub = new GraphQLSubscriptionHandlerPubsub({
189+
internalPubsub,
190+
subscriptionId: message.id,
191+
})
192+
151193
/**
152194
* @todo Add the path parameters from the pubsub URL.
153195
*/
154196
resolver({
155197
operationName: node.operationName,
156198
query: message.payload.query,
157-
variables: message.payload.variables,
199+
variables: message.payload.variables as any,
200+
pubsub,
158201
})
159202
}
160203
}
@@ -165,3 +208,18 @@ export function createGraphQLSubscriptionHandler(
165208
return webSocketHandler
166209
}
167210
}
211+
212+
class GraphQLSubscriptionHandlerPubsub<Query extends GraphQLQuery> {
213+
constructor(
214+
private readonly args: {
215+
internalPubsub: GraphQLInternalPubsub
216+
subscriptionId: string
217+
},
218+
) {}
219+
220+
public publish(payload: { data?: Query }): void {
221+
this.args.internalPubsub.pubsub.publish(payload, ({ subscription }) => {
222+
return subscription.id === this.args.subscriptionId
223+
})
224+
}
225+
}

test/typings/graphql.test-d.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,12 @@ it('graphql query cannot extract variable and reponse types', () => {
191191

192192
it('graphql mutation cannot extract variable and reponse types', () => {
193193
const createUser = parse(`
194-
mutation CreateUser {
195-
user {
196-
id
197-
}
198-
}
199-
`)
194+
mutation CreateUser {
195+
user {
196+
id
197+
}
198+
}
199+
`)
200200
graphql.mutation(createUser, () => {
201201
return HttpResponse.json({
202202
data: { arbitrary: true },
@@ -213,3 +213,37 @@ it('exposes a "subscription" method only on a GraphQL link', () => {
213213
graphql.link('http://localhost:4000').subscription,
214214
).toEqualTypeOf<GraphQLSubscriptionHandler>()
215215
})
216+
217+
it('graphql subscroption accepts matching data publish', () => {
218+
const api = graphql.link('http://localhost:4000/graphql')
219+
api.subscription<{ commentAdded: { id: string; text: string } }>(
220+
'OnCommentAdded',
221+
({ pubsub }) => {
222+
pubsub.publish({
223+
data: {
224+
commentAdded: {
225+
id: '1',
226+
text: 'Hello, world!',
227+
},
228+
},
229+
})
230+
},
231+
)
232+
})
233+
234+
it('graphql subscription does not allow mismatched data publish', () => {
235+
const api = graphql.link('http://localhost:4000/graphql')
236+
api.subscription<{ commentAdded: { id: string; text: string } }>(
237+
'OnCommentAdded',
238+
({ pubsub }) => {
239+
pubsub.publish({
240+
data: {
241+
commentAdded: {
242+
// @ts-expect-error number is not assignable to type string.
243+
id: 123,
244+
},
245+
},
246+
})
247+
},
248+
)
249+
})

0 commit comments

Comments
 (0)