@nifrajs/graphql 3.3.0 → 3.5.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 +40 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +136 -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 +147 -7
- package/dist/ws.js.map +1 -1
- package/package.json +2 -2
- package/src/http.ts +150 -20
- package/src/limits.ts +212 -0
- package/src/ws.ts +179 -7
package/src/limits.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
function selectionsMetrics(
|
|
85
|
+
selections: readonly SelectionNode[],
|
|
86
|
+
fragments: ReadonlyMap<string, readonly SelectionNode[]>,
|
|
87
|
+
depth: number,
|
|
88
|
+
activeFragments: ReadonlySet<string>,
|
|
89
|
+
): Omit<GraphqlDocumentMetrics, "operations"> {
|
|
90
|
+
let maxDepth = depth
|
|
91
|
+
let aliases = 0
|
|
92
|
+
let complexity = 0
|
|
93
|
+
for (const selection of selections) {
|
|
94
|
+
if (selection.kind === Kind.FIELD) {
|
|
95
|
+
complexity += 1
|
|
96
|
+
if (selection.alias !== undefined) aliases += 1
|
|
97
|
+
if (selection.selectionSet !== undefined) {
|
|
98
|
+
const nested = selectionsMetrics(
|
|
99
|
+
selection.selectionSet.selections,
|
|
100
|
+
fragments,
|
|
101
|
+
depth + 1,
|
|
102
|
+
activeFragments,
|
|
103
|
+
)
|
|
104
|
+
maxDepth = Math.max(maxDepth, nested.depth)
|
|
105
|
+
aliases += nested.aliases
|
|
106
|
+
complexity += nested.complexity
|
|
107
|
+
} else {
|
|
108
|
+
maxDepth = Math.max(maxDepth, depth + 1)
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
113
|
+
const nested = selectionsMetrics(
|
|
114
|
+
selection.selectionSet.selections,
|
|
115
|
+
fragments,
|
|
116
|
+
depth,
|
|
117
|
+
activeFragments,
|
|
118
|
+
)
|
|
119
|
+
maxDepth = Math.max(maxDepth, nested.depth)
|
|
120
|
+
aliases += nested.aliases
|
|
121
|
+
complexity += nested.complexity
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const nestedSelections = fragments.get(selection.name.value)
|
|
125
|
+
if (nestedSelections === undefined || activeFragments.has(selection.name.value)) continue
|
|
126
|
+
const nextActive = new Set(activeFragments)
|
|
127
|
+
nextActive.add(selection.name.value)
|
|
128
|
+
const nested = selectionsMetrics(nestedSelections, fragments, depth, nextActive)
|
|
129
|
+
maxDepth = Math.max(maxDepth, nested.depth)
|
|
130
|
+
aliases += nested.aliases
|
|
131
|
+
complexity += nested.complexity
|
|
132
|
+
}
|
|
133
|
+
return { depth: maxDepth, aliases, complexity }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function documentMetrics(document: DocumentNode): GraphqlDocumentMetrics {
|
|
137
|
+
const fragments = new Map<string, readonly SelectionNode[]>()
|
|
138
|
+
for (const definition of document.definitions) {
|
|
139
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
140
|
+
fragments.set(definition.name.value, definition.selectionSet.selections)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let operations = 0
|
|
144
|
+
let depth = 0
|
|
145
|
+
let aliases = 0
|
|
146
|
+
let complexity = 0
|
|
147
|
+
for (const definition of document.definitions) {
|
|
148
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue
|
|
149
|
+
operations += 1
|
|
150
|
+
const metrics = selectionsMetrics(definition.selectionSet.selections, fragments, 0, new Set())
|
|
151
|
+
depth = Math.max(depth, metrics.depth)
|
|
152
|
+
aliases += metrics.aliases
|
|
153
|
+
complexity += metrics.complexity
|
|
154
|
+
}
|
|
155
|
+
return { operations, depth, aliases, complexity }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function requestLimitError(
|
|
159
|
+
query: string,
|
|
160
|
+
variables: Record<string, unknown> | null,
|
|
161
|
+
limits: GraphqlLimits,
|
|
162
|
+
): string | undefined {
|
|
163
|
+
if (utf8Bytes(query) > limits.maxQueryBytes) {
|
|
164
|
+
return `GraphQL query exceeds the ${limits.maxQueryBytes}-byte limit.`
|
|
165
|
+
}
|
|
166
|
+
if (variablesBytes(variables) > limits.maxVariablesBytes) {
|
|
167
|
+
return `GraphQL variables exceed the ${limits.maxVariablesBytes}-byte limit.`
|
|
168
|
+
}
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function documentLimitError(
|
|
173
|
+
metrics: GraphqlDocumentMetrics,
|
|
174
|
+
limits: GraphqlLimits,
|
|
175
|
+
): string | undefined {
|
|
176
|
+
if (metrics.operations > limits.maxOperations) {
|
|
177
|
+
return `GraphQL document exceeds the ${limits.maxOperations}-operation limit.`
|
|
178
|
+
}
|
|
179
|
+
if (metrics.depth > limits.maxDepth) {
|
|
180
|
+
return `GraphQL document exceeds the depth limit of ${limits.maxDepth}.`
|
|
181
|
+
}
|
|
182
|
+
if (metrics.aliases > limits.maxAliases) {
|
|
183
|
+
return `GraphQL document exceeds the ${limits.maxAliases}-alias limit.`
|
|
184
|
+
}
|
|
185
|
+
if (metrics.complexity > limits.maxComplexity) {
|
|
186
|
+
return `GraphQL document exceeds the complexity limit of ${limits.maxComplexity}.`
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function maskExecutionError(error: GraphQLError): Record<string, unknown> {
|
|
192
|
+
return {
|
|
193
|
+
message: "Internal server error.",
|
|
194
|
+
...(error.locations === undefined ? {} : { locations: error.locations }),
|
|
195
|
+
...(error.path === undefined ? {} : { path: error.path }),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function safeFormattedError(
|
|
200
|
+
error: GraphQLError,
|
|
201
|
+
phase: "request" | "execution",
|
|
202
|
+
formatter?: (error: GraphQLError, phase: "request" | "execution") => unknown,
|
|
203
|
+
): unknown {
|
|
204
|
+
if (formatter !== undefined) {
|
|
205
|
+
try {
|
|
206
|
+
return formatter(error, phase)
|
|
207
|
+
} catch {
|
|
208
|
+
return phase === "execution" ? maskExecutionError(error) : error.toJSON()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return phase === "execution" ? maskExecutionError(error) : error.toJSON()
|
|
212
|
+
}
|
package/src/ws.ts
CHANGED
|
@@ -14,7 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { NifraWebSocket, WebSocketContext, WebSocketHandler } from "@nifrajs/core/ws"
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type DocumentNode,
|
|
19
|
+
execute,
|
|
20
|
+
GraphQLError,
|
|
21
|
+
type GraphQLSchema,
|
|
22
|
+
parse,
|
|
23
|
+
specifiedRules,
|
|
24
|
+
subscribe,
|
|
25
|
+
validate,
|
|
26
|
+
} from "graphql"
|
|
18
27
|
import {
|
|
19
28
|
type ConnectionInitMessage,
|
|
20
29
|
GRAPHQL_TRANSPORT_WS_PROTOCOL,
|
|
@@ -23,6 +32,13 @@ import {
|
|
|
23
32
|
type ServerOptions,
|
|
24
33
|
} from "graphql-ws"
|
|
25
34
|
import { buildContext, type GraphqlContextBuilder } from "./context.ts"
|
|
35
|
+
import {
|
|
36
|
+
documentLimitError,
|
|
37
|
+
documentMetrics,
|
|
38
|
+
type GraphqlLimits,
|
|
39
|
+
graphqlLimits,
|
|
40
|
+
requestLimitError,
|
|
41
|
+
} from "./limits.ts"
|
|
26
42
|
|
|
27
43
|
/** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
|
|
28
44
|
interface GraphqlWsConnection {
|
|
@@ -31,6 +47,7 @@ interface GraphqlWsConnection {
|
|
|
31
47
|
dispatch?: (message: string) => void | Promise<void>
|
|
32
48
|
/** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
|
|
33
49
|
closed?: (code: number, reason: string) => void | Promise<void>
|
|
50
|
+
readonly subscriptions: Set<string>
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
export interface GraphqlWsOptions<
|
|
@@ -50,6 +67,17 @@ export interface GraphqlWsOptions<
|
|
|
50
67
|
) => boolean | Promise<boolean>
|
|
51
68
|
/** Additional raw `graphql-ws` server options merged last (advanced escape hatch). */
|
|
52
69
|
readonly serverOptions?: Partial<ServerOptions>
|
|
70
|
+
/** Same query/complexity limits as the HTTP transport. */
|
|
71
|
+
readonly maxQueryBytes?: number
|
|
72
|
+
readonly maxVariablesBytes?: number
|
|
73
|
+
readonly maxDepth?: number
|
|
74
|
+
readonly maxAliases?: number
|
|
75
|
+
readonly maxComplexity?: number
|
|
76
|
+
readonly maxOperations?: number
|
|
77
|
+
/** Maximum concurrent subscription operation ids per connection. Default 20. */
|
|
78
|
+
readonly maxSubscriptions?: number
|
|
79
|
+
/** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
|
|
80
|
+
readonly executionTimeoutMs?: number
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
/** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
|
|
@@ -57,6 +85,105 @@ function asText(data: string | Uint8Array): string {
|
|
|
57
85
|
return typeof data === "string" ? data : new TextDecoder().decode(data)
|
|
58
86
|
}
|
|
59
87
|
|
|
88
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
90
|
+
? (value as Record<string, unknown>)
|
|
91
|
+
: undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function graphqlWsLimits(options: GraphqlWsOptions): GraphqlLimits {
|
|
95
|
+
return graphqlLimits(options)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertWsNumber(value: number, name: string, minimum: number): void {
|
|
99
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
100
|
+
throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function subscriptionFrameError(
|
|
105
|
+
message: string,
|
|
106
|
+
schema: GraphQLSchema,
|
|
107
|
+
limits: GraphqlLimits,
|
|
108
|
+
): { readonly id: unknown; readonly message: string } | undefined {
|
|
109
|
+
let frame: unknown
|
|
110
|
+
try {
|
|
111
|
+
frame = JSON.parse(message)
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
const envelope = recordOf(frame)
|
|
116
|
+
if (envelope?.type !== "subscribe") return undefined
|
|
117
|
+
const id = envelope.id
|
|
118
|
+
const payload = recordOf(envelope.payload)
|
|
119
|
+
const query = payload?.query
|
|
120
|
+
if (typeof query !== "string") return { id, message: "A subscription query is required." }
|
|
121
|
+
const variablesValue = payload?.variables
|
|
122
|
+
const variables =
|
|
123
|
+
variablesValue === undefined || variablesValue === null ? null : recordOf(variablesValue)
|
|
124
|
+
if (variablesValue !== undefined && variablesValue !== null && variables === undefined) {
|
|
125
|
+
return { id, message: "Subscription variables must be an object." }
|
|
126
|
+
}
|
|
127
|
+
const requestError = requestLimitError(query, variables ?? null, limits)
|
|
128
|
+
if (requestError !== undefined) return { id, message: requestError }
|
|
129
|
+
let document: DocumentNode
|
|
130
|
+
try {
|
|
131
|
+
document = parse(query)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
|
|
134
|
+
}
|
|
135
|
+
const validationErrors = validate(schema, document, specifiedRules)
|
|
136
|
+
if (validationErrors.length > 0)
|
|
137
|
+
return { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
|
|
138
|
+
const limitError = documentLimitError(documentMetrics(document), limits)
|
|
139
|
+
return limitError === undefined ? undefined : { id, message: limitError }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function maskWsError(error: unknown): unknown {
|
|
143
|
+
const record = recordOf(error)
|
|
144
|
+
if (record === undefined) return { message: "Internal server error." }
|
|
145
|
+
return {
|
|
146
|
+
message: "Internal server error.",
|
|
147
|
+
...(record.locations === undefined ? {} : { locations: record.locations }),
|
|
148
|
+
...(Array.isArray(record.path) ? { path: record.path } : {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function maskWsFrame(message: string): string {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = recordOf(JSON.parse(message))
|
|
155
|
+
if (parsed === undefined) return message
|
|
156
|
+
if (parsed.type === "next") {
|
|
157
|
+
const payload = recordOf(parsed.payload)
|
|
158
|
+
if (payload !== undefined && Array.isArray(payload.errors)) {
|
|
159
|
+
return JSON.stringify({
|
|
160
|
+
...parsed,
|
|
161
|
+
payload: { ...payload, errors: payload.errors.map(maskWsError) },
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (parsed.type === "error" && Array.isArray(parsed.payload)) {
|
|
166
|
+
return JSON.stringify({ ...parsed, payload: parsed.payload.map(maskWsError) })
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
return message
|
|
170
|
+
}
|
|
171
|
+
return message
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function withWsTimeout<T>(work: T | Promise<T>, timeoutMs: number): Promise<T> {
|
|
175
|
+
if (!(work instanceof Promise) || timeoutMs === 0) return work
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
177
|
+
const timeout = new Promise<T>((_, reject) => {
|
|
178
|
+
timer = setTimeout(() => reject(new GraphQLError("Execution timed out.")), timeoutMs)
|
|
179
|
+
})
|
|
180
|
+
try {
|
|
181
|
+
return await Promise.race([work, timeout])
|
|
182
|
+
} finally {
|
|
183
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
60
187
|
/**
|
|
61
188
|
* Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
|
|
62
189
|
* it on a route enabled with `.use(websocket())`:
|
|
@@ -74,10 +201,23 @@ function asText(data: string | Uint8Array): string {
|
|
|
74
201
|
export function graphqlWebSocket<Context extends Record<string, unknown> = Record<string, unknown>>(
|
|
75
202
|
options: GraphqlWsOptions<Context>,
|
|
76
203
|
): WebSocketHandler<GraphqlWsConnection> {
|
|
204
|
+
const limits = graphqlWsLimits(options)
|
|
205
|
+
const maxSubscriptions = options.maxSubscriptions ?? 20
|
|
206
|
+
assertWsNumber(maxSubscriptions, "maxSubscriptions", 1)
|
|
207
|
+
const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
|
|
208
|
+
assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
|
|
209
|
+
const executeWithTimeout: typeof execute = (args) =>
|
|
210
|
+
withWsTimeout(execute(args), executionTimeoutMs)
|
|
211
|
+
const subscribeWithTimeout: typeof subscribe = (args) =>
|
|
212
|
+
withWsTimeout(subscribe(args), executionTimeoutMs)
|
|
77
213
|
const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
|
|
214
|
+
// Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
|
|
215
|
+
// controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
|
|
216
|
+
// wrappers or the context/authentication seams with an unbounded implementation.
|
|
217
|
+
...options.serverOptions,
|
|
78
218
|
schema: options.schema,
|
|
79
|
-
execute,
|
|
80
|
-
subscribe,
|
|
219
|
+
execute: executeWithTimeout,
|
|
220
|
+
subscribe: subscribeWithTimeout,
|
|
81
221
|
onConnect: options.onConnect
|
|
82
222
|
? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
|
|
83
223
|
return await options.onConnect?.(
|
|
@@ -90,12 +230,11 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
90
230
|
? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
|
|
91
231
|
await buildContext(options.context, { request: ctx.extra.request })
|
|
92
232
|
: undefined,
|
|
93
|
-
...options.serverOptions,
|
|
94
233
|
})
|
|
95
234
|
|
|
96
235
|
return {
|
|
97
236
|
upgrade(c: WebSocketContext): GraphqlWsConnection {
|
|
98
|
-
return { request: c.req }
|
|
237
|
+
return { request: c.req, subscriptions: new Set() }
|
|
99
238
|
},
|
|
100
239
|
|
|
101
240
|
open(ws: NifraWebSocket<GraphqlWsConnection>): void {
|
|
@@ -106,7 +245,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
106
245
|
{
|
|
107
246
|
protocol,
|
|
108
247
|
send: async (data: string) => {
|
|
109
|
-
ws.send(data)
|
|
248
|
+
ws.send(maskWsFrame(data))
|
|
110
249
|
},
|
|
111
250
|
close: (code: number, reason: string) => {
|
|
112
251
|
ws.close(code, reason)
|
|
@@ -126,10 +265,43 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
|
|
|
126
265
|
// iterator completes (`await for await`), so awaiting it here would block every later frame on
|
|
127
266
|
// this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
|
|
128
267
|
// its own sends and error frames from inside that loop; we only feed it inbound frames.
|
|
129
|
-
|
|
268
|
+
const message = asText(data)
|
|
269
|
+
const rejected = subscriptionFrameError(message, options.schema, limits)
|
|
270
|
+
if (rejected !== undefined) {
|
|
271
|
+
ws.send(
|
|
272
|
+
JSON.stringify({
|
|
273
|
+
id: rejected.id,
|
|
274
|
+
type: "error",
|
|
275
|
+
payload: [{ message: rejected.message }],
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const frame = recordOf(JSON.parse(message))
|
|
282
|
+
if (frame?.type === "subscribe" && typeof frame.id === "string") {
|
|
283
|
+
if (ws.data.subscriptions.size >= maxSubscriptions) {
|
|
284
|
+
ws.send(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
id: frame.id,
|
|
287
|
+
type: "error",
|
|
288
|
+
payload: [{ message: "Subscription limit exceeded." }],
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
ws.data.subscriptions.add(frame.id)
|
|
294
|
+
} else if (frame?.type === "complete" && typeof frame.id === "string") {
|
|
295
|
+
ws.data.subscriptions.delete(frame.id)
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
// graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
|
|
299
|
+
}
|
|
300
|
+
void ws.data.dispatch?.(message)
|
|
130
301
|
},
|
|
131
302
|
|
|
132
303
|
close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
|
|
304
|
+
ws.data.subscriptions.clear()
|
|
133
305
|
void ws.data.closed?.(code, reason)
|
|
134
306
|
},
|
|
135
307
|
}
|