@effect-gql/express 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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2025 Nick Fisher
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,12 @@
1
+ import type { IncomingHttpHeaders } from "node:http";
2
+ /**
3
+ * Convert Node.js/Express IncomingHttpHeaders to web standard Headers.
4
+ *
5
+ * This handles the difference between Node.js headers (which can be
6
+ * string | string[] | undefined) and web Headers (which are always strings).
7
+ *
8
+ * @param nodeHeaders - Headers from req.headers
9
+ * @returns A web standard Headers object
10
+ */
11
+ export declare const toWebHeaders: (nodeHeaders: IncomingHttpHeaders) => Headers;
12
+ //# sourceMappingURL=http-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-utils.d.ts","sourceRoot":"","sources":["../src/http-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GAAI,aAAa,mBAAmB,KAAG,OAY/D,CAAA"}
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toWebHeaders = void 0;
4
+ /**
5
+ * Convert Node.js/Express IncomingHttpHeaders to web standard Headers.
6
+ *
7
+ * This handles the difference between Node.js headers (which can be
8
+ * string | string[] | undefined) and web Headers (which are always strings).
9
+ *
10
+ * @param nodeHeaders - Headers from req.headers
11
+ * @returns A web standard Headers object
12
+ */
13
+ const toWebHeaders = (nodeHeaders) => {
14
+ const headers = new Headers();
15
+ for (const [key, value] of Object.entries(nodeHeaders)) {
16
+ if (value) {
17
+ if (Array.isArray(value)) {
18
+ value.forEach((v) => headers.append(key, v));
19
+ }
20
+ else {
21
+ headers.set(key, value);
22
+ }
23
+ }
24
+ }
25
+ return headers;
26
+ };
27
+ exports.toWebHeaders = toWebHeaders;
28
+ //# sourceMappingURL=http-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-utils.js","sourceRoot":"","sources":["../src/http-utils.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;GAQG;AACI,MAAM,YAAY,GAAG,CAAC,WAAgC,EAAW,EAAE;IACxE,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA;IAC7B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC;QACvD,IAAI,KAAK,EAAE,CAAC;YACV,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;gBACzB,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAA;YAC9C,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACzB,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAZY,QAAA,YAAY,gBAYxB"}
@@ -0,0 +1,4 @@
1
+ export { toMiddleware } from "./middleware";
2
+ export { attachWebSocket, type ExpressWSOptions } from "./ws";
3
+ export { sseMiddleware, createSSEHandler, type ExpressSSEOptions } from "./sse";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAG3C,OAAO,EAAE,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,MAAM,CAAA;AAG7D,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSSEHandler = exports.sseMiddleware = exports.attachWebSocket = exports.toMiddleware = void 0;
4
+ var middleware_1 = require("./middleware");
5
+ Object.defineProperty(exports, "toMiddleware", { enumerable: true, get: function () { return middleware_1.toMiddleware; } });
6
+ // WebSocket subscription support
7
+ var ws_1 = require("./ws");
8
+ Object.defineProperty(exports, "attachWebSocket", { enumerable: true, get: function () { return ws_1.attachWebSocket; } });
9
+ // SSE (Server-Sent Events) subscription support
10
+ var sse_1 = require("./sse");
11
+ Object.defineProperty(exports, "sseMiddleware", { enumerable: true, get: function () { return sse_1.sseMiddleware; } });
12
+ Object.defineProperty(exports, "createSSEHandler", { enumerable: true, get: function () { return sse_1.createSSEHandler; } });
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,2CAA2C;AAAlC,0GAAA,YAAY,OAAA;AAErB,iCAAiC;AACjC,2BAA6D;AAApD,qGAAA,eAAe,OAAA;AAExB,gDAAgD;AAChD,6BAA+E;AAAtE,oGAAA,aAAa,OAAA;AAAE,uGAAA,gBAAgB,OAAA"}
@@ -0,0 +1,30 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "@effect/platform";
3
+ import type { RequestHandler } from "express";
4
+ /**
5
+ * Convert an HttpRouter to Express middleware.
6
+ *
7
+ * This creates Express-compatible middleware that can be mounted on any Express app.
8
+ * The middleware converts Express requests to web standard Requests, processes them
9
+ * through the Effect router, and writes the response back to Express.
10
+ *
11
+ * @param router - The HttpRouter to convert (typically from makeGraphQLRouter or toRouter)
12
+ * @param layer - Layer providing any services required by the router
13
+ * @returns Express middleware function
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import express from "express"
18
+ * import { makeGraphQLRouter } from "@effect-gql/core"
19
+ * import { toMiddleware } from "@effect-gql/express"
20
+ * import { Layer } from "effect"
21
+ *
22
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
23
+ *
24
+ * const app = express()
25
+ * app.use(toMiddleware(router, Layer.empty))
26
+ * app.listen(4000, () => console.log("Server running on http://localhost:4000"))
27
+ * ```
28
+ */
29
+ export declare const toMiddleware: <E, R, RE>(router: HttpRouter.HttpRouter<E, R>, layer: Layer.Layer<R, RE>) => RequestHandler;
30
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAC9B,OAAO,EAAW,UAAU,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EAAmC,cAAc,EAAE,MAAM,SAAS,CAAA;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,EAAE,CAAC,EAAE,EAAE,EACnC,QAAQ,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EACnC,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KACxB,cA+BF,CAAA"}
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toMiddleware = void 0;
4
+ const platform_1 = require("@effect/platform");
5
+ const http_utils_1 = require("./http-utils");
6
+ /**
7
+ * Convert an HttpRouter to Express middleware.
8
+ *
9
+ * This creates Express-compatible middleware that can be mounted on any Express app.
10
+ * The middleware converts Express requests to web standard Requests, processes them
11
+ * through the Effect router, and writes the response back to Express.
12
+ *
13
+ * @param router - The HttpRouter to convert (typically from makeGraphQLRouter or toRouter)
14
+ * @param layer - Layer providing any services required by the router
15
+ * @returns Express middleware function
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import express from "express"
20
+ * import { makeGraphQLRouter } from "@effect-gql/core"
21
+ * import { toMiddleware } from "@effect-gql/express"
22
+ * import { Layer } from "effect"
23
+ *
24
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
25
+ *
26
+ * const app = express()
27
+ * app.use(toMiddleware(router, Layer.empty))
28
+ * app.listen(4000, () => console.log("Server running on http://localhost:4000"))
29
+ * ```
30
+ */
31
+ const toMiddleware = (router, layer) => {
32
+ const { handler } = platform_1.HttpApp.toWebHandlerLayer(router, layer);
33
+ return async (req, res, next) => {
34
+ try {
35
+ // Convert Express request to web standard Request
36
+ // Use URL constructor for safe URL parsing (avoids Host header injection)
37
+ const baseUrl = `${req.protocol}://${req.hostname}`;
38
+ const url = new URL(req.originalUrl || "/", baseUrl).href;
39
+ const headers = (0, http_utils_1.toWebHeaders)(req.headers);
40
+ const webRequest = new Request(url, {
41
+ method: req.method,
42
+ headers,
43
+ body: ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body),
44
+ });
45
+ // Process through Effect handler
46
+ const webResponse = await handler(webRequest);
47
+ // Write response back to Express
48
+ res.status(webResponse.status);
49
+ webResponse.headers.forEach((value, key) => {
50
+ res.setHeader(key, value);
51
+ });
52
+ const body = await webResponse.text();
53
+ res.send(body);
54
+ }
55
+ catch (error) {
56
+ next(error);
57
+ }
58
+ };
59
+ };
60
+ exports.toMiddleware = toMiddleware;
61
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":";;;AACA,+CAAsD;AAEtD,6CAA2C;AAE3C;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACI,MAAM,YAAY,GAAG,CAC1B,MAAmC,EACnC,KAAyB,EACT,EAAE;IAClB,MAAM,EAAE,OAAO,EAAE,GAAG,kBAAO,CAAC,iBAAiB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAE5D,OAAO,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QAC/D,IAAI,CAAC;YACH,kDAAkD;YAClD,0EAA0E;YAC1E,MAAM,OAAO,GAAG,GAAG,GAAG,CAAC,QAAQ,MAAM,GAAG,CAAC,QAAQ,EAAE,CAAA;YACnD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,WAAW,IAAI,GAAG,EAAE,OAAO,CAAC,CAAC,IAAI,CAAA;YACzD,MAAM,OAAO,GAAG,IAAA,yBAAY,EAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YAEzC,MAAM,UAAU,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE;gBAClC,MAAM,EAAE,GAAG,CAAC,MAAM;gBAClB,OAAO;gBACP,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC;aAClF,CAAC,CAAA;YAEF,iCAAiC;YACjC,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,CAAA;YAE7C,iCAAiC;YACjC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;YAC9B,WAAW,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;gBACzC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YAC3B,CAAC,CAAC,CAAA;YACF,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,IAAI,EAAE,CAAA;YACrC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,KAAK,CAAC,CAAA;QACb,CAAC;IACH,CAAC,CAAA;AACH,CAAC,CAAA;AAlCY,QAAA,YAAY,gBAkCxB"}
package/dist/sse.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { Layer } from "effect";
2
+ import type { RequestHandler } from "express";
3
+ import { GraphQLSchema } from "graphql";
4
+ import { type GraphQLSSEOptions } from "@effect-gql/core";
5
+ /**
6
+ * Options for Express SSE middleware
7
+ */
8
+ export interface ExpressSSEOptions<R> extends GraphQLSSEOptions<R> {
9
+ /**
10
+ * Path for SSE connections.
11
+ * @default "/graphql/stream"
12
+ */
13
+ readonly path?: string;
14
+ }
15
+ /**
16
+ * Create an Express middleware for SSE subscriptions.
17
+ *
18
+ * This middleware handles POST requests to the configured path and streams
19
+ * GraphQL subscription events as Server-Sent Events.
20
+ *
21
+ * @param schema - The GraphQL schema with subscription definitions
22
+ * @param layer - Effect layer providing services required by resolvers
23
+ * @param options - Optional lifecycle hooks and configuration
24
+ * @returns An Express middleware function
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import express from "express"
29
+ * import { createServer } from "node:http"
30
+ * import { toMiddleware, sseMiddleware, attachWebSocket } from "@effect-gql/express"
31
+ * import { makeGraphQLRouter } from "@effect-gql/core"
32
+ *
33
+ * const app = express()
34
+ * app.use(express.json())
35
+ *
36
+ * // Regular GraphQL endpoint
37
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
38
+ * app.use(toMiddleware(router, Layer.empty))
39
+ *
40
+ * // SSE subscriptions endpoint
41
+ * app.use(sseMiddleware(schema, Layer.empty, {
42
+ * path: "/graphql/stream",
43
+ * onConnect: (request, headers) => Effect.gen(function* () {
44
+ * const token = headers.get("authorization")
45
+ * const user = yield* AuthService.validateToken(token)
46
+ * return { user }
47
+ * }),
48
+ * }))
49
+ *
50
+ * const server = createServer(app)
51
+ *
52
+ * // Optional: Also attach WebSocket subscriptions
53
+ * attachWebSocket(server, schema, Layer.empty)
54
+ *
55
+ * server.listen(4000)
56
+ * ```
57
+ */
58
+ export declare const sseMiddleware: <R>(schema: GraphQLSchema, layer: Layer.Layer<R>, options?: ExpressSSEOptions<R>) => RequestHandler;
59
+ /**
60
+ * Create a standalone Express route handler for SSE subscriptions.
61
+ *
62
+ * Use this if you want more control over routing than the middleware provides.
63
+ *
64
+ * @param schema - The GraphQL schema with subscription definitions
65
+ * @param layer - Effect layer providing services required by resolvers
66
+ * @param options - Optional lifecycle hooks and configuration
67
+ * @returns An Express request handler
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * import express from "express"
72
+ * import { createSSEHandler } from "@effect-gql/express"
73
+ *
74
+ * const app = express()
75
+ * app.use(express.json())
76
+ *
77
+ * const sseHandler = createSSEHandler(schema, Layer.empty)
78
+ * app.post("/graphql/stream", sseHandler)
79
+ *
80
+ * app.listen(4000)
81
+ * ```
82
+ */
83
+ export declare const createSSEHandler: <R>(schema: GraphQLSchema, layer: Layer.Layer<R>, options?: Omit<ExpressSSEOptions<R>, "path">) => RequestHandler;
84
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,EAAoB,MAAM,QAAQ,CAAA;AACxD,OAAO,KAAK,EAAmC,cAAc,EAAE,MAAM,SAAS,CAAA;AAC9E,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,EAIL,KAAK,iBAAiB,EAGvB,MAAM,kBAAkB,CAAA;AAGzB;;GAEG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,CAAE,SAAQ,iBAAiB,CAAC,CAAC,CAAC;IAChE;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,aAAa,GAAI,CAAC,EAC7B,QAAQ,aAAa,EACrB,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EACrB,UAAU,iBAAiB,CAAC,CAAC,CAAC,KAC7B,cAkGF,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,EAChC,QAAQ,aAAa,EACrB,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EACrB,UAAU,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,KAC3C,cAqFF,CAAA"}
package/dist/sse.js ADDED
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSSEHandler = exports.sseMiddleware = void 0;
4
+ const effect_1 = require("effect");
5
+ const core_1 = require("@effect-gql/core");
6
+ const http_utils_1 = require("./http-utils");
7
+ /**
8
+ * Create an Express middleware for SSE subscriptions.
9
+ *
10
+ * This middleware handles POST requests to the configured path and streams
11
+ * GraphQL subscription events as Server-Sent Events.
12
+ *
13
+ * @param schema - The GraphQL schema with subscription definitions
14
+ * @param layer - Effect layer providing services required by resolvers
15
+ * @param options - Optional lifecycle hooks and configuration
16
+ * @returns An Express middleware function
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import express from "express"
21
+ * import { createServer } from "node:http"
22
+ * import { toMiddleware, sseMiddleware, attachWebSocket } from "@effect-gql/express"
23
+ * import { makeGraphQLRouter } from "@effect-gql/core"
24
+ *
25
+ * const app = express()
26
+ * app.use(express.json())
27
+ *
28
+ * // Regular GraphQL endpoint
29
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
30
+ * app.use(toMiddleware(router, Layer.empty))
31
+ *
32
+ * // SSE subscriptions endpoint
33
+ * app.use(sseMiddleware(schema, Layer.empty, {
34
+ * path: "/graphql/stream",
35
+ * onConnect: (request, headers) => Effect.gen(function* () {
36
+ * const token = headers.get("authorization")
37
+ * const user = yield* AuthService.validateToken(token)
38
+ * return { user }
39
+ * }),
40
+ * }))
41
+ *
42
+ * const server = createServer(app)
43
+ *
44
+ * // Optional: Also attach WebSocket subscriptions
45
+ * attachWebSocket(server, schema, Layer.empty)
46
+ *
47
+ * server.listen(4000)
48
+ * ```
49
+ */
50
+ const sseMiddleware = (schema, layer, options) => {
51
+ const path = options?.path ?? "/graphql/stream";
52
+ const sseHandler = (0, core_1.makeGraphQLSSEHandler)(schema, layer, options);
53
+ return async (req, res, next) => {
54
+ // Check if this request is for our path
55
+ if (req.path !== path) {
56
+ next();
57
+ return;
58
+ }
59
+ // Only handle POST requests
60
+ if (req.method !== "POST") {
61
+ next();
62
+ return;
63
+ }
64
+ // Check Accept header for SSE support
65
+ const accept = req.headers.accept ?? "";
66
+ if (!accept.includes("text/event-stream") && !accept.includes("*/*")) {
67
+ res.status(406).json({
68
+ errors: [{ message: "Client must accept text/event-stream" }],
69
+ });
70
+ return;
71
+ }
72
+ // Parse the GraphQL request from the body
73
+ let subscriptionRequest;
74
+ try {
75
+ const body = req.body;
76
+ if (typeof body.query !== "string") {
77
+ throw new Error("Missing query");
78
+ }
79
+ subscriptionRequest = {
80
+ query: body.query,
81
+ variables: body.variables,
82
+ operationName: body.operationName,
83
+ extensions: body.extensions,
84
+ };
85
+ }
86
+ catch {
87
+ res.status(400).json({
88
+ errors: [{ message: "Invalid GraphQL request body" }],
89
+ });
90
+ return;
91
+ }
92
+ // Convert Express headers to web Headers
93
+ const headers = (0, http_utils_1.toWebHeaders)(req.headers);
94
+ // Set SSE headers
95
+ res.writeHead(200, core_1.SSE_HEADERS);
96
+ // Get the event stream
97
+ const eventStream = sseHandler(subscriptionRequest, headers);
98
+ // Create the streaming effect
99
+ const streamEffect = effect_1.Effect.gen(function* () {
100
+ // Track client disconnection
101
+ const clientDisconnected = yield* effect_1.Deferred.make();
102
+ req.on("close", () => {
103
+ effect_1.Effect.runPromise(effect_1.Deferred.succeed(clientDisconnected, undefined)).catch(() => { });
104
+ });
105
+ req.on("error", (error) => {
106
+ effect_1.Effect.runPromise(effect_1.Deferred.fail(clientDisconnected, new core_1.SSEError({ cause: error }))).catch(() => { });
107
+ });
108
+ // Stream events to the client
109
+ const runStream = effect_1.Stream.runForEach(eventStream, (event) => effect_1.Effect.async((resume) => {
110
+ const message = (0, core_1.formatSSEMessage)(event);
111
+ res.write(message, (error) => {
112
+ if (error) {
113
+ resume(effect_1.Effect.fail(new core_1.SSEError({ cause: error })));
114
+ }
115
+ else {
116
+ resume(effect_1.Effect.succeed(undefined));
117
+ }
118
+ });
119
+ }));
120
+ // Race between stream completion and client disconnection
121
+ yield* effect_1.Effect.race(runStream.pipe(effect_1.Effect.catchAll((error) => effect_1.Effect.logWarning("SSE stream error", error))), effect_1.Deferred.await(clientDisconnected));
122
+ });
123
+ await effect_1.Effect.runPromise(streamEffect.pipe(effect_1.Effect.ensuring(effect_1.Effect.sync(() => res.end())), effect_1.Effect.catchAll(() => effect_1.Effect.void)));
124
+ };
125
+ };
126
+ exports.sseMiddleware = sseMiddleware;
127
+ /**
128
+ * Create a standalone Express route handler for SSE subscriptions.
129
+ *
130
+ * Use this if you want more control over routing than the middleware provides.
131
+ *
132
+ * @param schema - The GraphQL schema with subscription definitions
133
+ * @param layer - Effect layer providing services required by resolvers
134
+ * @param options - Optional lifecycle hooks and configuration
135
+ * @returns An Express request handler
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * import express from "express"
140
+ * import { createSSEHandler } from "@effect-gql/express"
141
+ *
142
+ * const app = express()
143
+ * app.use(express.json())
144
+ *
145
+ * const sseHandler = createSSEHandler(schema, Layer.empty)
146
+ * app.post("/graphql/stream", sseHandler)
147
+ *
148
+ * app.listen(4000)
149
+ * ```
150
+ */
151
+ const createSSEHandler = (schema, layer, options) => {
152
+ const sseHandler = (0, core_1.makeGraphQLSSEHandler)(schema, layer, options);
153
+ return async (req, res, _next) => {
154
+ // Check Accept header for SSE support
155
+ const accept = req.headers.accept ?? "";
156
+ if (!accept.includes("text/event-stream") && !accept.includes("*/*")) {
157
+ res.status(406).json({
158
+ errors: [{ message: "Client must accept text/event-stream" }],
159
+ });
160
+ return;
161
+ }
162
+ // Parse the GraphQL request from the body
163
+ let subscriptionRequest;
164
+ try {
165
+ const body = req.body;
166
+ if (typeof body.query !== "string") {
167
+ throw new Error("Missing query");
168
+ }
169
+ subscriptionRequest = {
170
+ query: body.query,
171
+ variables: body.variables,
172
+ operationName: body.operationName,
173
+ extensions: body.extensions,
174
+ };
175
+ }
176
+ catch {
177
+ res.status(400).json({
178
+ errors: [{ message: "Invalid GraphQL request body" }],
179
+ });
180
+ return;
181
+ }
182
+ // Convert Express headers to web Headers
183
+ const headers = (0, http_utils_1.toWebHeaders)(req.headers);
184
+ // Set SSE headers
185
+ res.writeHead(200, core_1.SSE_HEADERS);
186
+ // Get the event stream
187
+ const eventStream = sseHandler(subscriptionRequest, headers);
188
+ // Create the streaming effect
189
+ const streamEffect = effect_1.Effect.gen(function* () {
190
+ // Track client disconnection
191
+ const clientDisconnected = yield* effect_1.Deferred.make();
192
+ req.on("close", () => {
193
+ effect_1.Effect.runPromise(effect_1.Deferred.succeed(clientDisconnected, undefined)).catch(() => { });
194
+ });
195
+ req.on("error", (error) => {
196
+ effect_1.Effect.runPromise(effect_1.Deferred.fail(clientDisconnected, new core_1.SSEError({ cause: error }))).catch(() => { });
197
+ });
198
+ // Stream events to the client
199
+ const runStream = effect_1.Stream.runForEach(eventStream, (event) => effect_1.Effect.async((resume) => {
200
+ const message = (0, core_1.formatSSEMessage)(event);
201
+ res.write(message, (error) => {
202
+ if (error) {
203
+ resume(effect_1.Effect.fail(new core_1.SSEError({ cause: error })));
204
+ }
205
+ else {
206
+ resume(effect_1.Effect.succeed(undefined));
207
+ }
208
+ });
209
+ }));
210
+ // Race between stream completion and client disconnection
211
+ yield* effect_1.Effect.race(runStream.pipe(effect_1.Effect.catchAll((error) => effect_1.Effect.logWarning("SSE stream error", error))), effect_1.Deferred.await(clientDisconnected));
212
+ });
213
+ await effect_1.Effect.runPromise(streamEffect.pipe(effect_1.Effect.ensuring(effect_1.Effect.sync(() => res.end())), effect_1.Effect.catchAll(() => effect_1.Effect.void)));
214
+ };
215
+ };
216
+ exports.createSSEHandler = createSSEHandler;
217
+ //# sourceMappingURL=sse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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"}
package/dist/ws.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { Layer } from "effect";
2
+ import type { Server } from "node:http";
3
+ import { GraphQLSchema } from "graphql";
4
+ import { type GraphQLWSOptions } from "@effect-gql/core";
5
+ /**
6
+ * Options for Express WebSocket server
7
+ */
8
+ export interface ExpressWSOptions<R> extends GraphQLWSOptions<R> {
9
+ /**
10
+ * Path for WebSocket connections.
11
+ * @default "/graphql"
12
+ */
13
+ readonly path?: string;
14
+ }
15
+ /**
16
+ * Attach WebSocket subscription support to an Express HTTP server.
17
+ *
18
+ * Since Express middleware doesn't own the HTTP server, this function
19
+ * must be called separately with the HTTP server instance to enable
20
+ * WebSocket subscriptions.
21
+ *
22
+ * @param server - The HTTP server running the Express app
23
+ * @param schema - The GraphQL schema with subscription definitions
24
+ * @param layer - Effect layer providing services required by resolvers
25
+ * @param options - Optional configuration and lifecycle hooks
26
+ * @returns Object with cleanup function
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * import express from "express"
31
+ * import { createServer } from "node:http"
32
+ * import { toMiddleware, attachWebSocket } from "@effect-gql/express"
33
+ * import { makeGraphQLRouter, GraphQLSchemaBuilder } from "@effect-gql/core"
34
+ * import { Layer, Effect, Stream } from "effect"
35
+ * import * as S from "effect/Schema"
36
+ *
37
+ * // Build schema with subscriptions
38
+ * const schema = GraphQLSchemaBuilder.empty
39
+ * .query("hello", { type: S.String, resolve: () => Effect.succeed("world") })
40
+ * .subscription("counter", {
41
+ * type: S.Int,
42
+ * subscribe: () => Effect.succeed(Stream.fromIterable([1, 2, 3]))
43
+ * })
44
+ * .buildSchema()
45
+ *
46
+ * // Create Express app with middleware
47
+ * const app = express()
48
+ * app.use(express.json())
49
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
50
+ * app.use(toMiddleware(router, Layer.empty))
51
+ *
52
+ * // Create HTTP server and attach WebSocket support
53
+ * const server = createServer(app)
54
+ * const ws = attachWebSocket(server, schema, Layer.empty, {
55
+ * path: "/graphql"
56
+ * })
57
+ *
58
+ * server.listen(4000, () => {
59
+ * console.log("Server running on http://localhost:4000")
60
+ * console.log("WebSocket subscriptions available at ws://localhost:4000/graphql")
61
+ * })
62
+ *
63
+ * // Cleanup on shutdown
64
+ * process.on("SIGINT", async () => {
65
+ * await ws.close()
66
+ * server.close()
67
+ * })
68
+ * ```
69
+ */
70
+ export declare const attachWebSocket: <R>(server: Server, schema: GraphQLSchema, layer: Layer.Layer<R>, options?: ExpressWSOptions<R>) => {
71
+ close: () => Promise<void>;
72
+ };
73
+ //# sourceMappingURL=ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws.d.ts","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAA;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAIvC,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAA;AAEzB;;GAEG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC,CAAE,SAAQ,gBAAgB,CAAC,CAAC,CAAC;IAC9D;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAC/B,QAAQ,MAAM,EACd,QAAQ,aAAa,EACrB,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EACrB,UAAU,gBAAgB,CAAC,CAAC,CAAC,KAC5B;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAoE9B,CAAA"}
package/dist/ws.js ADDED
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.attachWebSocket = void 0;
4
+ const effect_1 = require("effect");
5
+ const ws_1 = require("ws");
6
+ const core_1 = require("@effect-gql/core");
7
+ /**
8
+ * Attach WebSocket subscription support to an Express HTTP server.
9
+ *
10
+ * Since Express middleware doesn't own the HTTP server, this function
11
+ * must be called separately with the HTTP server instance to enable
12
+ * WebSocket subscriptions.
13
+ *
14
+ * @param server - The HTTP server running the Express app
15
+ * @param schema - The GraphQL schema with subscription definitions
16
+ * @param layer - Effect layer providing services required by resolvers
17
+ * @param options - Optional configuration and lifecycle hooks
18
+ * @returns Object with cleanup function
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * import express from "express"
23
+ * import { createServer } from "node:http"
24
+ * import { toMiddleware, attachWebSocket } from "@effect-gql/express"
25
+ * import { makeGraphQLRouter, GraphQLSchemaBuilder } from "@effect-gql/core"
26
+ * import { Layer, Effect, Stream } from "effect"
27
+ * import * as S from "effect/Schema"
28
+ *
29
+ * // Build schema with subscriptions
30
+ * const schema = GraphQLSchemaBuilder.empty
31
+ * .query("hello", { type: S.String, resolve: () => Effect.succeed("world") })
32
+ * .subscription("counter", {
33
+ * type: S.Int,
34
+ * subscribe: () => Effect.succeed(Stream.fromIterable([1, 2, 3]))
35
+ * })
36
+ * .buildSchema()
37
+ *
38
+ * // Create Express app with middleware
39
+ * const app = express()
40
+ * app.use(express.json())
41
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
42
+ * app.use(toMiddleware(router, Layer.empty))
43
+ *
44
+ * // Create HTTP server and attach WebSocket support
45
+ * const server = createServer(app)
46
+ * const ws = attachWebSocket(server, schema, Layer.empty, {
47
+ * path: "/graphql"
48
+ * })
49
+ *
50
+ * server.listen(4000, () => {
51
+ * console.log("Server running on http://localhost:4000")
52
+ * console.log("WebSocket subscriptions available at ws://localhost:4000/graphql")
53
+ * })
54
+ *
55
+ * // Cleanup on shutdown
56
+ * process.on("SIGINT", async () => {
57
+ * await ws.close()
58
+ * server.close()
59
+ * })
60
+ * ```
61
+ */
62
+ const attachWebSocket = (server, schema, layer, options) => {
63
+ const wss = new ws_1.WebSocketServer({ noServer: true });
64
+ const path = options?.path ?? "/graphql";
65
+ // Create the handler from core
66
+ const handler = (0, core_1.makeGraphQLWSHandler)(schema, layer, options);
67
+ // Track active connections for cleanup
68
+ const activeConnections = new Set();
69
+ wss.on("connection", (ws) => {
70
+ activeConnections.add(ws);
71
+ const effectSocket = (0, core_1.toEffectWebSocketFromWs)(ws);
72
+ // Run the handler
73
+ effect_1.Effect.runPromise(handler(effectSocket).pipe(effect_1.Effect.catchAll((error) => effect_1.Effect.logError("GraphQL WebSocket handler error", error)))).finally(() => {
74
+ activeConnections.delete(ws);
75
+ });
76
+ });
77
+ const handleUpgrade = (request, socket, head) => {
78
+ // Check if this is the GraphQL WebSocket path
79
+ const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
80
+ if (url.pathname !== path) {
81
+ socket.destroy();
82
+ return;
83
+ }
84
+ // Check for correct WebSocket subprotocol
85
+ const protocol = request.headers["sec-websocket-protocol"];
86
+ if (!protocol?.includes("graphql-transport-ws")) {
87
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
88
+ socket.destroy();
89
+ return;
90
+ }
91
+ wss.handleUpgrade(request, socket, head, (ws) => {
92
+ wss.emit("connection", ws, request);
93
+ });
94
+ };
95
+ // Attach upgrade handler to server
96
+ server.on("upgrade", (request, socket, head) => {
97
+ handleUpgrade(request, socket, head);
98
+ });
99
+ const close = async () => {
100
+ // Close all active connections
101
+ for (const ws of activeConnections) {
102
+ ws.close(1001, "Server shutting down");
103
+ }
104
+ activeConnections.clear();
105
+ // Close the WebSocket server
106
+ return new Promise((resolve, reject) => {
107
+ wss.close((error) => {
108
+ if (error)
109
+ reject(error);
110
+ else
111
+ resolve();
112
+ });
113
+ });
114
+ };
115
+ return { close };
116
+ };
117
+ exports.attachWebSocket = attachWebSocket;
118
+ //# 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,61 @@
1
+ {
2
+ "name": "@effect-gql/express",
3
+ "version": "0.1.0",
4
+ "description": "Express middleware adapter 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": "^3.19.0",
21
+ "express": "^4.18.0 || ^5.0.0",
22
+ "graphql-ws": "^6.0.0",
23
+ "ws": "^8.14.0"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "graphql-ws": {
27
+ "optional": true
28
+ },
29
+ "ws": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@effect-gql/core": "*",
35
+ "@effect/platform": "^0.94.0",
36
+ "@types/express": "^5.0.6",
37
+ "@types/ws": "^8.5.0",
38
+ "effect": "^3.19.13",
39
+ "express": "^5.2.1",
40
+ "graphql-ws": "^6.0.6",
41
+ "ws": "^8.18.0"
42
+ },
43
+ "keywords": [
44
+ "effect",
45
+ "graphql",
46
+ "express",
47
+ "middleware",
48
+ "http",
49
+ "server"
50
+ ],
51
+ "license": "MIT",
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "dev": "tsc --watch",
55
+ "clean": "rm -rf dist",
56
+ "test": "vitest run",
57
+ "test:unit": "vitest run test/unit",
58
+ "test:integration": "vitest run test/integration",
59
+ "test:watch": "vitest"
60
+ }
61
+ }
@@ -0,0 +1,24 @@
1
+ import type { IncomingHttpHeaders } from "node:http"
2
+
3
+ /**
4
+ * Convert Node.js/Express 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 req.headers
10
+ * @returns A web standard Headers object
11
+ */
12
+ export const toWebHeaders = (nodeHeaders: IncomingHttpHeaders): Headers => {
13
+ const headers = new Headers()
14
+ for (const [key, value] of Object.entries(nodeHeaders)) {
15
+ if (value) {
16
+ if (Array.isArray(value)) {
17
+ value.forEach((v) => headers.append(key, v))
18
+ } else {
19
+ headers.set(key, value)
20
+ }
21
+ }
22
+ }
23
+ return headers
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { toMiddleware } from "./middleware"
2
+
3
+ // WebSocket subscription support
4
+ export { attachWebSocket, type ExpressWSOptions } from "./ws"
5
+
6
+ // SSE (Server-Sent Events) subscription support
7
+ export { sseMiddleware, createSSEHandler, type ExpressSSEOptions } from "./sse"
@@ -0,0 +1,65 @@
1
+ import { Layer } from "effect"
2
+ import { HttpApp, HttpRouter } from "@effect/platform"
3
+ import type { Request, Response, NextFunction, RequestHandler } from "express"
4
+ import { toWebHeaders } from "./http-utils"
5
+
6
+ /**
7
+ * Convert an HttpRouter to Express middleware.
8
+ *
9
+ * This creates Express-compatible middleware that can be mounted on any Express app.
10
+ * The middleware converts Express requests to web standard Requests, processes them
11
+ * through the Effect router, and writes the response back to Express.
12
+ *
13
+ * @param router - The HttpRouter to convert (typically from makeGraphQLRouter or toRouter)
14
+ * @param layer - Layer providing any services required by the router
15
+ * @returns Express middleware function
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import express from "express"
20
+ * import { makeGraphQLRouter } from "@effect-gql/core"
21
+ * import { toMiddleware } from "@effect-gql/express"
22
+ * import { Layer } from "effect"
23
+ *
24
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
25
+ *
26
+ * const app = express()
27
+ * app.use(toMiddleware(router, Layer.empty))
28
+ * app.listen(4000, () => console.log("Server running on http://localhost:4000"))
29
+ * ```
30
+ */
31
+ export const toMiddleware = <E, R, RE>(
32
+ router: HttpRouter.HttpRouter<E, R>,
33
+ layer: Layer.Layer<R, RE>
34
+ ): RequestHandler => {
35
+ const { handler } = HttpApp.toWebHandlerLayer(router, layer)
36
+
37
+ return async (req: Request, res: Response, next: NextFunction) => {
38
+ try {
39
+ // Convert Express request to web standard Request
40
+ // Use URL constructor for safe URL parsing (avoids Host header injection)
41
+ const baseUrl = `${req.protocol}://${req.hostname}`
42
+ const url = new URL(req.originalUrl || "/", baseUrl).href
43
+ const headers = toWebHeaders(req.headers)
44
+
45
+ const webRequest = new Request(url, {
46
+ method: req.method,
47
+ headers,
48
+ body: ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body),
49
+ })
50
+
51
+ // Process through Effect handler
52
+ const webResponse = await handler(webRequest)
53
+
54
+ // Write response back to Express
55
+ res.status(webResponse.status)
56
+ webResponse.headers.forEach((value, key) => {
57
+ res.setHeader(key, value)
58
+ })
59
+ const body = await webResponse.text()
60
+ res.send(body)
61
+ } catch (error) {
62
+ next(error)
63
+ }
64
+ }
65
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { Effect, Layer, Stream, Deferred } from "effect"
2
+ import type { Request, Response, NextFunction, RequestHandler } from "express"
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 Express SSE middleware
16
+ */
17
+ export interface ExpressSSEOptions<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 Express middleware for SSE subscriptions.
27
+ *
28
+ * This middleware handles POST requests to the configured path and streams
29
+ * GraphQL subscription events as Server-Sent Events.
30
+ *
31
+ * @param schema - The GraphQL schema with subscription definitions
32
+ * @param layer - Effect layer providing services required by resolvers
33
+ * @param options - Optional lifecycle hooks and configuration
34
+ * @returns An Express middleware function
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import express from "express"
39
+ * import { createServer } from "node:http"
40
+ * import { toMiddleware, sseMiddleware, attachWebSocket } from "@effect-gql/express"
41
+ * import { makeGraphQLRouter } from "@effect-gql/core"
42
+ *
43
+ * const app = express()
44
+ * app.use(express.json())
45
+ *
46
+ * // Regular GraphQL endpoint
47
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
48
+ * app.use(toMiddleware(router, Layer.empty))
49
+ *
50
+ * // SSE subscriptions endpoint
51
+ * app.use(sseMiddleware(schema, Layer.empty, {
52
+ * path: "/graphql/stream",
53
+ * onConnect: (request, headers) => Effect.gen(function* () {
54
+ * const token = headers.get("authorization")
55
+ * const user = yield* AuthService.validateToken(token)
56
+ * return { user }
57
+ * }),
58
+ * }))
59
+ *
60
+ * const server = createServer(app)
61
+ *
62
+ * // Optional: Also attach WebSocket subscriptions
63
+ * attachWebSocket(server, schema, Layer.empty)
64
+ *
65
+ * server.listen(4000)
66
+ * ```
67
+ */
68
+ export const sseMiddleware = <R>(
69
+ schema: GraphQLSchema,
70
+ layer: Layer.Layer<R>,
71
+ options?: ExpressSSEOptions<R>
72
+ ): RequestHandler => {
73
+ const path = options?.path ?? "/graphql/stream"
74
+ const sseHandler = makeGraphQLSSEHandler(schema, layer, options)
75
+
76
+ return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
77
+ // Check if this request is for our path
78
+ if (req.path !== path) {
79
+ next()
80
+ return
81
+ }
82
+
83
+ // Only handle POST requests
84
+ if (req.method !== "POST") {
85
+ next()
86
+ return
87
+ }
88
+
89
+ // Check Accept header for SSE support
90
+ const accept = req.headers.accept ?? ""
91
+ if (!accept.includes("text/event-stream") && !accept.includes("*/*")) {
92
+ res.status(406).json({
93
+ errors: [{ message: "Client must accept text/event-stream" }],
94
+ })
95
+ return
96
+ }
97
+
98
+ // Parse the GraphQL request from the body
99
+ let subscriptionRequest: SSESubscriptionRequest
100
+ try {
101
+ const body = req.body as Record<string, unknown>
102
+ if (typeof body.query !== "string") {
103
+ throw new Error("Missing query")
104
+ }
105
+ subscriptionRequest = {
106
+ query: body.query,
107
+ variables: body.variables as Record<string, unknown> | undefined,
108
+ operationName: body.operationName as string | undefined,
109
+ extensions: body.extensions as Record<string, unknown> | undefined,
110
+ }
111
+ } catch {
112
+ res.status(400).json({
113
+ errors: [{ message: "Invalid GraphQL request body" }],
114
+ })
115
+ return
116
+ }
117
+
118
+ // Convert Express headers to web Headers
119
+ const headers = toWebHeaders(req.headers)
120
+
121
+ // Set SSE headers
122
+ res.writeHead(200, SSE_HEADERS)
123
+
124
+ // Get the event stream
125
+ const eventStream = sseHandler(subscriptionRequest, headers)
126
+
127
+ // Create the streaming effect
128
+ const streamEffect = Effect.gen(function* () {
129
+ // Track client disconnection
130
+ const clientDisconnected = yield* Deferred.make<void, SSEError>()
131
+
132
+ req.on("close", () => {
133
+ Effect.runPromise(Deferred.succeed(clientDisconnected, undefined)).catch(() => {})
134
+ })
135
+
136
+ req.on("error", (error) => {
137
+ Effect.runPromise(Deferred.fail(clientDisconnected, new SSEError({ cause: error }))).catch(
138
+ () => {}
139
+ )
140
+ })
141
+
142
+ // Stream events to the client
143
+ const runStream = Stream.runForEach(eventStream, (event) =>
144
+ Effect.async<void, SSEError>((resume) => {
145
+ const message = formatSSEMessage(event)
146
+ res.write(message, (error) => {
147
+ if (error) {
148
+ resume(Effect.fail(new SSEError({ cause: error })))
149
+ } else {
150
+ resume(Effect.succeed(undefined))
151
+ }
152
+ })
153
+ })
154
+ )
155
+
156
+ // Race between stream completion and client disconnection
157
+ yield* Effect.race(
158
+ runStream.pipe(Effect.catchAll((error) => Effect.logWarning("SSE stream error", error))),
159
+ Deferred.await(clientDisconnected)
160
+ )
161
+ })
162
+
163
+ await Effect.runPromise(
164
+ streamEffect.pipe(
165
+ Effect.ensuring(Effect.sync(() => res.end())),
166
+ Effect.catchAll(() => Effect.void)
167
+ )
168
+ )
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Create a standalone Express route handler for SSE subscriptions.
174
+ *
175
+ * Use this if you want more control over routing than the middleware provides.
176
+ *
177
+ * @param schema - The GraphQL schema with subscription definitions
178
+ * @param layer - Effect layer providing services required by resolvers
179
+ * @param options - Optional lifecycle hooks and configuration
180
+ * @returns An Express request handler
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * import express from "express"
185
+ * import { createSSEHandler } from "@effect-gql/express"
186
+ *
187
+ * const app = express()
188
+ * app.use(express.json())
189
+ *
190
+ * const sseHandler = createSSEHandler(schema, Layer.empty)
191
+ * app.post("/graphql/stream", sseHandler)
192
+ *
193
+ * app.listen(4000)
194
+ * ```
195
+ */
196
+ export const createSSEHandler = <R>(
197
+ schema: GraphQLSchema,
198
+ layer: Layer.Layer<R>,
199
+ options?: Omit<ExpressSSEOptions<R>, "path">
200
+ ): RequestHandler => {
201
+ const sseHandler = makeGraphQLSSEHandler(schema, layer, options)
202
+
203
+ return async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
204
+ // Check Accept header for SSE support
205
+ const accept = req.headers.accept ?? ""
206
+ if (!accept.includes("text/event-stream") && !accept.includes("*/*")) {
207
+ res.status(406).json({
208
+ errors: [{ message: "Client must accept text/event-stream" }],
209
+ })
210
+ return
211
+ }
212
+
213
+ // Parse the GraphQL request from the body
214
+ let subscriptionRequest: SSESubscriptionRequest
215
+ try {
216
+ const body = req.body as Record<string, unknown>
217
+ if (typeof body.query !== "string") {
218
+ throw new Error("Missing query")
219
+ }
220
+ subscriptionRequest = {
221
+ query: body.query,
222
+ variables: body.variables as Record<string, unknown> | undefined,
223
+ operationName: body.operationName as string | undefined,
224
+ extensions: body.extensions as Record<string, unknown> | undefined,
225
+ }
226
+ } catch {
227
+ res.status(400).json({
228
+ errors: [{ message: "Invalid GraphQL request body" }],
229
+ })
230
+ return
231
+ }
232
+
233
+ // Convert Express headers to web Headers
234
+ const headers = toWebHeaders(req.headers)
235
+
236
+ // Set SSE headers
237
+ res.writeHead(200, SSE_HEADERS)
238
+
239
+ // Get the event stream
240
+ const eventStream = sseHandler(subscriptionRequest, headers)
241
+
242
+ // Create the streaming effect
243
+ const streamEffect = Effect.gen(function* () {
244
+ // Track client disconnection
245
+ const clientDisconnected = yield* Deferred.make<void, SSEError>()
246
+
247
+ req.on("close", () => {
248
+ Effect.runPromise(Deferred.succeed(clientDisconnected, undefined)).catch(() => {})
249
+ })
250
+
251
+ req.on("error", (error) => {
252
+ Effect.runPromise(Deferred.fail(clientDisconnected, new SSEError({ cause: error }))).catch(
253
+ () => {}
254
+ )
255
+ })
256
+
257
+ // Stream events to the client
258
+ const runStream = Stream.runForEach(eventStream, (event) =>
259
+ Effect.async<void, SSEError>((resume) => {
260
+ const message = formatSSEMessage(event)
261
+ res.write(message, (error) => {
262
+ if (error) {
263
+ resume(Effect.fail(new SSEError({ cause: error })))
264
+ } else {
265
+ resume(Effect.succeed(undefined))
266
+ }
267
+ })
268
+ })
269
+ )
270
+
271
+ // Race between stream completion and client disconnection
272
+ yield* Effect.race(
273
+ runStream.pipe(Effect.catchAll((error) => Effect.logWarning("SSE stream error", error))),
274
+ Deferred.await(clientDisconnected)
275
+ )
276
+ })
277
+
278
+ await Effect.runPromise(
279
+ streamEffect.pipe(
280
+ Effect.ensuring(Effect.sync(() => res.end())),
281
+ Effect.catchAll(() => Effect.void)
282
+ )
283
+ )
284
+ }
285
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { Effect, Layer } from "effect"
2
+ import type { Server } from "node:http"
3
+ import type { IncomingMessage } from "node:http"
4
+ import type { Duplex } from "node:stream"
5
+ import { WebSocket, WebSocketServer } from "ws"
6
+ import { GraphQLSchema } from "graphql"
7
+ import {
8
+ makeGraphQLWSHandler,
9
+ toEffectWebSocketFromWs,
10
+ type GraphQLWSOptions,
11
+ } from "@effect-gql/core"
12
+
13
+ /**
14
+ * Options for Express WebSocket server
15
+ */
16
+ export interface ExpressWSOptions<R> extends GraphQLWSOptions<R> {
17
+ /**
18
+ * Path for WebSocket connections.
19
+ * @default "/graphql"
20
+ */
21
+ readonly path?: string
22
+ }
23
+
24
+ /**
25
+ * Attach WebSocket subscription support to an Express HTTP server.
26
+ *
27
+ * Since Express middleware doesn't own the HTTP server, this function
28
+ * must be called separately with the HTTP server instance to enable
29
+ * WebSocket subscriptions.
30
+ *
31
+ * @param server - The HTTP server running the Express app
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 with cleanup function
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * import express from "express"
40
+ * import { createServer } from "node:http"
41
+ * import { toMiddleware, attachWebSocket } from "@effect-gql/express"
42
+ * import { makeGraphQLRouter, GraphQLSchemaBuilder } from "@effect-gql/core"
43
+ * import { Layer, Effect, Stream } from "effect"
44
+ * import * as S from "effect/Schema"
45
+ *
46
+ * // Build schema with subscriptions
47
+ * const schema = GraphQLSchemaBuilder.empty
48
+ * .query("hello", { type: S.String, resolve: () => Effect.succeed("world") })
49
+ * .subscription("counter", {
50
+ * type: S.Int,
51
+ * subscribe: () => Effect.succeed(Stream.fromIterable([1, 2, 3]))
52
+ * })
53
+ * .buildSchema()
54
+ *
55
+ * // Create Express app with middleware
56
+ * const app = express()
57
+ * app.use(express.json())
58
+ * const router = makeGraphQLRouter(schema, Layer.empty, { graphiql: true })
59
+ * app.use(toMiddleware(router, Layer.empty))
60
+ *
61
+ * // Create HTTP server and attach WebSocket support
62
+ * const server = createServer(app)
63
+ * const ws = attachWebSocket(server, schema, Layer.empty, {
64
+ * path: "/graphql"
65
+ * })
66
+ *
67
+ * server.listen(4000, () => {
68
+ * console.log("Server running on http://localhost:4000")
69
+ * console.log("WebSocket subscriptions available at ws://localhost:4000/graphql")
70
+ * })
71
+ *
72
+ * // Cleanup on shutdown
73
+ * process.on("SIGINT", async () => {
74
+ * await ws.close()
75
+ * server.close()
76
+ * })
77
+ * ```
78
+ */
79
+ export const attachWebSocket = <R>(
80
+ server: Server,
81
+ schema: GraphQLSchema,
82
+ layer: Layer.Layer<R>,
83
+ options?: ExpressWSOptions<R>
84
+ ): { close: () => Promise<void> } => {
85
+ const wss = new WebSocketServer({ noServer: true })
86
+ const path = options?.path ?? "/graphql"
87
+
88
+ // Create the handler from core
89
+ const handler = makeGraphQLWSHandler(schema, layer, options)
90
+
91
+ // Track active connections for cleanup
92
+ const activeConnections = new Set<WebSocket>()
93
+
94
+ wss.on("connection", (ws) => {
95
+ activeConnections.add(ws)
96
+
97
+ const effectSocket = toEffectWebSocketFromWs(ws)
98
+
99
+ // Run the handler
100
+ Effect.runPromise(
101
+ handler(effectSocket).pipe(
102
+ Effect.catchAll((error) => Effect.logError("GraphQL WebSocket handler error", error))
103
+ )
104
+ ).finally(() => {
105
+ activeConnections.delete(ws)
106
+ })
107
+ })
108
+
109
+ const handleUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
110
+ // Check if this is the GraphQL WebSocket path
111
+ const url = new URL(request.url ?? "/", `http://${request.headers.host}`)
112
+ if (url.pathname !== path) {
113
+ socket.destroy()
114
+ return
115
+ }
116
+
117
+ // Check for correct WebSocket subprotocol
118
+ const protocol = request.headers["sec-websocket-protocol"]
119
+ if (!protocol?.includes("graphql-transport-ws")) {
120
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
121
+ socket.destroy()
122
+ return
123
+ }
124
+
125
+ wss.handleUpgrade(request, socket, head, (ws) => {
126
+ wss.emit("connection", ws, request)
127
+ })
128
+ }
129
+
130
+ // Attach upgrade handler to server
131
+ server.on("upgrade", (request, socket, head) => {
132
+ handleUpgrade(request, socket as Duplex, head)
133
+ })
134
+
135
+ const close = async () => {
136
+ // Close all active connections
137
+ for (const ws of activeConnections) {
138
+ ws.close(1001, "Server shutting down")
139
+ }
140
+ activeConnections.clear()
141
+
142
+ // Close the WebSocket server
143
+ return new Promise<void>((resolve, reject) => {
144
+ wss.close((error) => {
145
+ if (error) reject(error)
146
+ else resolve()
147
+ })
148
+ })
149
+ }
150
+
151
+ return { close }
152
+ }