@effect-gql/node 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/dist/ws.js ADDED
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachWebSocketToServer = exports.createGraphQLWSServer = exports.toEffectWebSocket = void 0;
4
+ const effect_1 = require("effect");
5
+ const ws_1 = require("ws");
6
+ const core_1 = require("@effect-gql/core");
7
+ /**
8
+ * Convert a Node.js WebSocket (from 'ws' library) to an EffectWebSocket.
9
+ *
10
+ * This creates an Effect-based wrapper around the ws WebSocket instance,
11
+ * providing a Stream for incoming messages and Effect-based send/close operations.
12
+ *
13
+ * @param ws - The WebSocket instance from the 'ws' library
14
+ * @returns An EffectWebSocket that can be used with makeGraphQLWSHandler
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * wss.on("connection", (ws, req) => {
19
+ * const effectSocket = toEffectWebSocket(ws)
20
+ * Effect.runPromise(handler(effectSocket))
21
+ * })
22
+ * ```
23
+ */
24
+ const toEffectWebSocket = (ws) => (0, core_1.toEffectWebSocketFromWs)(ws);
25
+ exports.toEffectWebSocket = toEffectWebSocket;
26
+ /**
27
+ * Create a WebSocket server that handles GraphQL subscriptions.
28
+ *
29
+ * This function creates a WebSocketServer and returns utilities for
30
+ * integrating it with an HTTP server via the upgrade event.
31
+ *
32
+ * @param schema - The GraphQL schema with subscription definitions
33
+ * @param layer - Effect layer providing services required by resolvers
34
+ * @param options - Optional configuration and lifecycle hooks
35
+ * @returns Object containing the WebSocketServer and handlers
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const httpServer = createServer(requestHandler)
40
+ * const { wss, handleUpgrade } = createGraphQLWSServer(schema, serviceLayer)
41
+ *
42
+ * httpServer.on("upgrade", (request, socket, head) => {
43
+ * if (request.url === "/graphql") {
44
+ * handleUpgrade(request, socket, head)
45
+ * }
46
+ * })
47
+ *
48
+ * httpServer.listen(4000)
49
+ * ```
50
+ */
51
+ const createGraphQLWSServer = (schema, layer, options) => {
52
+ const wss = new ws_1.WebSocketServer({ noServer: true });
53
+ const path = options?.path ?? "/graphql";
54
+ // Create the handler from core
55
+ const handler = (0, core_1.makeGraphQLWSHandler)(schema, layer, options);
56
+ // Track active connections for cleanup
57
+ const activeConnections = new Set();
58
+ wss.on("connection", (ws, _request) => {
59
+ activeConnections.add(ws);
60
+ const effectSocket = (0, exports.toEffectWebSocket)(ws);
61
+ // Run the handler
62
+ effect_1.Effect.runPromise(handler(effectSocket).pipe(effect_1.Effect.catchAll((error) => effect_1.Effect.logError("GraphQL WebSocket handler error", error)))).finally(() => {
63
+ activeConnections.delete(ws);
64
+ });
65
+ });
66
+ const handleUpgrade = (request, socket, head) => {
67
+ // Check if this is the GraphQL WebSocket path
68
+ const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
69
+ if (url.pathname !== path) {
70
+ socket.destroy();
71
+ return;
72
+ }
73
+ // Check for correct WebSocket subprotocol
74
+ const protocol = request.headers["sec-websocket-protocol"];
75
+ if (!protocol?.includes("graphql-transport-ws")) {
76
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
77
+ socket.destroy();
78
+ return;
79
+ }
80
+ wss.handleUpgrade(request, socket, head, (ws) => {
81
+ wss.emit("connection", ws, request);
82
+ });
83
+ };
84
+ const close = async () => {
85
+ // Close all active connections
86
+ for (const ws of activeConnections) {
87
+ ws.close(1001, "Server shutting down");
88
+ }
89
+ activeConnections.clear();
90
+ // Close the WebSocket server
91
+ return new Promise((resolve, reject) => {
92
+ wss.close((error) => {
93
+ if (error)
94
+ reject(error);
95
+ else
96
+ resolve();
97
+ });
98
+ });
99
+ };
100
+ return { wss, handleUpgrade, close };
101
+ };
102
+ exports.createGraphQLWSServer = createGraphQLWSServer;
103
+ /**
104
+ * Attach WebSocket subscription support to an existing HTTP server.
105
+ *
106
+ * This is a convenience function that creates a GraphQL WebSocket server
107
+ * and attaches it to an HTTP server's upgrade event.
108
+ *
109
+ * @param server - The HTTP server to attach to
110
+ * @param schema - The GraphQL schema with subscription definitions
111
+ * @param layer - Effect layer providing services required by resolvers
112
+ * @param options - Optional configuration and lifecycle hooks
113
+ * @returns Cleanup function to close the WebSocket server
114
+ *
115
+ * @example
116
+ * ```typescript
117
+ * const httpServer = createServer(requestHandler)
118
+ *
119
+ * const cleanup = attachWebSocketToServer(httpServer, schema, serviceLayer, {
120
+ * path: "/graphql",
121
+ * })
122
+ *
123
+ * httpServer.listen(4000)
124
+ *
125
+ * // Later, to cleanup:
126
+ * await cleanup()
127
+ * ```
128
+ */
129
+ const attachWebSocketToServer = (server, schema, layer, options) => {
130
+ const { handleUpgrade, close } = (0, exports.createGraphQLWSServer)(schema, layer, options);
131
+ server.on("upgrade", (request, socket, head) => {
132
+ handleUpgrade(request, socket, head);
133
+ });
134
+ return { close };
135
+ };
136
+ exports.attachWebSocketToServer = attachWebSocketToServer;
137
+ //# sourceMappingURL=ws.js.map
package/dist/ws.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws.js","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@effect-gql/node",
3
+ "version": "0.1.0",
4
+ "description": "Node.js HTTP server integration for @effect-gql/core",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src"
16
+ ],
17
+ "peerDependencies": {
18
+ "@effect-gql/core": "^0.1.0",
19
+ "@effect/platform": "^0.94.0",
20
+ "@effect/platform-node": "^0.104.0",
21
+ "effect": "^3.19.0",
22
+ "graphql": "^16.0.0",
23
+ "ws": "^8.14.0"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "ws": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "devDependencies": {
31
+ "@effect-gql/core": "*",
32
+ "@effect/platform": "^0.94.0",
33
+ "@effect/platform-node": "^0.104.0",
34
+ "effect": "^3.19.13",
35
+ "graphql": "^16.0.0",
36
+ "graphql-ws": "^6.0.6",
37
+ "ws": "^8.18.0",
38
+ "@types/ws": "^8.5.0"
39
+ },
40
+ "keywords": [
41
+ "effect",
42
+ "graphql",
43
+ "node",
44
+ "http",
45
+ "server"
46
+ ],
47
+ "license": "MIT",
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "dev": "tsc --watch",
51
+ "clean": "rm -rf dist",
52
+ "test": "vitest run",
53
+ "test:unit": "vitest run test/unit",
54
+ "test:integration": "vitest run test/integration",
55
+ "test:watch": "vitest"
56
+ }
57
+ }
@@ -0,0 +1,32 @@
1
+ import type { IncomingHttpHeaders } from "node:http"
2
+
3
+ /**
4
+ * Convert Node.js IncomingHttpHeaders to web standard Headers.
5
+ *
6
+ * This handles the difference between Node.js headers (which can be
7
+ * string | string[] | undefined) and web Headers (which are always strings).
8
+ *
9
+ * @param nodeHeaders - Headers from IncomingMessage.headers
10
+ * @returns A web standard Headers object
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { toWebHeaders } from "@effect-gql/node"
15
+ *
16
+ * const webHeaders = toWebHeaders(req.headers)
17
+ * const auth = webHeaders.get("authorization")
18
+ * ```
19
+ */
20
+ export const toWebHeaders = (nodeHeaders: IncomingHttpHeaders): Headers => {
21
+ const headers = new Headers()
22
+ for (const [key, value] of Object.entries(nodeHeaders)) {
23
+ if (value) {
24
+ if (Array.isArray(value)) {
25
+ value.forEach((v) => headers.append(key, v))
26
+ } else {
27
+ headers.set(key, value)
28
+ }
29
+ }
30
+ }
31
+ return headers
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { serve, type ServeOptions } from "./serve"
2
+
3
+ // HTTP utilities
4
+ export { toWebHeaders } from "./http-utils"
5
+
6
+ // WebSocket subscription support
7
+ export {
8
+ toEffectWebSocket,
9
+ createGraphQLWSServer,
10
+ attachWebSocketToServer,
11
+ type NodeWSOptions,
12
+ } from "./ws"
13
+
14
+ // SSE (Server-Sent Events) subscription support
15
+ export { createSSEHandler, createSSEServer, type NodeSSEOptions } from "./sse"
package/src/serve.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { HttpApp, HttpRouter, HttpServer } from "@effect/platform"
3
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
4
+ import { createServer } from "node:http"
5
+ import type { GraphQLSchema } from "graphql"
6
+ import type { GraphQLWSOptions } from "@effect-gql/core"
7
+ import { toWebHeaders } from "./http-utils"
8
+
9
+ /**
10
+ * Configuration for WebSocket subscriptions
11
+ */
12
+ export interface SubscriptionsConfig<R> extends GraphQLWSOptions<R> {
13
+ /**
14
+ * The GraphQL schema (required for subscriptions).
15
+ * Must be the same schema used to create the router.
16
+ */
17
+ readonly schema: GraphQLSchema
18
+ /**
19
+ * Path for WebSocket connections.
20
+ * @default "/graphql"
21
+ */
22
+ readonly path?: string
23
+ }
24
+
25
+ /**
26
+ * Options for the Node.js GraphQL server
27
+ */
28
+ export interface ServeOptions<R = never> {
29
+ /** Port to listen on (default: 4000) */
30
+ readonly port?: number
31
+ /** Hostname to bind to (default: "0.0.0.0") */
32
+ readonly host?: string
33
+ /** Callback when server starts */
34
+ readonly onStart?: (url: string) => void
35
+ /**
36
+ * Enable WebSocket subscriptions.
37
+ * When provided, the server will handle WebSocket upgrade requests
38
+ * for GraphQL subscriptions using the graphql-ws protocol.
39
+ */
40
+ readonly subscriptions?: SubscriptionsConfig<R>
41
+ }
42
+
43
+ /**
44
+ * Start a Node.js HTTP server with the given router.
45
+ *
46
+ * This is the main entry point for running a GraphQL server on Node.js.
47
+ * It handles all the Effect runtime setup and server lifecycle.
48
+ *
49
+ * @param router - The HttpRouter to serve (typically from makeGraphQLRouter or toRouter)
50
+ * @param layer - Layer providing the router's service dependencies
51
+ * @param options - Server configuration options
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import { makeGraphQLRouter } from "@effect-gql/core"
56
+ * import { serve } from "@effect-gql/node"
57
+ *
58
+ * const schema = GraphQLSchemaBuilder.empty
59
+ * .query("hello", { type: S.String, resolve: () => Effect.succeed("world") })
60
+ * .buildSchema()
61
+ *
62
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
63
+ *
64
+ * // Without subscriptions
65
+ * serve(router, serviceLayer, {
66
+ * port: 4000,
67
+ * onStart: (url) => console.log(`Server running at ${url}`)
68
+ * })
69
+ *
70
+ * // With subscriptions
71
+ * serve(router, serviceLayer, {
72
+ * port: 4000,
73
+ * subscriptions: { schema },
74
+ * onStart: (url) => console.log(`Server running at ${url}`)
75
+ * })
76
+ * ```
77
+ */
78
+ export const serve = <E, R, RE>(
79
+ router: HttpRouter.HttpRouter<E, R>,
80
+ layer: Layer.Layer<R, RE>,
81
+ options: ServeOptions<R> = {}
82
+ ): void => {
83
+ const { port = 4000, host = "0.0.0.0", onStart, subscriptions } = options
84
+
85
+ if (subscriptions) {
86
+ // With WebSocket subscriptions - we need to manage the HTTP server ourselves
87
+ serveWithSubscriptions(router, layer, port, host, subscriptions, onStart)
88
+ } else {
89
+ // Without subscriptions - use the standard Effect approach
90
+ const app = router.pipe(
91
+ Effect.catchAllCause((cause) => Effect.die(cause)),
92
+ HttpServer.serve()
93
+ )
94
+
95
+ const serverLayer = NodeHttpServer.layer(() => createServer(), { port })
96
+ const fullLayer = Layer.merge(serverLayer, layer)
97
+
98
+ if (onStart) {
99
+ onStart(`http://${host === "0.0.0.0" ? "localhost" : host}:${port}`)
100
+ }
101
+
102
+ NodeRuntime.runMain(Layer.launch(Layer.provide(app, fullLayer)))
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Internal implementation for serving with WebSocket subscriptions.
108
+ * Uses a custom HTTP server setup to enable WebSocket upgrade handling.
109
+ */
110
+ function serveWithSubscriptions<E, R, RE>(
111
+ router: HttpRouter.HttpRouter<E, R>,
112
+ layer: Layer.Layer<R, RE>,
113
+ port: number,
114
+ host: string,
115
+ subscriptions: SubscriptionsConfig<R>,
116
+ onStart?: (url: string) => void
117
+ ): void {
118
+ // Dynamically import ws module to keep it optional
119
+ const importWs = Effect.tryPromise({
120
+ try: () => import("./ws"),
121
+ catch: (error) => error as Error,
122
+ })
123
+
124
+ Effect.runPromise(
125
+ importWs.pipe(
126
+ Effect.catchAll((error) =>
127
+ Effect.logError("Failed to load WebSocket support", error).pipe(
128
+ Effect.andThen(Effect.logError("Make sure 'ws' package is installed: npm install ws")),
129
+ Effect.andThen(Effect.sync(() => process.exit(1))),
130
+ Effect.andThen(Effect.fail(error))
131
+ )
132
+ )
133
+ )
134
+ ).then(({ createGraphQLWSServer }) => {
135
+ // Create the web handler from the Effect router
136
+ const { handler } = HttpApp.toWebHandlerLayer(router, layer)
137
+
138
+ // Create the HTTP server
139
+ const httpServer = createServer(async (req, res) => {
140
+ try {
141
+ // Collect request body
142
+ const chunks: Buffer[] = []
143
+ for await (const chunk of req) {
144
+ chunks.push(chunk as Buffer)
145
+ }
146
+ const body = Buffer.concat(chunks).toString()
147
+
148
+ // Convert Node.js request to web standard Request
149
+ // Use URL constructor for safe URL parsing (avoids injection via req.url)
150
+ const baseUrl = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`
151
+ const url = new URL(req.url || "/", baseUrl).href
152
+ const headers = toWebHeaders(req.headers)
153
+
154
+ const webRequest = new Request(url, {
155
+ method: req.method,
156
+ headers,
157
+ body: ["GET", "HEAD"].includes(req.method!) ? undefined : body,
158
+ })
159
+
160
+ // Process through Effect handler
161
+ const webResponse = await handler(webRequest)
162
+
163
+ // Write response
164
+ res.statusCode = webResponse.status
165
+ webResponse.headers.forEach((value, key) => {
166
+ res.setHeader(key, value)
167
+ })
168
+ const responseBody = await webResponse.text()
169
+ res.end(responseBody)
170
+ } catch (error) {
171
+ res.statusCode = 500
172
+ res.end(JSON.stringify({ error: String(error) }))
173
+ }
174
+ })
175
+
176
+ // Create WebSocket server for subscriptions
177
+ const { handleUpgrade, close: closeWS } = createGraphQLWSServer(
178
+ subscriptions.schema,
179
+ layer as Layer.Layer<R>,
180
+ {
181
+ path: subscriptions.path,
182
+ complexity: subscriptions.complexity,
183
+ fieldComplexities: subscriptions.fieldComplexities,
184
+ onConnect: subscriptions.onConnect,
185
+ onDisconnect: subscriptions.onDisconnect,
186
+ onSubscribe: subscriptions.onSubscribe,
187
+ onComplete: subscriptions.onComplete,
188
+ onError: subscriptions.onError,
189
+ }
190
+ )
191
+
192
+ // Attach WebSocket upgrade handler
193
+ httpServer.on("upgrade", (request, socket, head) => {
194
+ handleUpgrade(request, socket, head)
195
+ })
196
+
197
+ // Handle shutdown
198
+ process.on("SIGINT", async () => {
199
+ await closeWS()
200
+ httpServer.close()
201
+ process.exit(0)
202
+ })
203
+
204
+ process.on("SIGTERM", async () => {
205
+ await closeWS()
206
+ httpServer.close()
207
+ process.exit(0)
208
+ })
209
+
210
+ // Start listening
211
+ httpServer.listen(port, host, () => {
212
+ if (onStart) {
213
+ onStart(`http://${host === "0.0.0.0" ? "localhost" : host}:${port}`)
214
+ }
215
+ })
216
+ })
217
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { Effect, Layer, Stream, Deferred } from "effect"
2
+ import type { IncomingMessage, ServerResponse } from "node:http"
3
+ import { GraphQLSchema } from "graphql"
4
+ import {
5
+ makeGraphQLSSEHandler,
6
+ formatSSEMessage,
7
+ SSE_HEADERS,
8
+ type GraphQLSSEOptions,
9
+ type SSESubscriptionRequest,
10
+ SSEError,
11
+ } from "@effect-gql/core"
12
+ import { toWebHeaders } from "./http-utils"
13
+
14
+ /**
15
+ * Options for Node.js SSE handler
16
+ */
17
+ export interface NodeSSEOptions<R> extends GraphQLSSEOptions<R> {
18
+ /**
19
+ * Path for SSE connections.
20
+ * @default "/graphql/stream"
21
+ */
22
+ readonly path?: string
23
+ }
24
+
25
+ /**
26
+ * Create an SSE handler for Node.js HTTP server.
27
+ *
28
+ * This function creates a handler that can process SSE subscription requests.
29
+ * It handles:
30
+ * - Parsing the GraphQL subscription request from the HTTP body
31
+ * - Setting up the SSE connection with proper headers
32
+ * - Streaming subscription events to the client
33
+ * - Detecting client disconnection and cleaning up
34
+ *
35
+ * @param schema - The GraphQL schema with subscription definitions
36
+ * @param layer - Effect layer providing services required by resolvers
37
+ * @param options - Optional lifecycle hooks and configuration
38
+ * @returns A request handler function
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * import { createServer } from "node:http"
43
+ * import { createSSEHandler } from "@effect-gql/node"
44
+ *
45
+ * const sseHandler = createSSEHandler(schema, serviceLayer, {
46
+ * path: "/graphql/stream",
47
+ * onConnect: (request, headers) => Effect.gen(function* () {
48
+ * const user = yield* AuthService.validateToken(headers.get("authorization"))
49
+ * return { user }
50
+ * }),
51
+ * })
52
+ *
53
+ * const server = createServer((req, res) => {
54
+ * const url = new URL(req.url, `http://${req.headers.host}`)
55
+ * if (url.pathname === "/graphql/stream" && req.method === "POST") {
56
+ * sseHandler(req, res)
57
+ * } else {
58
+ * // Handle other requests...
59
+ * }
60
+ * })
61
+ *
62
+ * server.listen(4000)
63
+ * ```
64
+ */
65
+ export const createSSEHandler = <R>(
66
+ schema: GraphQLSchema,
67
+ layer: Layer.Layer<R>,
68
+ options?: NodeSSEOptions<R>
69
+ ): ((req: IncomingMessage, res: ServerResponse) => Promise<void>) => {
70
+ const sseHandler = makeGraphQLSSEHandler(schema, layer, options)
71
+
72
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
73
+ // Check Accept header for SSE support
74
+ const accept = req.headers.accept ?? ""
75
+ if (!accept.includes("text/event-stream") && !accept.includes("*/*")) {
76
+ res.statusCode = 406
77
+ res.end(
78
+ JSON.stringify({
79
+ errors: [{ message: "Client must accept text/event-stream" }],
80
+ })
81
+ )
82
+ return
83
+ }
84
+
85
+ // Read the request body
86
+ let body: string
87
+ try {
88
+ body = await readBody(req)
89
+ } catch {
90
+ res.statusCode = 400
91
+ res.end(
92
+ JSON.stringify({
93
+ errors: [{ message: "Failed to read request body" }],
94
+ })
95
+ )
96
+ return
97
+ }
98
+
99
+ // Parse the GraphQL request
100
+ let request: SSESubscriptionRequest
101
+ try {
102
+ const parsed = JSON.parse(body)
103
+ if (typeof parsed.query !== "string") {
104
+ throw new Error("Missing query")
105
+ }
106
+ request = {
107
+ query: parsed.query,
108
+ variables: parsed.variables,
109
+ operationName: parsed.operationName,
110
+ extensions: parsed.extensions,
111
+ }
112
+ } catch {
113
+ res.statusCode = 400
114
+ res.end(
115
+ JSON.stringify({
116
+ errors: [{ message: "Invalid GraphQL request body" }],
117
+ })
118
+ )
119
+ return
120
+ }
121
+
122
+ // Convert Node.js headers to web Headers
123
+ const headers = toWebHeaders(req.headers)
124
+
125
+ // Set SSE headers
126
+ res.writeHead(200, SSE_HEADERS)
127
+
128
+ // Get the event stream
129
+ const eventStream = sseHandler(request, headers)
130
+
131
+ // Create the streaming effect
132
+ const streamEffect = Effect.gen(function* () {
133
+ // Track client disconnection
134
+ const clientDisconnected = yield* Deferred.make<void, SSEError>()
135
+
136
+ req.on("close", () => {
137
+ Effect.runPromise(Deferred.succeed(clientDisconnected, undefined)).catch(() => {})
138
+ })
139
+
140
+ req.on("error", (error) => {
141
+ Effect.runPromise(Deferred.fail(clientDisconnected, new SSEError({ cause: error }))).catch(
142
+ () => {}
143
+ )
144
+ })
145
+
146
+ // Stream events to the client
147
+ const runStream = Stream.runForEach(eventStream, (event) =>
148
+ Effect.async<void, SSEError>((resume) => {
149
+ const message = formatSSEMessage(event)
150
+ res.write(message, (error) => {
151
+ if (error) {
152
+ resume(Effect.fail(new SSEError({ cause: error })))
153
+ } else {
154
+ resume(Effect.succeed(undefined))
155
+ }
156
+ })
157
+ })
158
+ )
159
+
160
+ // Race between stream completion and client disconnection
161
+ yield* Effect.race(
162
+ runStream.pipe(Effect.catchAll((error) => Effect.logWarning("SSE stream error", error))),
163
+ Deferred.await(clientDisconnected)
164
+ )
165
+ })
166
+
167
+ await Effect.runPromise(
168
+ streamEffect.pipe(
169
+ Effect.ensuring(Effect.sync(() => res.end())),
170
+ Effect.catchAll(() => Effect.void)
171
+ )
172
+ )
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Read the request body as a string.
178
+ */
179
+ function readBody(req: IncomingMessage): Promise<string> {
180
+ return new Promise((resolve, reject) => {
181
+ const chunks: Buffer[] = []
182
+ req.on("data", (chunk: Buffer) => chunks.push(chunk))
183
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()))
184
+ req.on("error", reject)
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Create SSE middleware that can be used with the serve() function.
190
+ *
191
+ * This returns an object that can be used to integrate SSE subscriptions
192
+ * with the HTTP server when using the custom subscription mode.
193
+ *
194
+ * @param schema - The GraphQL schema with subscription definitions
195
+ * @param layer - Effect layer providing services required by resolvers
196
+ * @param options - Optional lifecycle hooks and configuration
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * // In serve.ts with custom HTTP server setup
201
+ * const sseServer = createSSEServer(schema, layer, { path: "/graphql/stream" })
202
+ *
203
+ * httpServer.on("request", (req, res) => {
204
+ * if (sseServer.shouldHandle(req)) {
205
+ * sseServer.handle(req, res)
206
+ * }
207
+ * })
208
+ * ```
209
+ */
210
+ export const createSSEServer = <R>(
211
+ schema: GraphQLSchema,
212
+ layer: Layer.Layer<R>,
213
+ options?: NodeSSEOptions<R>
214
+ ): {
215
+ /** Path this SSE server handles */
216
+ readonly path: string
217
+ /** Check if a request should be handled by this SSE server */
218
+ shouldHandle: (req: IncomingMessage) => boolean
219
+ /** Handle an SSE request */
220
+ handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>
221
+ } => {
222
+ const path = options?.path ?? "/graphql/stream"
223
+ const handler = createSSEHandler(schema, layer, options)
224
+
225
+ return {
226
+ path,
227
+ shouldHandle: (req: IncomingMessage) => {
228
+ if (req.method !== "POST") return false
229
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`)
230
+ return url.pathname === path
231
+ },
232
+ handle: handler,
233
+ }
234
+ }