@nifrajs/graphql 3.2.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/dist/ws.js
CHANGED
|
@@ -12,13 +12,112 @@
|
|
|
12
12
|
* `graphql-ws` is an optional peer dependency: import this module only when you need subscriptions, and
|
|
13
13
|
* the dependency (and its bundle weight) stays off every app that doesn't.
|
|
14
14
|
*/
|
|
15
|
-
import { execute, subscribe } from "graphql";
|
|
15
|
+
import { execute, GraphQLError, parse, specifiedRules, subscribe, validate, } from "graphql";
|
|
16
16
|
import { GRAPHQL_TRANSPORT_WS_PROTOCOL, makeServer, } from "graphql-ws";
|
|
17
17
|
import { buildContext } from "./context.js";
|
|
18
|
+
import { documentLimitError, documentMetrics, graphqlLimits, requestLimitError, } from "./limits.js";
|
|
18
19
|
/** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
|
|
19
20
|
function asText(data) {
|
|
20
21
|
return typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
21
22
|
}
|
|
23
|
+
function recordOf(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
25
|
+
? value
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
function graphqlWsLimits(options) {
|
|
29
|
+
return graphqlLimits(options);
|
|
30
|
+
}
|
|
31
|
+
function assertWsNumber(value, name, minimum) {
|
|
32
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
33
|
+
throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function subscriptionFrameError(message, schema, limits) {
|
|
37
|
+
let frame;
|
|
38
|
+
try {
|
|
39
|
+
frame = JSON.parse(message);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const envelope = recordOf(frame);
|
|
45
|
+
if (envelope?.type !== "subscribe")
|
|
46
|
+
return undefined;
|
|
47
|
+
const id = envelope.id;
|
|
48
|
+
const payload = recordOf(envelope.payload);
|
|
49
|
+
const query = payload?.query;
|
|
50
|
+
if (typeof query !== "string")
|
|
51
|
+
return { id, message: "A subscription query is required." };
|
|
52
|
+
const variablesValue = payload?.variables;
|
|
53
|
+
const variables = variablesValue === undefined || variablesValue === null ? null : recordOf(variablesValue);
|
|
54
|
+
if (variablesValue !== undefined && variablesValue !== null && variables === undefined) {
|
|
55
|
+
return { id, message: "Subscription variables must be an object." };
|
|
56
|
+
}
|
|
57
|
+
const requestError = requestLimitError(query, variables ?? null, limits);
|
|
58
|
+
if (requestError !== undefined)
|
|
59
|
+
return { id, message: requestError };
|
|
60
|
+
let document;
|
|
61
|
+
try {
|
|
62
|
+
document = parse(query);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." };
|
|
66
|
+
}
|
|
67
|
+
const validationErrors = validate(schema, document, specifiedRules);
|
|
68
|
+
if (validationErrors.length > 0)
|
|
69
|
+
return { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." };
|
|
70
|
+
const limitError = documentLimitError(documentMetrics(document), limits);
|
|
71
|
+
return limitError === undefined ? undefined : { id, message: limitError };
|
|
72
|
+
}
|
|
73
|
+
function maskWsError(error) {
|
|
74
|
+
const record = recordOf(error);
|
|
75
|
+
if (record === undefined)
|
|
76
|
+
return { message: "Internal server error." };
|
|
77
|
+
return {
|
|
78
|
+
message: "Internal server error.",
|
|
79
|
+
...(record.locations === undefined ? {} : { locations: record.locations }),
|
|
80
|
+
...(Array.isArray(record.path) ? { path: record.path } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function maskWsFrame(message) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = recordOf(JSON.parse(message));
|
|
86
|
+
if (parsed === undefined)
|
|
87
|
+
return message;
|
|
88
|
+
if (parsed.type === "next") {
|
|
89
|
+
const payload = recordOf(parsed.payload);
|
|
90
|
+
if (payload !== undefined && Array.isArray(payload.errors)) {
|
|
91
|
+
return JSON.stringify({
|
|
92
|
+
...parsed,
|
|
93
|
+
payload: { ...payload, errors: payload.errors.map(maskWsError) },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (parsed.type === "error" && Array.isArray(parsed.payload)) {
|
|
98
|
+
return JSON.stringify({ ...parsed, payload: parsed.payload.map(maskWsError) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return message;
|
|
103
|
+
}
|
|
104
|
+
return message;
|
|
105
|
+
}
|
|
106
|
+
async function withWsTimeout(work, timeoutMs) {
|
|
107
|
+
if (!(work instanceof Promise) || timeoutMs === 0)
|
|
108
|
+
return work;
|
|
109
|
+
let timer;
|
|
110
|
+
const timeout = new Promise((_, reject) => {
|
|
111
|
+
timer = setTimeout(() => reject(new GraphQLError("Execution timed out.")), timeoutMs);
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
return await Promise.race([work, timeout]);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (timer !== undefined)
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
22
121
|
/**
|
|
23
122
|
* Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
|
|
24
123
|
* it on a route enabled with `.use(websocket())`:
|
|
@@ -34,10 +133,21 @@ function asText(data) {
|
|
|
34
133
|
* `graphql-ws` client to connect.
|
|
35
134
|
*/
|
|
36
135
|
export function graphqlWebSocket(options) {
|
|
136
|
+
const limits = graphqlWsLimits(options);
|
|
137
|
+
const maxSubscriptions = options.maxSubscriptions ?? 20;
|
|
138
|
+
assertWsNumber(maxSubscriptions, "maxSubscriptions", 1);
|
|
139
|
+
const executionTimeoutMs = options.executionTimeoutMs ?? 10_000;
|
|
140
|
+
assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0);
|
|
141
|
+
const executeWithTimeout = (args) => withWsTimeout(execute(args), executionTimeoutMs);
|
|
142
|
+
const subscribeWithTimeout = (args) => withWsTimeout(subscribe(args), executionTimeoutMs);
|
|
37
143
|
const gqlServer = makeServer({
|
|
144
|
+
// Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
|
|
145
|
+
// controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
|
|
146
|
+
// wrappers or the context/authentication seams with an unbounded implementation.
|
|
147
|
+
...options.serverOptions,
|
|
38
148
|
schema: options.schema,
|
|
39
|
-
execute,
|
|
40
|
-
subscribe,
|
|
149
|
+
execute: executeWithTimeout,
|
|
150
|
+
subscribe: subscribeWithTimeout,
|
|
41
151
|
onConnect: options.onConnect
|
|
42
152
|
? async (ctx) => {
|
|
43
153
|
return await options.onConnect?.(ctx.connectionParams, ctx.extra.request);
|
|
@@ -46,11 +156,10 @@ export function graphqlWebSocket(options) {
|
|
|
46
156
|
context: options.context
|
|
47
157
|
? async (ctx) => await buildContext(options.context, { request: ctx.extra.request })
|
|
48
158
|
: undefined,
|
|
49
|
-
...options.serverOptions,
|
|
50
159
|
});
|
|
51
160
|
return {
|
|
52
161
|
upgrade(c) {
|
|
53
|
-
return { request: c.req };
|
|
162
|
+
return { request: c.req, subscriptions: new Set() };
|
|
54
163
|
},
|
|
55
164
|
open(ws) {
|
|
56
165
|
const protocol = ws.raw?.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL;
|
|
@@ -58,7 +167,7 @@ export function graphqlWebSocket(options) {
|
|
|
58
167
|
const closed = gqlServer.opened({
|
|
59
168
|
protocol,
|
|
60
169
|
send: async (data) => {
|
|
61
|
-
ws.send(data);
|
|
170
|
+
ws.send(maskWsFrame(data));
|
|
62
171
|
},
|
|
63
172
|
close: (code, reason) => {
|
|
64
173
|
ws.close(code, reason);
|
|
@@ -75,9 +184,40 @@ export function graphqlWebSocket(options) {
|
|
|
75
184
|
// iterator completes (`await for await`), so awaiting it here would block every later frame on
|
|
76
185
|
// this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
|
|
77
186
|
// its own sends and error frames from inside that loop; we only feed it inbound frames.
|
|
78
|
-
|
|
187
|
+
const message = asText(data);
|
|
188
|
+
const rejected = subscriptionFrameError(message, options.schema, limits);
|
|
189
|
+
if (rejected !== undefined) {
|
|
190
|
+
ws.send(JSON.stringify({
|
|
191
|
+
id: rejected.id,
|
|
192
|
+
type: "error",
|
|
193
|
+
payload: [{ message: rejected.message }],
|
|
194
|
+
}));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const frame = recordOf(JSON.parse(message));
|
|
199
|
+
if (frame?.type === "subscribe" && typeof frame.id === "string") {
|
|
200
|
+
if (ws.data.subscriptions.size >= maxSubscriptions) {
|
|
201
|
+
ws.send(JSON.stringify({
|
|
202
|
+
id: frame.id,
|
|
203
|
+
type: "error",
|
|
204
|
+
payload: [{ message: "Subscription limit exceeded." }],
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
ws.data.subscriptions.add(frame.id);
|
|
209
|
+
}
|
|
210
|
+
else if (frame?.type === "complete" && typeof frame.id === "string") {
|
|
211
|
+
ws.data.subscriptions.delete(frame.id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
|
|
216
|
+
}
|
|
217
|
+
void ws.data.dispatch?.(message);
|
|
79
218
|
},
|
|
80
219
|
close(ws, code, reason) {
|
|
220
|
+
ws.data.subscriptions.clear();
|
|
81
221
|
void ws.data.closed?.(code, reason);
|
|
82
222
|
},
|
|
83
223
|
};
|
package/dist/ws.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.js","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,
|
|
1
|
+
{"version":3,"file":"ws.js","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/graphql",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Mount a GraphQL endpoint on a nifra app - a spec-compliant GraphQL-over-HTTP handler you mount at POST /graphql, graphql-ws subscriptions over nifra's native WebSocket lane, and typed nifra context piped straight into your resolvers. Uses graphql's own execute/subscribe; no server framework re-bundled.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@nifrajs/core": "^3.
|
|
46
|
+
"@nifrajs/core": "^3.5.0",
|
|
47
47
|
"graphql": "^17.0.0",
|
|
48
48
|
"graphql-ws": "^6.0.0"
|
|
49
49
|
},
|
package/src/http.ts
CHANGED
|
@@ -44,6 +44,14 @@ import {
|
|
|
44
44
|
type GraphqlContextInput,
|
|
45
45
|
type NifraContextLike,
|
|
46
46
|
} from "./context.ts"
|
|
47
|
+
import {
|
|
48
|
+
documentLimitError,
|
|
49
|
+
documentMetrics,
|
|
50
|
+
type GraphqlLimits,
|
|
51
|
+
graphqlLimits,
|
|
52
|
+
requestLimitError,
|
|
53
|
+
safeFormattedError,
|
|
54
|
+
} from "./limits.ts"
|
|
47
55
|
|
|
48
56
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
49
57
|
const GRAPHQL_RESPONSE_JSON = "application/graphql-response+json; charset=utf-8"
|
|
@@ -64,6 +72,20 @@ export interface GraphqlHttpOptions<Context = unknown, Env = unknown> {
|
|
|
64
72
|
readonly nifra?: NifraContextLike<Env>
|
|
65
73
|
/** Maximum request body size in bytes. Default 1 MB. Rejected bodies answer 413. */
|
|
66
74
|
readonly maxBodyBytes?: number
|
|
75
|
+
/** Maximum UTF-8 query size. Default 64 KiB, applied to POST and GET. */
|
|
76
|
+
readonly maxQueryBytes?: number
|
|
77
|
+
/** Maximum UTF-8 serialized variables size. Default 256 KiB. */
|
|
78
|
+
readonly maxVariablesBytes?: number
|
|
79
|
+
/** Maximum expanded field depth. Default 20. */
|
|
80
|
+
readonly maxDepth?: number
|
|
81
|
+
/** Maximum aliases in one document. Default 100. */
|
|
82
|
+
readonly maxAliases?: number
|
|
83
|
+
/** Maximum simple field complexity in one document. Default 1,000. */
|
|
84
|
+
readonly maxComplexity?: number
|
|
85
|
+
/** Maximum operation definitions in one document. Default 10. */
|
|
86
|
+
readonly maxOperations?: number
|
|
87
|
+
/** Maximum asynchronous resolver execution time. Default 10 seconds. */
|
|
88
|
+
readonly executionTimeoutMs?: number
|
|
67
89
|
/** Prototype-pollution policy for the parsed JSON body. Default `"reject"`. */
|
|
68
90
|
readonly protoPoisoning?: ProtoPoisoning
|
|
69
91
|
/** Optional root value passed to the executor. */
|
|
@@ -80,6 +102,8 @@ export interface GraphqlHttpOptions<Context = unknown, Env = unknown> {
|
|
|
80
102
|
readonly authorize?: (request: Request) => boolean | Promise<boolean>
|
|
81
103
|
/** Emit the legacy always-200 `application/json` response shape instead of `application/graphql-response+json`. */
|
|
82
104
|
readonly legacyJsonResponse?: boolean
|
|
105
|
+
/** Format request/execution errors. The default masks resolver messages and preserves request errors. */
|
|
106
|
+
readonly formatError?: (error: GraphQLError, phase: "request" | "execution") => unknown
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
/** A parsed GraphQL request payload (POST body or GET query string), before validation. */
|
|
@@ -95,6 +119,12 @@ function assertByteLimit(value: number): void {
|
|
|
95
119
|
}
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
function assertExecutionTimeout(value: number): void {
|
|
123
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
124
|
+
throw new RangeError("GraphQL executionTimeoutMs must be a non-negative safe integer")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
function corsFor(
|
|
99
129
|
request: Request,
|
|
100
130
|
allowedOrigins: readonly string[] | undefined,
|
|
@@ -136,9 +166,15 @@ function requestError(
|
|
|
136
166
|
legacy: boolean,
|
|
137
167
|
cors: Record<string, string>,
|
|
138
168
|
errors?: readonly GraphQLError[],
|
|
169
|
+
formatter?: GraphqlHttpOptions["formatError"],
|
|
139
170
|
): Response {
|
|
140
171
|
const payload = errors ?? [new GraphQLError(message)]
|
|
141
|
-
return graphqlResponse(
|
|
172
|
+
return graphqlResponse(
|
|
173
|
+
{ errors: payload.map((e) => safeFormattedError(e, "request", formatter)) },
|
|
174
|
+
status,
|
|
175
|
+
legacy,
|
|
176
|
+
cors,
|
|
177
|
+
)
|
|
142
178
|
}
|
|
143
179
|
|
|
144
180
|
/** Coerce a `?variables=<json>` string (GET) into an object, or throw a GraphQLError on bad JSON. */
|
|
@@ -189,18 +225,43 @@ async function run<Context, Env>(
|
|
|
189
225
|
isGet: boolean,
|
|
190
226
|
legacy: boolean,
|
|
191
227
|
cors: Record<string, string>,
|
|
228
|
+
limits: GraphqlLimits,
|
|
229
|
+
executionTimeoutMs: number,
|
|
192
230
|
): Promise<Response> {
|
|
231
|
+
const requestLimit = requestLimitError(params.query, params.variables, limits)
|
|
232
|
+
if (requestLimit !== undefined) {
|
|
233
|
+
return requestError(
|
|
234
|
+
requestLimit,
|
|
235
|
+
413,
|
|
236
|
+
legacy,
|
|
237
|
+
cors,
|
|
238
|
+
[new GraphQLError(requestLimit)],
|
|
239
|
+
opts.formatError,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
193
242
|
let document: DocumentNode
|
|
194
243
|
try {
|
|
195
244
|
document = parse(new Source(params.query, "GraphQL request"))
|
|
196
245
|
} catch (err) {
|
|
197
246
|
const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
|
|
198
|
-
return requestError("", 400, legacy, cors, [gqlErr])
|
|
247
|
+
return requestError("", 400, legacy, cors, [gqlErr], opts.formatError)
|
|
199
248
|
}
|
|
200
249
|
|
|
201
250
|
const validationErrors = validate(opts.schema, document, specifiedRules)
|
|
202
251
|
if (validationErrors.length > 0) {
|
|
203
|
-
return requestError("", 400, legacy, cors, validationErrors)
|
|
252
|
+
return requestError("", 400, legacy, cors, validationErrors, opts.formatError)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const documentLimit = documentLimitError(documentMetrics(document), limits)
|
|
256
|
+
if (documentLimit !== undefined) {
|
|
257
|
+
return requestError(
|
|
258
|
+
documentLimit,
|
|
259
|
+
400,
|
|
260
|
+
legacy,
|
|
261
|
+
cors,
|
|
262
|
+
[new GraphQLError(documentLimit)],
|
|
263
|
+
opts.formatError,
|
|
264
|
+
)
|
|
204
265
|
}
|
|
205
266
|
|
|
206
267
|
// GET is idempotent and cacheable, so per the GraphQL-over-HTTP spec it may run queries only.
|
|
@@ -212,6 +273,7 @@ async function run<Context, Env>(
|
|
|
212
273
|
legacy,
|
|
213
274
|
{ ...cors, allow: "POST" },
|
|
214
275
|
[new GraphQLError("Only query operations are allowed over GET.")],
|
|
276
|
+
opts.formatError,
|
|
215
277
|
)
|
|
216
278
|
}
|
|
217
279
|
|
|
@@ -223,26 +285,74 @@ async function run<Context, Env>(
|
|
|
223
285
|
} catch (err) {
|
|
224
286
|
const gqlErr =
|
|
225
287
|
err instanceof GraphQLError ? err : new GraphQLError("Failed to build request context.")
|
|
226
|
-
return graphqlResponse(
|
|
288
|
+
return graphqlResponse(
|
|
289
|
+
{ errors: [safeFormattedError(gqlErr, "execution", opts.formatError)] },
|
|
290
|
+
500,
|
|
291
|
+
legacy,
|
|
292
|
+
cors,
|
|
293
|
+
)
|
|
227
294
|
}
|
|
228
295
|
|
|
229
296
|
let result: ExecutionResult
|
|
230
297
|
try {
|
|
231
|
-
|
|
298
|
+
const execution = execute({
|
|
232
299
|
schema: opts.schema,
|
|
233
300
|
document,
|
|
234
301
|
rootValue: opts.rootValue,
|
|
235
302
|
contextValue,
|
|
236
303
|
variableValues: params.variables ?? undefined,
|
|
237
304
|
operationName: params.operationName ?? undefined,
|
|
238
|
-
})
|
|
305
|
+
})
|
|
306
|
+
result = (await withExecutionTimeout(execution, executionTimeoutMs)) as ExecutionResult
|
|
239
307
|
} catch (err) {
|
|
240
|
-
const gqlErr =
|
|
241
|
-
|
|
308
|
+
const gqlErr =
|
|
309
|
+
err instanceof GraphQLError
|
|
310
|
+
? err
|
|
311
|
+
: err instanceof ExecutionTimeoutError
|
|
312
|
+
? new GraphQLError("Execution timed out.")
|
|
313
|
+
: new GraphQLError("Execution failed.")
|
|
314
|
+
return graphqlResponse(
|
|
315
|
+
{ errors: [safeFormattedError(gqlErr, "execution", opts.formatError)] },
|
|
316
|
+
err instanceof ExecutionTimeoutError ? 504 : 500,
|
|
317
|
+
legacy,
|
|
318
|
+
cors,
|
|
319
|
+
)
|
|
242
320
|
}
|
|
243
321
|
|
|
244
322
|
// Field/execution errors are a normal 200 result with both `data` and `errors`.
|
|
245
|
-
return graphqlResponse(
|
|
323
|
+
return graphqlResponse(
|
|
324
|
+
result.errors === undefined
|
|
325
|
+
? result
|
|
326
|
+
: {
|
|
327
|
+
...result,
|
|
328
|
+
errors: result.errors.map((error) =>
|
|
329
|
+
safeFormattedError(error, "execution", opts.formatError),
|
|
330
|
+
),
|
|
331
|
+
},
|
|
332
|
+
200,
|
|
333
|
+
legacy,
|
|
334
|
+
cors,
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
class ExecutionTimeoutError extends Error {
|
|
339
|
+
constructor() {
|
|
340
|
+
super("GraphQL execution timed out")
|
|
341
|
+
this.name = "ExecutionTimeoutError"
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function withExecutionTimeout<T>(work: T | Promise<T>, timeoutMs: number): Promise<T> {
|
|
346
|
+
if (!(work instanceof Promise) || timeoutMs === 0) return work
|
|
347
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
348
|
+
const timeout = new Promise<T>((_, reject) => {
|
|
349
|
+
timer = setTimeout(() => reject(new ExecutionTimeoutError()), timeoutMs)
|
|
350
|
+
})
|
|
351
|
+
try {
|
|
352
|
+
return await Promise.race([work, timeout])
|
|
353
|
+
} finally {
|
|
354
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
355
|
+
}
|
|
246
356
|
}
|
|
247
357
|
|
|
248
358
|
/**
|
|
@@ -256,6 +366,9 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
256
366
|
): Promise<Response> {
|
|
257
367
|
const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
258
368
|
assertByteLimit(maxBytes)
|
|
369
|
+
const limits: GraphqlLimits = graphqlLimits(options)
|
|
370
|
+
const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
|
|
371
|
+
assertExecutionTimeout(executionTimeoutMs)
|
|
259
372
|
const legacy = options.legacyJsonResponse === true
|
|
260
373
|
|
|
261
374
|
const cors = corsFor(request, options.allowedOrigins)
|
|
@@ -266,7 +379,14 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
266
379
|
if (options.authorize !== undefined) {
|
|
267
380
|
const ok = await options.authorize(request)
|
|
268
381
|
if (!ok) {
|
|
269
|
-
return requestError(
|
|
382
|
+
return requestError(
|
|
383
|
+
"Unauthorized.",
|
|
384
|
+
401,
|
|
385
|
+
legacy,
|
|
386
|
+
cors,
|
|
387
|
+
[new GraphQLError("Unauthorized.")],
|
|
388
|
+
options.formatError,
|
|
389
|
+
)
|
|
270
390
|
}
|
|
271
391
|
}
|
|
272
392
|
|
|
@@ -276,15 +396,20 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
276
396
|
params = paramsFromQuery(queryObjectOf(searchOf(request.url)) as Record<string, unknown>)
|
|
277
397
|
} catch (err) {
|
|
278
398
|
const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
|
|
279
|
-
return requestError("", 400, legacy, cors, [gqlErr])
|
|
399
|
+
return requestError("", 400, legacy, cors, [gqlErr], options.formatError)
|
|
280
400
|
}
|
|
281
|
-
return run(params, request, options, true, legacy, cors)
|
|
401
|
+
return run(params, request, options, true, legacy, cors, limits, executionTimeoutMs)
|
|
282
402
|
}
|
|
283
403
|
|
|
284
404
|
if (request.method !== "POST") {
|
|
285
|
-
return requestError(
|
|
286
|
-
|
|
287
|
-
|
|
405
|
+
return requestError(
|
|
406
|
+
"Method not allowed.",
|
|
407
|
+
405,
|
|
408
|
+
legacy,
|
|
409
|
+
{ ...cors, allow: "GET, POST" },
|
|
410
|
+
[new GraphQLError("Only GET and POST are supported.")],
|
|
411
|
+
options.formatError,
|
|
412
|
+
)
|
|
288
413
|
}
|
|
289
414
|
|
|
290
415
|
// Read the JSON body through core's single bounded + proto-guarded framing lane.
|
|
@@ -298,9 +423,9 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
298
423
|
params = paramsFromBody(parsed)
|
|
299
424
|
} catch (err) {
|
|
300
425
|
const gqlErr = err instanceof GraphQLError ? err : new GraphQLError(String(err))
|
|
301
|
-
return requestError("", 400, legacy, cors, [gqlErr])
|
|
426
|
+
return requestError("", 400, legacy, cors, [gqlErr], options.formatError)
|
|
302
427
|
}
|
|
303
|
-
return run(params, request, options, false, legacy, cors)
|
|
428
|
+
return run(params, request, options, false, legacy, cors, limits, executionTimeoutMs)
|
|
304
429
|
},
|
|
305
430
|
(response: Response | ResponseResult) => {
|
|
306
431
|
// readBodyFramed's own rejections (413 too-large, 415, proto-guard) - render + attach CORS.
|
|
@@ -311,9 +436,14 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
311
436
|
(err: unknown) => {
|
|
312
437
|
// A body that failed to read (bad JSON / proto-guard) is a request error, not a crash.
|
|
313
438
|
if (err instanceof Response) return err
|
|
314
|
-
return requestError(
|
|
315
|
-
|
|
316
|
-
|
|
439
|
+
return requestError(
|
|
440
|
+
"Invalid request body.",
|
|
441
|
+
400,
|
|
442
|
+
legacy,
|
|
443
|
+
cors,
|
|
444
|
+
[new GraphQLError("Request body could not be read as JSON.")],
|
|
445
|
+
options.formatError,
|
|
446
|
+
)
|
|
317
447
|
},
|
|
318
448
|
).catch(() => plainErrorResponse(cors))
|
|
319
449
|
}
|