@nifrajs/graphql 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/http.d.ts +17 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +67 -23
- package/dist/http.js.map +1 -1
- package/dist/limits.d.ts +45 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +149 -0
- package/dist/limits.js.map +1 -0
- package/dist/ws.d.ts +12 -0
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +177 -10
- package/dist/ws.js.map +1 -1
- package/package.json +5 -2
- package/src/http.ts +150 -20
- package/src/limits.ts +215 -0
- package/src/ws.ts +217 -13
package/src/limits.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { type DocumentNode, type GraphQLError, Kind, type SelectionNode } from "graphql"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
|
|
4
|
+
maxQueryBytes: 64 * 1024,
|
|
5
|
+
maxVariablesBytes: 256 * 1024,
|
|
6
|
+
maxDepth: 20,
|
|
7
|
+
maxAliases: 100,
|
|
8
|
+
maxComplexity: 1_000,
|
|
9
|
+
maxOperations: 10,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export interface GraphqlLimitsOptions {
|
|
13
|
+
readonly maxQueryBytes?: number
|
|
14
|
+
readonly maxVariablesBytes?: number
|
|
15
|
+
readonly maxDepth?: number
|
|
16
|
+
readonly maxAliases?: number
|
|
17
|
+
readonly maxComplexity?: number
|
|
18
|
+
readonly maxOperations?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GraphqlLimits {
|
|
22
|
+
readonly maxQueryBytes: number
|
|
23
|
+
readonly maxVariablesBytes: number
|
|
24
|
+
readonly maxDepth: number
|
|
25
|
+
readonly maxAliases: number
|
|
26
|
+
readonly maxComplexity: number
|
|
27
|
+
readonly maxOperations: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function limit(value: number | undefined, fallback: number, name: string, minimum: number): number {
|
|
31
|
+
const result = value ?? fallback
|
|
32
|
+
if (!Number.isSafeInteger(result) || result < minimum) {
|
|
33
|
+
throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function graphqlLimits(options: GraphqlLimitsOptions = {}): GraphqlLimits {
|
|
39
|
+
return {
|
|
40
|
+
maxQueryBytes: limit(
|
|
41
|
+
options.maxQueryBytes,
|
|
42
|
+
DEFAULT_GRAPHQL_LIMITS.maxQueryBytes,
|
|
43
|
+
"maxQueryBytes",
|
|
44
|
+
1,
|
|
45
|
+
),
|
|
46
|
+
maxVariablesBytes: limit(
|
|
47
|
+
options.maxVariablesBytes,
|
|
48
|
+
DEFAULT_GRAPHQL_LIMITS.maxVariablesBytes,
|
|
49
|
+
"maxVariablesBytes",
|
|
50
|
+
0,
|
|
51
|
+
),
|
|
52
|
+
maxDepth: limit(options.maxDepth, DEFAULT_GRAPHQL_LIMITS.maxDepth, "maxDepth", 1),
|
|
53
|
+
maxAliases: limit(options.maxAliases, DEFAULT_GRAPHQL_LIMITS.maxAliases, "maxAliases", 0),
|
|
54
|
+
maxComplexity: limit(
|
|
55
|
+
options.maxComplexity,
|
|
56
|
+
DEFAULT_GRAPHQL_LIMITS.maxComplexity,
|
|
57
|
+
"maxComplexity",
|
|
58
|
+
1,
|
|
59
|
+
),
|
|
60
|
+
maxOperations: limit(
|
|
61
|
+
options.maxOperations,
|
|
62
|
+
DEFAULT_GRAPHQL_LIMITS.maxOperations,
|
|
63
|
+
"maxOperations",
|
|
64
|
+
1,
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function utf8Bytes(value: string): number {
|
|
70
|
+
return new TextEncoder().encode(value).byteLength
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function variablesBytes(value: Record<string, unknown> | null): number {
|
|
74
|
+
return value === null ? 0 : utf8Bytes(JSON.stringify(value))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GraphqlDocumentMetrics {
|
|
78
|
+
readonly operations: number
|
|
79
|
+
readonly depth: number
|
|
80
|
+
readonly aliases: number
|
|
81
|
+
readonly complexity: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type SelectionMetrics = Omit<GraphqlDocumentMetrics, "operations">
|
|
85
|
+
|
|
86
|
+
const NO_SELECTIONS: SelectionMetrics = { depth: 0, aliases: 0, complexity: 0 }
|
|
87
|
+
|
|
88
|
+
/** `depth` counts from the selection set's own level, so one fragment measure holds at every spread. */
|
|
89
|
+
function selectionsMetrics(
|
|
90
|
+
selections: readonly SelectionNode[],
|
|
91
|
+
fragment: (name: string) => SelectionMetrics,
|
|
92
|
+
): SelectionMetrics {
|
|
93
|
+
let depth = 0
|
|
94
|
+
let aliases = 0
|
|
95
|
+
let complexity = 0
|
|
96
|
+
for (const selection of selections) {
|
|
97
|
+
let nested: SelectionMetrics
|
|
98
|
+
if (selection.kind === Kind.FIELD) {
|
|
99
|
+
complexity += 1
|
|
100
|
+
if (selection.alias !== undefined) aliases += 1
|
|
101
|
+
nested =
|
|
102
|
+
selection.selectionSet === undefined
|
|
103
|
+
? NO_SELECTIONS
|
|
104
|
+
: selectionsMetrics(selection.selectionSet.selections, fragment)
|
|
105
|
+
depth = Math.max(depth, nested.depth + 1)
|
|
106
|
+
} else {
|
|
107
|
+
nested =
|
|
108
|
+
selection.kind === Kind.INLINE_FRAGMENT
|
|
109
|
+
? selectionsMetrics(selection.selectionSet.selections, fragment)
|
|
110
|
+
: fragment(selection.name.value)
|
|
111
|
+
depth = Math.max(depth, nested.depth)
|
|
112
|
+
}
|
|
113
|
+
aliases += nested.aliases
|
|
114
|
+
complexity += nested.complexity
|
|
115
|
+
}
|
|
116
|
+
return { depth, aliases, complexity }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Each fragment is measured once and reused at every spread. Expanding spreads in place costs a visit
|
|
121
|
+
* per expanded field, and a document whose fragments each spread the one before twice turns n short
|
|
122
|
+
* lines into 2^n visits, all of it synchronous before any limit can refuse the document.
|
|
123
|
+
*/
|
|
124
|
+
export function documentMetrics(document: DocumentNode): GraphqlDocumentMetrics {
|
|
125
|
+
const definitions = new Map<string, readonly SelectionNode[]>()
|
|
126
|
+
for (const definition of document.definitions) {
|
|
127
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
128
|
+
definitions.set(definition.name.value, definition.selectionSet.selections)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const measured = new Map<string, SelectionMetrics>()
|
|
132
|
+
const measuring = new Set<string>()
|
|
133
|
+
const fragment = (name: string): SelectionMetrics => {
|
|
134
|
+
const known = measured.get(name)
|
|
135
|
+
if (known !== undefined) return known
|
|
136
|
+
const selections = definitions.get(name)
|
|
137
|
+
// An unknown fragment and a cycle back into one being measured, both refused by validation,
|
|
138
|
+
// add nothing.
|
|
139
|
+
if (selections === undefined || measuring.has(name)) return NO_SELECTIONS
|
|
140
|
+
measuring.add(name)
|
|
141
|
+
const metrics = selectionsMetrics(selections, fragment)
|
|
142
|
+
measuring.delete(name)
|
|
143
|
+
measured.set(name, metrics)
|
|
144
|
+
return metrics
|
|
145
|
+
}
|
|
146
|
+
let operations = 0
|
|
147
|
+
let depth = 0
|
|
148
|
+
let aliases = 0
|
|
149
|
+
let complexity = 0
|
|
150
|
+
for (const definition of document.definitions) {
|
|
151
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue
|
|
152
|
+
operations += 1
|
|
153
|
+
const metrics = selectionsMetrics(definition.selectionSet.selections, fragment)
|
|
154
|
+
depth = Math.max(depth, metrics.depth)
|
|
155
|
+
aliases += metrics.aliases
|
|
156
|
+
complexity += metrics.complexity
|
|
157
|
+
}
|
|
158
|
+
return { operations, depth, aliases, complexity }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function requestLimitError(
|
|
162
|
+
query: string,
|
|
163
|
+
variables: Record<string, unknown> | null,
|
|
164
|
+
limits: GraphqlLimits,
|
|
165
|
+
): string | undefined {
|
|
166
|
+
if (utf8Bytes(query) > limits.maxQueryBytes) {
|
|
167
|
+
return `GraphQL query exceeds the ${limits.maxQueryBytes}-byte limit.`
|
|
168
|
+
}
|
|
169
|
+
if (variablesBytes(variables) > limits.maxVariablesBytes) {
|
|
170
|
+
return `GraphQL variables exceed the ${limits.maxVariablesBytes}-byte limit.`
|
|
171
|
+
}
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function documentLimitError(
|
|
176
|
+
metrics: GraphqlDocumentMetrics,
|
|
177
|
+
limits: GraphqlLimits,
|
|
178
|
+
): string | undefined {
|
|
179
|
+
if (metrics.operations > limits.maxOperations) {
|
|
180
|
+
return `GraphQL document exceeds the ${limits.maxOperations}-operation limit.`
|
|
181
|
+
}
|
|
182
|
+
if (metrics.depth > limits.maxDepth) {
|
|
183
|
+
return `GraphQL document exceeds the depth limit of ${limits.maxDepth}.`
|
|
184
|
+
}
|
|
185
|
+
if (metrics.aliases > limits.maxAliases) {
|
|
186
|
+
return `GraphQL document exceeds the ${limits.maxAliases}-alias limit.`
|
|
187
|
+
}
|
|
188
|
+
if (metrics.complexity > limits.maxComplexity) {
|
|
189
|
+
return `GraphQL document exceeds the complexity limit of ${limits.maxComplexity}.`
|
|
190
|
+
}
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function maskExecutionError(error: GraphQLError): Record<string, unknown> {
|
|
195
|
+
return {
|
|
196
|
+
message: "Internal server error.",
|
|
197
|
+
...(error.locations === undefined ? {} : { locations: error.locations }),
|
|
198
|
+
...(error.path === undefined ? {} : { path: error.path }),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function safeFormattedError(
|
|
203
|
+
error: GraphQLError,
|
|
204
|
+
phase: "request" | "execution",
|
|
205
|
+
formatter?: (error: GraphQLError, phase: "request" | "execution") => unknown,
|
|
206
|
+
): unknown {
|
|
207
|
+
if (formatter !== undefined) {
|
|
208
|
+
try {
|
|
209
|
+
return formatter(error, phase)
|
|
210
|
+
} catch {
|
|
211
|
+
return phase === "execution" ? maskExecutionError(error) : error.toJSON()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return phase === "execution" ? maskExecutionError(error) : error.toJSON()
|
|
215
|
+
}
|
package/src/ws.ts
CHANGED
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { NifraWebSocket, WebSocketContext, WebSocketHandler } from "@nifrajs/core/ws"
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type DocumentNode,
|
|
19
|
+
type ExecutionResult,
|
|
20
|
+
execute,
|
|
21
|
+
GraphQLError,
|
|
22
|
+
type GraphQLSchema,
|
|
23
|
+
parse,
|
|
24
|
+
specifiedRules,
|
|
25
|
+
subscribe,
|
|
26
|
+
validate,
|
|
27
|
+
} from "graphql"
|
|
18
28
|
import {
|
|
19
29
|
type ConnectionInitMessage,
|
|
20
30
|
GRAPHQL_TRANSPORT_WS_PROTOCOL,
|
|
@@ -23,6 +33,13 @@ import {
|
|
|
23
33
|
type ServerOptions,
|
|
24
34
|
} from "graphql-ws"
|
|
25
35
|
import { buildContext, type GraphqlContextBuilder } from "./context.ts"
|
|
36
|
+
import {
|
|
37
|
+
documentLimitError,
|
|
38
|
+
documentMetrics,
|
|
39
|
+
type GraphqlLimits,
|
|
40
|
+
graphqlLimits,
|
|
41
|
+
requestLimitError,
|
|
42
|
+
} from "./limits.ts"
|
|
26
43
|
|
|
27
44
|
/** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
|
|
28
45
|
interface GraphqlWsConnection {
|
|
@@ -31,6 +48,7 @@ interface GraphqlWsConnection {
|
|
|
31
48
|
dispatch?: (message: string) => void | Promise<void>
|
|
32
49
|
/** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
|
|
33
50
|
closed?: (code: number, reason: string) => void | Promise<void>
|
|
51
|
+
readonly subscriptions: Set<string>
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export interface GraphqlWsOptions<
|
|
@@ -50,6 +68,17 @@ export interface GraphqlWsOptions<
|
|
|
50
68
|
) => boolean | Promise<boolean>
|
|
51
69
|
/** Additional raw `graphql-ws` server options merged last (advanced escape hatch). */
|
|
52
70
|
readonly serverOptions?: Partial<ServerOptions>
|
|
71
|
+
/** Same query/complexity limits as the HTTP transport. */
|
|
72
|
+
readonly maxQueryBytes?: number
|
|
73
|
+
readonly maxVariablesBytes?: number
|
|
74
|
+
readonly maxDepth?: number
|
|
75
|
+
readonly maxAliases?: number
|
|
76
|
+
readonly maxComplexity?: number
|
|
77
|
+
readonly maxOperations?: number
|
|
78
|
+
/** Maximum concurrent subscription operation ids per connection. Default 20. */
|
|
79
|
+
readonly maxSubscriptions?: number
|
|
80
|
+
/** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
|
|
81
|
+
readonly executionTimeoutMs?: number
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
|
|
@@ -57,6 +86,123 @@ function asText(data: string | Uint8Array): string {
|
|
|
57
86
|
return typeof data === "string" ? data : new TextDecoder().decode(data)
|
|
58
87
|
}
|
|
59
88
|
|
|
89
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
91
|
+
? (value as Record<string, unknown>)
|
|
92
|
+
: undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function graphqlWsLimits(options: GraphqlWsOptions): GraphqlLimits {
|
|
96
|
+
return graphqlLimits(options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertWsNumber(value: number, name: string, minimum: number): void {
|
|
100
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
101
|
+
throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function subscriptionFrameError(
|
|
106
|
+
message: string,
|
|
107
|
+
schema: GraphQLSchema,
|
|
108
|
+
limits: GraphqlLimits,
|
|
109
|
+
): { readonly id: unknown; readonly message: string } | undefined {
|
|
110
|
+
let frame: unknown
|
|
111
|
+
try {
|
|
112
|
+
frame = JSON.parse(message)
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
const envelope = recordOf(frame)
|
|
117
|
+
if (envelope?.type !== "subscribe") return undefined
|
|
118
|
+
const id = envelope.id
|
|
119
|
+
const payload = recordOf(envelope.payload)
|
|
120
|
+
const query = payload?.query
|
|
121
|
+
if (typeof query !== "string") return { id, message: "A subscription query is required." }
|
|
122
|
+
const variablesValue = payload?.variables
|
|
123
|
+
const variables =
|
|
124
|
+
variablesValue === undefined || variablesValue === null ? null : recordOf(variablesValue)
|
|
125
|
+
if (variablesValue !== undefined && variablesValue !== null && variables === undefined) {
|
|
126
|
+
return { id, message: "Subscription variables must be an object." }
|
|
127
|
+
}
|
|
128
|
+
const requestError = requestLimitError(query, variables ?? null, limits)
|
|
129
|
+
if (requestError !== undefined) return { id, message: requestError }
|
|
130
|
+
let document: DocumentNode
|
|
131
|
+
try {
|
|
132
|
+
document = parse(query)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
|
|
135
|
+
}
|
|
136
|
+
const validationErrors = validate(schema, document, specifiedRules)
|
|
137
|
+
if (validationErrors.length > 0)
|
|
138
|
+
return { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
|
|
139
|
+
const limitError = documentLimitError(documentMetrics(document), limits)
|
|
140
|
+
return limitError === undefined ? undefined : { id, message: limitError }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function maskWsError(error: unknown): unknown {
|
|
144
|
+
const record = recordOf(error)
|
|
145
|
+
if (record === undefined) return { message: "Internal server error." }
|
|
146
|
+
return {
|
|
147
|
+
message: "Internal server error.",
|
|
148
|
+
...(record.locations === undefined ? {} : { locations: record.locations }),
|
|
149
|
+
...(Array.isArray(record.path) ? { path: record.path } : {}),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A frame graphql-ws sends, execution errors masked. A `complete` or `error` frame ends its operation,
|
|
155
|
+
* so the id stops counting against `maxSubscriptions`: a client is not expected to complete an
|
|
156
|
+
* operation the server already ended.
|
|
157
|
+
*/
|
|
158
|
+
function outboundFrame(message: string, subscriptions: Set<string>): string {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = recordOf(JSON.parse(message))
|
|
161
|
+
if (parsed === undefined) return message
|
|
162
|
+
if ((parsed.type === "complete" || parsed.type === "error") && typeof parsed.id === "string") {
|
|
163
|
+
subscriptions.delete(parsed.id)
|
|
164
|
+
}
|
|
165
|
+
if (parsed.type === "next") {
|
|
166
|
+
const payload = recordOf(parsed.payload)
|
|
167
|
+
if (payload !== undefined && Array.isArray(payload.errors)) {
|
|
168
|
+
return JSON.stringify({
|
|
169
|
+
...parsed,
|
|
170
|
+
payload: { ...payload, errors: payload.errors.map(maskWsError) },
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (parsed.type === "error" && Array.isArray(parsed.payload)) {
|
|
175
|
+
return JSON.stringify({ ...parsed, payload: parsed.payload.map(maskWsError) })
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
return message
|
|
179
|
+
}
|
|
180
|
+
return message
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A timeout is that operation's error result. graphql-ws does not catch a rejected execute or
|
|
185
|
+
* subscribe, so a rejection would end the whole socket instead of the one operation.
|
|
186
|
+
*/
|
|
187
|
+
async function withWsTimeout<T>(
|
|
188
|
+
work: T | Promise<T>,
|
|
189
|
+
timeoutMs: number,
|
|
190
|
+
): Promise<T | ExecutionResult> {
|
|
191
|
+
if (!(work instanceof Promise) || timeoutMs === 0) return work
|
|
192
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
193
|
+
const timeout = new Promise<ExecutionResult>((resolve) => {
|
|
194
|
+
timer = setTimeout(
|
|
195
|
+
() => resolve({ errors: [new GraphQLError("Execution timed out.")] }),
|
|
196
|
+
timeoutMs,
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
try {
|
|
200
|
+
return await Promise.race([work, timeout])
|
|
201
|
+
} finally {
|
|
202
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
60
206
|
/**
|
|
61
207
|
* Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
|
|
62
208
|
* it on a route enabled with `.use(websocket())`:
|
|
@@ -74,28 +220,44 @@ function asText(data: string | Uint8Array): string {
|
|
|
74
220
|
export function graphqlWebSocket<Context extends Record<string, unknown> = Record<string, unknown>>(
|
|
75
221
|
options: GraphqlWsOptions<Context>,
|
|
76
222
|
): WebSocketHandler<GraphqlWsConnection> {
|
|
223
|
+
const limits = graphqlWsLimits(options)
|
|
224
|
+
const maxSubscriptions = options.maxSubscriptions ?? 20
|
|
225
|
+
assertWsNumber(maxSubscriptions, "maxSubscriptions", 1)
|
|
226
|
+
const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
|
|
227
|
+
assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
|
|
228
|
+
const executeWithTimeout: typeof execute = (args) =>
|
|
229
|
+
withWsTimeout(execute(args), executionTimeoutMs)
|
|
230
|
+
const subscribeWithTimeout: typeof subscribe = (args) =>
|
|
231
|
+
withWsTimeout(subscribe(args), executionTimeoutMs)
|
|
77
232
|
const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
|
|
233
|
+
// Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
|
|
234
|
+
// controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
|
|
235
|
+
// wrappers or the context/authentication seams with an unbounded implementation.
|
|
236
|
+
...options.serverOptions,
|
|
78
237
|
schema: options.schema,
|
|
79
|
-
execute,
|
|
80
|
-
subscribe,
|
|
238
|
+
execute: executeWithTimeout,
|
|
239
|
+
subscribe: subscribeWithTimeout,
|
|
81
240
|
onConnect: options.onConnect
|
|
82
241
|
? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
242
|
+
try {
|
|
243
|
+
return await options.onConnect?.(
|
|
244
|
+
ctx.connectionParams as ConnectionInitMessage["payload"],
|
|
245
|
+
ctx.extra.request,
|
|
246
|
+
)
|
|
247
|
+
} catch {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
87
250
|
}
|
|
88
251
|
: undefined,
|
|
89
252
|
context: options.context
|
|
90
253
|
? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
|
|
91
254
|
await buildContext(options.context, { request: ctx.extra.request })
|
|
92
255
|
: undefined,
|
|
93
|
-
...options.serverOptions,
|
|
94
256
|
})
|
|
95
257
|
|
|
96
258
|
return {
|
|
97
259
|
upgrade(c: WebSocketContext): GraphqlWsConnection {
|
|
98
|
-
return { request: c.req }
|
|
260
|
+
return { request: c.req, subscriptions: new Set() }
|
|
99
261
|
},
|
|
100
262
|
|
|
101
263
|
open(ws: NifraWebSocket<GraphqlWsConnection>): void {
|
|
@@ -106,7 +268,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
106
268
|
{
|
|
107
269
|
protocol,
|
|
108
270
|
send: async (data: string) => {
|
|
109
|
-
ws.send(data)
|
|
271
|
+
ws.send(outboundFrame(data, ws.data.subscriptions))
|
|
110
272
|
},
|
|
111
273
|
close: (code: number, reason: string) => {
|
|
112
274
|
ws.close(code, reason)
|
|
@@ -118,7 +280,15 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
118
280
|
{ request: ws.data.request },
|
|
119
281
|
)
|
|
120
282
|
ws.data.closed = closed
|
|
121
|
-
ws.data.dispatch = (message: string) =>
|
|
283
|
+
ws.data.dispatch = async (message: string) => {
|
|
284
|
+
try {
|
|
285
|
+
await onFrame?.(message)
|
|
286
|
+
} catch {
|
|
287
|
+
// graphql-ws leaves a failed frame (a throwing context builder, say) to its socket adapter.
|
|
288
|
+
// Unhandled, the rejection would end a Bun or Node process; the protocol closes one socket.
|
|
289
|
+
ws.close(4500, "Internal server error")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
122
292
|
},
|
|
123
293
|
|
|
124
294
|
message(ws: NifraWebSocket<GraphqlWsConnection>, data: string | Uint8Array): void {
|
|
@@ -126,11 +296,45 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
126
296
|
// iterator completes (`await for await`), so awaiting it here would block every later frame on
|
|
127
297
|
// this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
|
|
128
298
|
// its own sends and error frames from inside that loop; we only feed it inbound frames.
|
|
129
|
-
|
|
299
|
+
const message = asText(data)
|
|
300
|
+
const rejected = subscriptionFrameError(message, options.schema, limits)
|
|
301
|
+
if (rejected !== undefined) {
|
|
302
|
+
ws.send(
|
|
303
|
+
JSON.stringify({
|
|
304
|
+
id: rejected.id,
|
|
305
|
+
type: "error",
|
|
306
|
+
payload: [{ message: rejected.message }],
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const frame = recordOf(JSON.parse(message))
|
|
313
|
+
if (frame?.type === "subscribe" && typeof frame.id === "string") {
|
|
314
|
+
if (ws.data.subscriptions.size >= maxSubscriptions) {
|
|
315
|
+
ws.send(
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
id: frame.id,
|
|
318
|
+
type: "error",
|
|
319
|
+
payload: [{ message: "Subscription limit exceeded." }],
|
|
320
|
+
}),
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
ws.data.subscriptions.add(frame.id)
|
|
325
|
+
} else if (frame?.type === "complete" && typeof frame.id === "string") {
|
|
326
|
+
ws.data.subscriptions.delete(frame.id)
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
|
|
330
|
+
}
|
|
331
|
+
void ws.data.dispatch?.(message)
|
|
130
332
|
},
|
|
131
333
|
|
|
132
334
|
close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
|
|
133
|
-
|
|
335
|
+
ws.data.subscriptions.clear()
|
|
336
|
+
// The socket is gone: a failing iterator return() or onClose hook has no one left to answer.
|
|
337
|
+
void Promise.resolve(ws.data.closed?.(code, reason)).catch(() => {})
|
|
134
338
|
},
|
|
135
339
|
}
|
|
136
340
|
}
|