@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/README.md +119 -0
- package/dist/context.d.ts +45 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +21 -0
- package/dist/context.js.map +1 -0
- package/dist/http.d.ts +53 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +221 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/pubsub.d.ts +21 -0
- package/dist/pubsub.d.ts.map +1 -0
- package/dist/pubsub.js +83 -0
- package/dist/pubsub.js.map +1 -0
- package/dist/ws.d.ts +56 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +85 -0
- package/dist/ws.js.map +1 -0
- package/package.json +76 -0
- package/src/context.ts +56 -0
- package/src/http.ts +326 -0
- package/src/index.ts +100 -0
- package/src/pubsub.ts +98 -0
- package/src/ws.ts +136 -0
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
|
+
}
|