@nifrajs/graphql 3.2.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/http.ts ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The GraphQL-over-HTTP transport - a plain Web `fetch` handler you mount at `POST /graphql` (and
3
+ * optionally `GET /graphql`). It parses, validates, and executes against your `GraphQLSchema` using
4
+ * `graphql`'s own `parse`/`validate`/`execute`; no server framework is re-bundled and no second HTTP
5
+ * trust boundary is created - the request body is read through core's one bounded/proto-guarded lane
6
+ * (`@nifrajs/core/edge-kit`'s {@link readBodyFramed}).
7
+ *
8
+ * Spec: https://graphql.github.io/graphql-over-http/draft/. POST carries `{query, operationName?,
9
+ * variables?, extensions?}` as `application/json`; GET carries them as query-string params and may run
10
+ * **queries only** (never a mutation) so it stays a safe, cacheable idempotent method. Responses use the
11
+ * `application/graphql-response+json` media type with spec status codes by default; set
12
+ * `legacyJsonResponse` for the old always-200 `application/json` shape older clients expect.
13
+ *
14
+ * PRIVACY: the handler never logs the query text, variables, or the request body. GraphQL documents are
15
+ * user payloads; keeping them out of this package's output is the guardrail (a payload sink is an
16
+ * operated concern, not a public-package one).
17
+ */
18
+
19
+ import {
20
+ EMPTY_RESPONSE_CONTROLS,
21
+ type ProtoPoisoning,
22
+ plainError,
23
+ queryObjectOf,
24
+ type ResponseResult,
25
+ readBodyFramed,
26
+ searchOf,
27
+ toResponse,
28
+ } from "@nifrajs/core/edge-kit"
29
+ import {
30
+ type DocumentNode,
31
+ type ExecutionResult,
32
+ execute,
33
+ GraphQLError,
34
+ type GraphQLSchema,
35
+ getOperationAST,
36
+ parse,
37
+ Source,
38
+ specifiedRules,
39
+ validate,
40
+ } from "graphql"
41
+ import {
42
+ buildContext,
43
+ type GraphqlContextBuilder,
44
+ type GraphqlContextInput,
45
+ type NifraContextLike,
46
+ } from "./context.ts"
47
+
48
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000
49
+ const GRAPHQL_RESPONSE_JSON = "application/graphql-response+json; charset=utf-8"
50
+ const JSON_UTF8 = "application/json; charset=utf-8"
51
+
52
+ const CORS_BASE: Record<string, string> = {
53
+ "access-control-allow-methods": "GET, POST, OPTIONS",
54
+ "access-control-allow-headers": "content-type, authorization",
55
+ "access-control-max-age": "86400",
56
+ }
57
+
58
+ export interface GraphqlHttpOptions<Context = unknown, Env = unknown> {
59
+ /** The executable schema (types + resolvers), e.g. from `graphql`'s `buildSchema` or a schema builder. */
60
+ readonly schema: GraphQLSchema
61
+ /** Build the resolver `contextValue` from the request (and nifra context when mounted). */
62
+ readonly context?: GraphqlContextBuilder<Context, Env>
63
+ /** The nifra route context, when the caller has one (supplied by `mountGraphql`). */
64
+ readonly nifra?: NifraContextLike<Env>
65
+ /** Maximum request body size in bytes. Default 1 MB. Rejected bodies answer 413. */
66
+ readonly maxBodyBytes?: number
67
+ /** Prototype-pollution policy for the parsed JSON body. Default `"reject"`. */
68
+ readonly protoPoisoning?: ProtoPoisoning
69
+ /** Optional root value passed to the executor. */
70
+ readonly rootValue?: unknown
71
+ /**
72
+ * Origin allowlist for the CORS `access-control-allow-origin` header. Omit to reflect any origin
73
+ * (`*`) - correct for a public API; set it to lock the browser callers down.
74
+ */
75
+ readonly allowedOrigins?: readonly string[]
76
+ /**
77
+ * Per-request authorization, run after the body parses and before execution. Return `false` to answer
78
+ * `401` with a GraphQL error envelope and skip execution entirely.
79
+ */
80
+ readonly authorize?: (request: Request) => boolean | Promise<boolean>
81
+ /** Emit the legacy always-200 `application/json` response shape instead of `application/graphql-response+json`. */
82
+ readonly legacyJsonResponse?: boolean
83
+ }
84
+
85
+ /** A parsed GraphQL request payload (POST body or GET query string), before validation. */
86
+ interface GraphqlParams {
87
+ readonly query: string
88
+ readonly operationName: string | null
89
+ readonly variables: Record<string, unknown> | null
90
+ }
91
+
92
+ function assertByteLimit(value: number): void {
93
+ if (!Number.isSafeInteger(value) || value < 0) {
94
+ throw new RangeError("GraphQL maxBodyBytes must be a non-negative safe integer")
95
+ }
96
+ }
97
+
98
+ function corsFor(
99
+ request: Request,
100
+ allowedOrigins: readonly string[] | undefined,
101
+ ): Record<string, string> | null {
102
+ if (allowedOrigins === undefined) return { ...CORS_BASE, "access-control-allow-origin": "*" }
103
+ const origin = request.headers.get("origin")
104
+ if (origin === null) return { ...CORS_BASE, vary: "Origin" }
105
+ if (allowedOrigins.includes(origin)) {
106
+ return { ...CORS_BASE, "access-control-allow-origin": origin, vary: "Origin" }
107
+ }
108
+ return null
109
+ }
110
+
111
+ /** Render a rejection from readBodyFramed - a `Response` passes through, a `ResponseResult` is rendered
112
+ * through the same core renderer the full Server uses, so the wire bytes match. */
113
+ function render(r: Response | ResponseResult): Response {
114
+ return r instanceof Response ? r : toResponse(r, EMPTY_RESPONSE_CONTROLS)
115
+ }
116
+
117
+ /** Serialize an execution/error payload to a JSON `Response` with the negotiated media type + status.
118
+ * `payload` is any JSON-serializable GraphQL result (`{data?, errors?}`); GraphQLError values serialize
119
+ * via their own `toJSON`. */
120
+ function graphqlResponse(
121
+ payload: unknown,
122
+ status: number,
123
+ legacy: boolean,
124
+ cors: Record<string, string>,
125
+ ): Response {
126
+ return new Response(JSON.stringify(payload), {
127
+ status: legacy ? 200 : status,
128
+ headers: { "content-type": legacy ? JSON_UTF8 : GRAPHQL_RESPONSE_JSON, ...cors },
129
+ })
130
+ }
131
+
132
+ /** A request-level (parse/validate/auth/method) failure: `{ errors: [...] }`, no `data` key. */
133
+ function requestError(
134
+ message: string,
135
+ status: number,
136
+ legacy: boolean,
137
+ cors: Record<string, string>,
138
+ errors?: readonly GraphQLError[],
139
+ ): Response {
140
+ const payload = errors ?? [new GraphQLError(message)]
141
+ return graphqlResponse({ errors: payload.map((e) => e.toJSON()) }, status, legacy, cors)
142
+ }
143
+
144
+ /** Coerce a `?variables=<json>` string (GET) into an object, or throw a GraphQLError on bad JSON. */
145
+ function parseVariables(raw: unknown): Record<string, unknown> | null {
146
+ if (raw === undefined || raw === null || raw === "") return null
147
+ if (typeof raw === "object") return raw as Record<string, unknown>
148
+ if (typeof raw !== "string") throw new GraphQLError("Variables must be a JSON object.")
149
+ let parsed: unknown
150
+ try {
151
+ parsed = JSON.parse(raw)
152
+ } catch {
153
+ throw new GraphQLError("Variables are invalid JSON.")
154
+ }
155
+ if (parsed === null) return null
156
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
157
+ throw new GraphQLError("Variables must be a JSON object.")
158
+ }
159
+ return parsed as Record<string, unknown>
160
+ }
161
+
162
+ /** Read `{query, operationName, variables}` from a parsed POST body. Throws GraphQLError when malformed. */
163
+ function paramsFromBody(body: unknown): GraphqlParams {
164
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
165
+ throw new GraphQLError("POST body must be a JSON object.")
166
+ }
167
+ const b = body as Record<string, unknown>
168
+ if (typeof b.query !== "string" || b.query.length === 0) {
169
+ throw new GraphQLError("A `query` string is required.")
170
+ }
171
+ const operationName = typeof b.operationName === "string" ? b.operationName : null
172
+ return { query: b.query, operationName, variables: parseVariables(b.variables) }
173
+ }
174
+
175
+ /** Read the same shape from a GET query string. */
176
+ function paramsFromQuery(query: Record<string, unknown>): GraphqlParams {
177
+ const q = query.query
178
+ if (typeof q !== "string" || q.length === 0)
179
+ throw new GraphQLError("A `query` parameter is required.")
180
+ const operationName = typeof query.operationName === "string" ? query.operationName : null
181
+ return { query: q, operationName, variables: parseVariables(query.variables) }
182
+ }
183
+
184
+ /** Parse, validate, and execute one already-extracted GraphQL request. */
185
+ async function run<Context, Env>(
186
+ params: GraphqlParams,
187
+ request: Request,
188
+ opts: GraphqlHttpOptions<Context, Env>,
189
+ isGet: boolean,
190
+ legacy: boolean,
191
+ cors: Record<string, string>,
192
+ ): Promise<Response> {
193
+ let document: DocumentNode
194
+ try {
195
+ document = parse(new Source(params.query, "GraphQL request"))
196
+ } catch (err) {
197
+ const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
198
+ return requestError("", 400, legacy, cors, [gqlErr])
199
+ }
200
+
201
+ const validationErrors = validate(opts.schema, document, specifiedRules)
202
+ if (validationErrors.length > 0) {
203
+ return requestError("", 400, legacy, cors, validationErrors)
204
+ }
205
+
206
+ // GET is idempotent and cacheable, so per the GraphQL-over-HTTP spec it may run queries only.
207
+ const operationAst = getOperationAST(document, params.operationName ?? undefined)
208
+ if (isGet && operationAst != null && operationAst.operation !== "query") {
209
+ return requestError(
210
+ "Only query operations may run over GET.",
211
+ 405,
212
+ legacy,
213
+ { ...cors, allow: "POST" },
214
+ [new GraphQLError("Only query operations are allowed over GET.")],
215
+ )
216
+ }
217
+
218
+ const contextInput: GraphqlContextInput<Env> =
219
+ opts.nifra !== undefined ? { request, nifra: opts.nifra } : { request }
220
+ let contextValue: Context
221
+ try {
222
+ contextValue = await buildContext(opts.context, contextInput)
223
+ } catch (err) {
224
+ const gqlErr =
225
+ err instanceof GraphQLError ? err : new GraphQLError("Failed to build request context.")
226
+ return graphqlResponse({ errors: [gqlErr.toJSON()] }, 500, legacy, cors)
227
+ }
228
+
229
+ let result: ExecutionResult
230
+ try {
231
+ result = (await execute({
232
+ schema: opts.schema,
233
+ document,
234
+ rootValue: opts.rootValue,
235
+ contextValue,
236
+ variableValues: params.variables ?? undefined,
237
+ operationName: params.operationName ?? undefined,
238
+ })) as ExecutionResult
239
+ } catch (err) {
240
+ const gqlErr = err instanceof GraphQLError ? err : new GraphQLError("Execution failed.")
241
+ return graphqlResponse({ errors: [gqlErr.toJSON()] }, 400, legacy, cors)
242
+ }
243
+
244
+ // Field/execution errors are a normal 200 result with both `data` and `errors`.
245
+ return graphqlResponse(result, 200, legacy, cors)
246
+ }
247
+
248
+ /**
249
+ * Handle one GraphQL HTTP request. Never throws - a malformed request becomes a GraphQL error envelope.
250
+ * Mount it directly (`app.all('/graphql', (c) => respondGraphql(c.req, { schema }))`) or via
251
+ * {@link mountGraphql}, which also wires the nifra context in.
252
+ */
253
+ export async function respondGraphql<Context = unknown, Env = unknown>(
254
+ request: Request,
255
+ options: GraphqlHttpOptions<Context, Env>,
256
+ ): Promise<Response> {
257
+ const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
258
+ assertByteLimit(maxBytes)
259
+ const legacy = options.legacyJsonResponse === true
260
+
261
+ const cors = corsFor(request, options.allowedOrigins)
262
+ if (cors === null) return new Response(null, { status: 403 })
263
+
264
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors })
265
+
266
+ if (options.authorize !== undefined) {
267
+ const ok = await options.authorize(request)
268
+ if (!ok) {
269
+ return requestError("Unauthorized.", 401, legacy, cors, [new GraphQLError("Unauthorized.")])
270
+ }
271
+ }
272
+
273
+ if (request.method === "GET") {
274
+ let params: GraphqlParams
275
+ try {
276
+ params = paramsFromQuery(queryObjectOf(searchOf(request.url)) as Record<string, unknown>)
277
+ } catch (err) {
278
+ const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
279
+ return requestError("", 400, legacy, cors, [gqlErr])
280
+ }
281
+ return run(params, request, options, true, legacy, cors)
282
+ }
283
+
284
+ if (request.method !== "POST") {
285
+ return requestError("Method not allowed.", 405, legacy, { ...cors, allow: "GET, POST" }, [
286
+ new GraphQLError("Only GET and POST are supported."),
287
+ ])
288
+ }
289
+
290
+ // Read the JSON body through core's single bounded + proto-guarded framing lane.
291
+ return readBodyFramed<Response>(
292
+ request,
293
+ maxBytes,
294
+ options.protoPoisoning ?? "reject",
295
+ async (parsed) => {
296
+ let params: GraphqlParams
297
+ try {
298
+ params = paramsFromBody(parsed)
299
+ } catch (err) {
300
+ const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
301
+ return requestError("", 400, legacy, cors, [gqlErr])
302
+ }
303
+ return run(params, request, options, false, legacy, cors)
304
+ },
305
+ (response: Response | ResponseResult) => {
306
+ // readBodyFramed's own rejections (413 too-large, 415, proto-guard) - render + attach CORS.
307
+ const res = render(response)
308
+ for (const [k, v] of Object.entries(cors)) res.headers.set(k, v)
309
+ return res
310
+ },
311
+ (err: unknown) => {
312
+ // A body that failed to read (bad JSON / proto-guard) is a request error, not a crash.
313
+ if (err instanceof Response) return err
314
+ return requestError("Invalid request body.", 400, legacy, cors, [
315
+ new GraphQLError("Request body could not be read as JSON."),
316
+ ])
317
+ },
318
+ ).catch(() => plainErrorResponse(cors))
319
+ }
320
+
321
+ /** Last-resort framing fallback so the handler contract ("never throws") holds even on an edge-kit change. */
322
+ function plainErrorResponse(cors: Record<string, string>): Response {
323
+ const res = render(plainError(400, "bad_request"))
324
+ for (const [k, v] of Object.entries(cors)) res.headers.set(k, v)
325
+ return res
326
+ }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `@nifrajs/graphql` - mount a GraphQL endpoint on a nifra app.
3
+ *
4
+ * - {@link respondGraphql} (`./http.ts`) - a spec-compliant GraphQL-over-HTTP `fetch` handler.
5
+ * - {@link graphqlWebSocket} (`./ws.ts`) - `graphql-transport-ws` subscriptions over `app.ws()`.
6
+ * - {@link createPubSub} (`./pubsub.ts`) - an in-memory subscription source; swap for a durable bus.
7
+ * - {@link mountGraphql} - the one-call sugar that wires POST/GET (and, optionally, subscriptions).
8
+ *
9
+ * It executes with `graphql`'s own `parse`/`validate`/`execute`/`subscribe`; no HTTP framework is
10
+ * re-bundled, the request body reuses core's single bounded trust boundary, and the query text is never
11
+ * logged. `graphql` is a required peer; `graphql-ws` is optional (needed only for subscriptions).
12
+ */
13
+
14
+ import type { GraphqlContextBuilder, NifraContextLike } from "./context.ts"
15
+ import { type GraphqlHttpOptions, respondGraphql } from "./http.ts"
16
+ import { type GraphqlWsOptions, graphqlWebSocket } from "./ws.ts"
17
+
18
+ export type {
19
+ GraphqlContextBuilder,
20
+ GraphqlContextInput,
21
+ NifraContextLike,
22
+ } from "./context.ts"
23
+ export { buildContext } from "./context.ts"
24
+ export { type GraphqlHttpOptions, respondGraphql } from "./http.ts"
25
+ export { createPubSub, type GraphqlPubSub } from "./pubsub.ts"
26
+ export { type GraphqlWsOptions, graphqlWebSocket } from "./ws.ts"
27
+
28
+ /** The structural slice of a nifra server `mountGraphql` needs. Kept loose so core stays a peer dep. */
29
+ interface RouteContextLike<Env = unknown> extends NifraContextLike<Env> {
30
+ readonly req: Request
31
+ }
32
+
33
+ interface MountableApp<Env = unknown> {
34
+ get(path: string, handler: (c: RouteContextLike<Env>) => Response | Promise<Response>): unknown
35
+ post(path: string, handler: (c: RouteContextLike<Env>) => Response | Promise<Response>): unknown
36
+ ws?(path: string, handler: unknown): unknown
37
+ }
38
+
39
+ export interface MountGraphqlOptions<Context = unknown, Env = unknown>
40
+ extends Omit<GraphqlHttpOptions<Context, Env>, "nifra"> {
41
+ /** Endpoint path. Default `/graphql`. */
42
+ readonly path?: string
43
+ /** Enable `GET /graphql` (queries only). Default `true`. */
44
+ readonly enableGet?: boolean
45
+ /**
46
+ * Enable `graphql-transport-ws` subscriptions on the same path. Pass the WS options (or `true` to
47
+ * reuse `schema`/`context`). Requires the app to have `.use(websocket())` and `graphql-ws` installed.
48
+ */
49
+ readonly subscriptions?: boolean | GraphqlWsOptions<Record<string, unknown>>
50
+ }
51
+
52
+ /**
53
+ * Mount a GraphQL endpoint. Wires `POST` (and `GET`, unless disabled) on `path`, injecting the nifra
54
+ * route context into your resolver context, and - when `subscriptions` is set - a `graphql-ws` WebSocket
55
+ * on the same path.
56
+ *
57
+ * ```ts
58
+ * import { server } from "@nifrajs/core"
59
+ * import { websocket } from "@nifrajs/core/ws"
60
+ * import { mountGraphql, createPubSub } from "@nifrajs/graphql"
61
+ *
62
+ * const pubsub = createPubSub()
63
+ * const app = server().use(websocket())
64
+ * mountGraphql(app, {
65
+ * schema,
66
+ * context: ({ nifra }) => ({ user: nifra?.env, pubsub }),
67
+ * subscriptions: true,
68
+ * })
69
+ * ```
70
+ */
71
+ export function mountGraphql<Context = unknown, Env = unknown>(
72
+ app: MountableApp<Env>,
73
+ options: MountGraphqlOptions<Context, Env>,
74
+ ): void {
75
+ const path = options.path ?? "/graphql"
76
+ const enableGet = options.enableGet !== false
77
+
78
+ const handle = (c: RouteContextLike<Env>): Promise<Response> =>
79
+ respondGraphql(c.req, { ...options, nifra: c })
80
+
81
+ app.post(path, handle)
82
+ if (enableGet) app.get(path, handle)
83
+
84
+ if (options.subscriptions) {
85
+ if (typeof app.ws !== "function") {
86
+ throw new Error(
87
+ "mountGraphql: subscriptions require a WebSocket-enabled app - call `.use(websocket())` first.",
88
+ )
89
+ }
90
+ let wsOptions: GraphqlWsOptions<Record<string, unknown>>
91
+ if (options.subscriptions === true) {
92
+ const ctx = options.context as GraphqlContextBuilder<Record<string, unknown>> | undefined
93
+ wsOptions =
94
+ ctx !== undefined ? { schema: options.schema, context: ctx } : { schema: options.schema }
95
+ } else {
96
+ wsOptions = options.subscriptions
97
+ }
98
+ app.ws(path, graphqlWebSocket(wsOptions))
99
+ }
100
+ }
package/src/pubsub.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A trivial in-memory pub/sub for `subscription` resolvers - the reference implementation of the
3
+ * subscription source seam. A resolver's `subscribe` returns `pubsub.subscribe(topic)` (an async
4
+ * iterator); anything in the process calls `pubsub.publish(topic, payload)` to push an event.
5
+ *
6
+ * This is deliberately in-process and unbounded-fan-in only within one server instance - the same
7
+ * single-instance contract as core's `TopicRegistry`. A durable, multi-instance, or tenant-scoped
8
+ * pub/sub (Redis, a Durable Object, NATS) is an operated concern: implement the same
9
+ * `{ publish, subscribe }` shape over your bus and pass it in. Keeping only the in-memory impl here is
10
+ * the guardrail - a durable payload-carrying sink does not belong in a public package.
11
+ */
12
+
13
+ /** The subscription source shape a resolver's `subscribe` consumes. Swap the impl for a durable bus. */
14
+ export interface GraphqlPubSub<Event = unknown> {
15
+ /** Push `payload` to every open iterator on `topic`. */
16
+ publish(topic: string, payload: Event): void
17
+ /** An async iterator that yields each future `publish(topic, ...)` until it is returned/closed. */
18
+ subscribe(topic: string): AsyncIterableIterator<Event>
19
+ }
20
+
21
+ /** Create an in-memory {@link GraphqlPubSub}. */
22
+ export function createPubSub<Event = unknown>(): GraphqlPubSub<Event> {
23
+ const topics = new Map<string, Set<Sink<Event>>>()
24
+
25
+ const publish = (topic: string, payload: Event): void => {
26
+ const sinks = topics.get(topic)
27
+ if (sinks === undefined) return
28
+ for (const sink of sinks) sink.push(payload)
29
+ }
30
+
31
+ const subscribe = (topic: string): AsyncIterableIterator<Event> => {
32
+ let sinks = topics.get(topic)
33
+ if (sinks === undefined) {
34
+ sinks = new Set()
35
+ topics.set(topic, sinks)
36
+ }
37
+ const set = sinks
38
+ const sink = createSink<Event>(() => {
39
+ set.delete(sink)
40
+ if (set.size === 0) topics.delete(topic)
41
+ })
42
+ set.add(sink)
43
+ return sink.iterator
44
+ }
45
+
46
+ return { publish, subscribe }
47
+ }
48
+
49
+ /** One subscriber: a bounded push queue bridged to an async iterator with backpressure via awaiting pulls. */
50
+ interface Sink<Event> {
51
+ push(payload: Event): void
52
+ readonly iterator: AsyncIterableIterator<Event>
53
+ }
54
+
55
+ function createSink<Event>(onClose: () => void): Sink<Event> {
56
+ const queue: Event[] = []
57
+ const pulls: Array<(result: IteratorResult<Event>) => void> = []
58
+ let done = false
59
+
60
+ const push = (payload: Event): void => {
61
+ if (done) return
62
+ const pull = pulls.shift()
63
+ if (pull !== undefined) {
64
+ pull({ value: payload, done: false })
65
+ return
66
+ }
67
+ queue.push(payload)
68
+ }
69
+
70
+ const finish = (): IteratorResult<Event> => {
71
+ if (!done) {
72
+ done = true
73
+ onClose()
74
+ for (const pull of pulls.splice(0)) pull({ value: undefined, done: true })
75
+ }
76
+ return { value: undefined, done: true }
77
+ }
78
+
79
+ const iterator: AsyncIterableIterator<Event> = {
80
+ next(): Promise<IteratorResult<Event>> {
81
+ if (queue.length > 0) return Promise.resolve({ value: queue.shift() as Event, done: false })
82
+ if (done) return Promise.resolve({ value: undefined, done: true })
83
+ return new Promise((resolve) => pulls.push(resolve))
84
+ },
85
+ return(): Promise<IteratorResult<Event>> {
86
+ return Promise.resolve(finish())
87
+ },
88
+ throw(err?: unknown): Promise<IteratorResult<Event>> {
89
+ finish()
90
+ return Promise.reject(err)
91
+ },
92
+ [Symbol.asyncIterator]() {
93
+ return this
94
+ },
95
+ }
96
+
97
+ return { push, iterator }
98
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * GraphQL subscriptions over nifra's native WebSocket lane, speaking the `graphql-transport-ws`
3
+ * protocol (the modern `graphql-ws` wire protocol). This adapts `graphql-ws`'s transport-agnostic
4
+ * server (`makeServer`) onto a nifra `app.ws()` route, so subscription operations run through the same
5
+ * socket runtime as every other nifra WebSocket - no separate socket server, no second upgrade path.
6
+ *
7
+ * `graphql-ws` owns the per-operation async-iterator lifecycle (one `subscribe` iterator per client
8
+ * `Subscribe` message, drained to that client's socket). The event *source* those iterators pull from
9
+ * is yours - typically a {@link ./pubsub.ts | GraphqlPubSub}, whose in-memory reference impl mirrors
10
+ * core's `TopicRegistry` and can be swapped for a durable bus without touching this transport.
11
+ *
12
+ * `graphql-ws` is an optional peer dependency: import this module only when you need subscriptions, and
13
+ * the dependency (and its bundle weight) stays off every app that doesn't.
14
+ */
15
+
16
+ import type { NifraWebSocket, WebSocketContext, WebSocketHandler } from "@nifrajs/core/ws"
17
+ import { execute, type GraphQLSchema, subscribe } from "graphql"
18
+ import {
19
+ type ConnectionInitMessage,
20
+ GRAPHQL_TRANSPORT_WS_PROTOCOL,
21
+ type Context as GraphqlWsContext,
22
+ makeServer,
23
+ type ServerOptions,
24
+ } from "graphql-ws"
25
+ import { buildContext, type GraphqlContextBuilder } from "./context.ts"
26
+
27
+ /** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
28
+ interface GraphqlWsConnection {
29
+ readonly request: Request
30
+ /** Feed one raw inbound frame into graphql-ws (registered via its `onMessage`). */
31
+ dispatch?: (message: string) => void | Promise<void>
32
+ /** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
33
+ closed?: (code: number, reason: string) => void | Promise<void>
34
+ }
35
+
36
+ export interface GraphqlWsOptions<
37
+ Context extends Record<string, unknown> = Record<string, unknown>,
38
+ > {
39
+ /** The executable schema. */
40
+ readonly schema: GraphQLSchema
41
+ /** Build the resolver `contextValue` from the upgrade request. Shared with the HTTP transport. */
42
+ readonly context?: GraphqlContextBuilder<Context>
43
+ /**
44
+ * Called once per connection with the client's `connection_init` payload; return `false` (or throw)
45
+ * to reject the connection. The seam for authenticating a socket from an init token.
46
+ */
47
+ readonly onConnect?: (
48
+ payload: ConnectionInitMessage["payload"],
49
+ request: Request,
50
+ ) => boolean | Promise<boolean>
51
+ /** Additional raw `graphql-ws` server options merged last (advanced escape hatch). */
52
+ readonly serverOptions?: Partial<ServerOptions>
53
+ }
54
+
55
+ /** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
56
+ function asText(data: string | Uint8Array): string {
57
+ return typeof data === "string" ? data : new TextDecoder().decode(data)
58
+ }
59
+
60
+ /**
61
+ * Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
62
+ * it on a route enabled with `.use(websocket())`:
63
+ *
64
+ * ```ts
65
+ * import { websocket } from "@nifrajs/core/ws"
66
+ * import { graphqlWebSocket } from "@nifrajs/graphql/ws"
67
+ *
68
+ * const app = server().use(websocket()).ws("/graphql", graphqlWebSocket({ schema, context }))
69
+ * ```
70
+ *
71
+ * The route MUST negotiate the `graphql-transport-ws` subprotocol at the WebSocket layer for a browser
72
+ * `graphql-ws` client to connect.
73
+ */
74
+ export function graphqlWebSocket<Context extends Record<string, unknown> = Record<string, unknown>>(
75
+ options: GraphqlWsOptions<Context>,
76
+ ): WebSocketHandler<GraphqlWsConnection> {
77
+ const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
78
+ schema: options.schema,
79
+ execute,
80
+ subscribe,
81
+ onConnect: options.onConnect
82
+ ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
83
+ return await options.onConnect?.(
84
+ ctx.connectionParams as ConnectionInitMessage["payload"],
85
+ ctx.extra.request,
86
+ )
87
+ }
88
+ : undefined,
89
+ context: options.context
90
+ ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
91
+ await buildContext(options.context, { request: ctx.extra.request })
92
+ : undefined,
93
+ ...options.serverOptions,
94
+ })
95
+
96
+ return {
97
+ upgrade(c: WebSocketContext): GraphqlWsConnection {
98
+ return { request: c.req }
99
+ },
100
+
101
+ open(ws: NifraWebSocket<GraphqlWsConnection>): void {
102
+ const protocol =
103
+ (ws.raw as { protocol?: string } | undefined)?.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL
104
+ let onFrame: ((message: string) => void | Promise<void>) | undefined
105
+ const closed = gqlServer.opened(
106
+ {
107
+ protocol,
108
+ send: async (data: string) => {
109
+ ws.send(data)
110
+ },
111
+ close: (code: number, reason: string) => {
112
+ ws.close(code, reason)
113
+ },
114
+ onMessage: (cb) => {
115
+ onFrame = cb
116
+ },
117
+ },
118
+ { request: ws.data.request },
119
+ )
120
+ ws.data.closed = closed
121
+ ws.data.dispatch = (message: string) => onFrame?.(message)
122
+ },
123
+
124
+ message(ws: NifraWebSocket<GraphqlWsConnection>, data: string | Uint8Array): void {
125
+ // Fire-and-forget: graphql-ws's per-message handler does not resolve until a subscription's
126
+ // iterator completes (`await for await`), so awaiting it here would block every later frame on
127
+ // this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
128
+ // its own sends and error frames from inside that loop; we only feed it inbound frames.
129
+ void ws.data.dispatch?.(asText(data))
130
+ },
131
+
132
+ close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
133
+ void ws.data.closed?.(code, reason)
134
+ },
135
+ }
136
+ }