@effect-gql/bun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ws.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { Effect, Stream, Queue, Deferred, Layer } from "effect"
2
+ import { GraphQLSchema } from "graphql"
3
+ import {
4
+ makeGraphQLWSHandler,
5
+ type EffectWebSocket,
6
+ type GraphQLWSOptions,
7
+ WebSocketError,
8
+ type CloseEvent,
9
+ } from "@effect-gql/core"
10
+ import type { Server, ServerWebSocket } from "bun"
11
+
12
+ /**
13
+ * Data attached to each WebSocket connection
14
+ */
15
+ interface WebSocketData {
16
+ messageQueue: Queue.Queue<string>
17
+ closedDeferred: Deferred.Deferred<CloseEvent, WebSocketError>
18
+ effectSocket: EffectWebSocket
19
+ }
20
+
21
+ /**
22
+ * Options for Bun WebSocket server
23
+ */
24
+ export interface BunWSOptions<R> extends GraphQLWSOptions<R> {
25
+ /**
26
+ * Path for WebSocket connections.
27
+ * @default "/graphql"
28
+ */
29
+ readonly path?: string
30
+ }
31
+
32
+ /**
33
+ * Create WebSocket handlers for Bun.serve().
34
+ *
35
+ * Bun has built-in WebSocket support that's configured as part of Bun.serve().
36
+ * This function returns the handlers needed to integrate GraphQL subscriptions.
37
+ *
38
+ * @param schema - The GraphQL schema with subscription definitions
39
+ * @param layer - Effect layer providing services required by resolvers
40
+ * @param options - Optional configuration and lifecycle hooks
41
+ * @returns Object containing upgrade check and WebSocket handlers
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const { upgrade, websocket } = createBunWSHandlers(schema, serviceLayer)
46
+ *
47
+ * Bun.serve({
48
+ * port: 4000,
49
+ * fetch(req, server) {
50
+ * // Try WebSocket upgrade first
51
+ * if (upgrade(req, server)) {
52
+ * return // Upgraded to WebSocket
53
+ * }
54
+ * // Handle HTTP requests...
55
+ * },
56
+ * websocket,
57
+ * })
58
+ * ```
59
+ */
60
+ export const createBunWSHandlers = <R>(
61
+ schema: GraphQLSchema,
62
+ layer: Layer.Layer<R>,
63
+ options?: BunWSOptions<R>
64
+ ): {
65
+ /**
66
+ * Check if request should upgrade to WebSocket and perform upgrade.
67
+ * Returns true if upgraded, false otherwise.
68
+ */
69
+ upgrade: (request: Request, server: Server<WebSocketData>) => boolean
70
+ /**
71
+ * WebSocket event handlers for Bun.serve()
72
+ */
73
+ websocket: {
74
+ open: (ws: ServerWebSocket<WebSocketData>) => void
75
+ message: (ws: ServerWebSocket<WebSocketData>, message: string | Buffer) => void
76
+ close: (ws: ServerWebSocket<WebSocketData>, code: number, reason: string) => void
77
+ error: (ws: ServerWebSocket<WebSocketData>, error: Error) => void
78
+ }
79
+ } => {
80
+ const path = options?.path ?? "/graphql"
81
+ const handler = makeGraphQLWSHandler(schema, layer, options)
82
+
83
+ // Track active connection handlers for cleanup
84
+ const activeHandlers = new Map<ServerWebSocket<WebSocketData>, Promise<void>>()
85
+
86
+ const upgrade = (request: Request, server: Server<WebSocketData>): boolean => {
87
+ const url = new URL(request.url)
88
+
89
+ // Check if this is a WebSocket upgrade request for the GraphQL path
90
+ if (url.pathname !== path) {
91
+ return false
92
+ }
93
+
94
+ const upgradeHeader = request.headers.get("upgrade")
95
+ if (upgradeHeader?.toLowerCase() !== "websocket") {
96
+ return false
97
+ }
98
+
99
+ // Check for correct subprotocol
100
+ const protocol = request.headers.get("sec-websocket-protocol")
101
+ if (!protocol?.includes("graphql-transport-ws")) {
102
+ return false
103
+ }
104
+
105
+ // Perform upgrade - data will be set in open handler
106
+ const success = server.upgrade(request, {
107
+ data: {} as WebSocketData, // Will be populated in open handler
108
+ })
109
+
110
+ return success
111
+ }
112
+
113
+ const websocket = {
114
+ open: (ws: ServerWebSocket<WebSocketData>) => {
115
+ // Create Effect-based socket wrapper
116
+ const setupEffect = Effect.gen(function* () {
117
+ const messageQueue = yield* Queue.unbounded<string>()
118
+ const closedDeferred = yield* Deferred.make<CloseEvent, WebSocketError>()
119
+
120
+ const effectSocket: EffectWebSocket = {
121
+ protocol: ws.data?.effectSocket?.protocol || "graphql-transport-ws",
122
+
123
+ send: (data: string) =>
124
+ Effect.try({
125
+ try: () => {
126
+ ws.send(data)
127
+ },
128
+ catch: (error) => new WebSocketError({ cause: error }),
129
+ }),
130
+
131
+ close: (code?: number, reason?: string) =>
132
+ Effect.sync(() => {
133
+ ws.close(code ?? 1000, reason ?? "")
134
+ }),
135
+
136
+ messages: Stream.fromQueue(messageQueue).pipe(Stream.catchAll(() => Stream.empty)),
137
+
138
+ closed: Deferred.await(closedDeferred),
139
+ }
140
+
141
+ // Store in WebSocket data
142
+ ws.data = {
143
+ messageQueue,
144
+ closedDeferred,
145
+ effectSocket,
146
+ }
147
+
148
+ return effectSocket
149
+ })
150
+
151
+ // Run setup and handler
152
+ const handlerPromise = Effect.runPromise(
153
+ setupEffect.pipe(
154
+ Effect.flatMap((effectSocket) => handler(effectSocket)),
155
+ Effect.catchAllCause(() => Effect.void)
156
+ )
157
+ )
158
+
159
+ activeHandlers.set(ws, handlerPromise)
160
+ },
161
+
162
+ message: (ws: ServerWebSocket<WebSocketData>, message: string | Buffer) => {
163
+ const data = ws.data as WebSocketData | undefined
164
+ if (data?.messageQueue) {
165
+ const messageStr = typeof message === "string" ? message : message.toString()
166
+ Effect.runPromise(Queue.offer(data.messageQueue, messageStr)).catch(() => {
167
+ // Queue might be shutdown
168
+ })
169
+ }
170
+ },
171
+
172
+ close: (ws: ServerWebSocket<WebSocketData>, code: number, reason: string) => {
173
+ const data = ws.data as WebSocketData | undefined
174
+ if (data) {
175
+ Effect.runPromise(
176
+ Effect.all([
177
+ Queue.shutdown(data.messageQueue),
178
+ Deferred.succeed(data.closedDeferred, { code, reason }),
179
+ ])
180
+ ).catch(() => {
181
+ // Already completed
182
+ })
183
+ }
184
+ activeHandlers.delete(ws)
185
+ },
186
+
187
+ error: (ws: ServerWebSocket<WebSocketData>, error: Error) => {
188
+ const data = ws.data as WebSocketData | undefined
189
+ if (data) {
190
+ Effect.runPromise(
191
+ Deferred.fail(data.closedDeferred, new WebSocketError({ cause: error }))
192
+ ).catch(() => {
193
+ // Already completed
194
+ })
195
+ }
196
+ },
197
+ }
198
+
199
+ return { upgrade, websocket }
200
+ }
201
+
202
+ /**
203
+ * Convert a Bun ServerWebSocket to an EffectWebSocket.
204
+ *
205
+ * This is a lower-level utility for custom WebSocket handling.
206
+ * Most users should use createBunWSHandlers() instead.
207
+ *
208
+ * @param ws - The Bun ServerWebSocket instance
209
+ * @returns An EffectWebSocket that can be used with makeGraphQLWSHandler
210
+ */
211
+ export const toBunEffectWebSocket = (
212
+ ws: ServerWebSocket<WebSocketData>
213
+ ): Effect.Effect<EffectWebSocket, never, never> =>
214
+ Effect.gen(function* () {
215
+ const messageQueue = yield* Queue.unbounded<string>()
216
+ const closedDeferred = yield* Deferred.make<CloseEvent, WebSocketError>()
217
+
218
+ const effectSocket: EffectWebSocket = {
219
+ protocol: "graphql-transport-ws",
220
+
221
+ send: (data: string) =>
222
+ Effect.try({
223
+ try: () => {
224
+ ws.send(data)
225
+ },
226
+ catch: (error) => new WebSocketError({ cause: error }),
227
+ }),
228
+
229
+ close: (code?: number, reason?: string) =>
230
+ Effect.sync(() => {
231
+ ws.close(code ?? 1000, reason ?? "")
232
+ }),
233
+
234
+ messages: Stream.fromQueue(messageQueue).pipe(Stream.catchAll(() => Stream.empty)),
235
+
236
+ closed: Deferred.await(closedDeferred),
237
+ }
238
+
239
+ // Store in WebSocket data for event handlers
240
+ ws.data = {
241
+ messageQueue,
242
+ closedDeferred,
243
+ effectSocket,
244
+ }
245
+
246
+ return effectSocket
247
+ })