@znight/graphql 0.0.0-stage → 0.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1062 -2
  3. package/dist/compiler.d.mts +93 -0
  4. package/dist/compiler.mjs +2832 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +542 -0
  7. package/dist/index.mjs +128 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-BdBxr5NQ.mjs +3222 -0
  12. package/dist/plugin-BdBxr5NQ.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-CgbV9LjD.d.mts +234 -0
  17. package/package.json +95 -4
  18. package/src/compiler/analyze.ts +608 -0
  19. package/src/compiler/builder.ts +1640 -0
  20. package/src/compiler/constants.ts +74 -0
  21. package/src/compiler/data.ts +73 -0
  22. package/src/compiler/defaults.ts +92 -0
  23. package/src/compiler/discriminators.ts +53 -0
  24. package/src/compiler/docs.ts +55 -0
  25. package/src/compiler/generate.ts +405 -0
  26. package/src/compiler/ids.ts +128 -0
  27. package/src/compiler/index.ts +93 -0
  28. package/src/compiler/locate.ts +44 -0
  29. package/src/compiler/model.ts +122 -0
  30. package/src/compiler/options.ts +54 -0
  31. package/src/compiler/scalars.ts +138 -0
  32. package/src/compiler/sdl-view.ts +59 -0
  33. package/src/context.ts +181 -0
  34. package/src/decisions.ts +169 -0
  35. package/src/decorators.ts +196 -0
  36. package/src/errors.ts +89 -0
  37. package/src/execution.ts +1064 -0
  38. package/src/format.ts +285 -0
  39. package/src/index.ts +84 -0
  40. package/src/limits.ts +177 -0
  41. package/src/loaders.ts +149 -0
  42. package/src/memory-pubsub.ts +133 -0
  43. package/src/metadata.ts +195 -0
  44. package/src/names.ts +47 -0
  45. package/src/options.ts +309 -0
  46. package/src/pipeline.ts +277 -0
  47. package/src/plugin.ts +557 -0
  48. package/src/pubsub.ts +36 -0
  49. package/src/scalars.ts +131 -0
  50. package/src/schema-hash.ts +18 -0
  51. package/src/schema.ts +557 -0
  52. package/src/sdl.ts +515 -0
  53. package/src/testing.ts +454 -0
  54. package/src/tokens.ts +22 -0
  55. package/src/transport/abort.ts +80 -0
  56. package/src/transport/accept.ts +155 -0
  57. package/src/transport/graphiql.ts +106 -0
  58. package/src/transport/http.ts +373 -0
  59. package/src/transport/sse.ts +45 -0
  60. package/src/type-ref.ts +89 -0
  61. package/src/types.ts +190 -0
  62. package/src/validate-args.ts +48 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Content negotiation of the GraphQL endpoint (RFC 0005 §8.3): which of the media types the
3
+ * endpoint produces a request accepts best. Import-free.
4
+ *
5
+ * `application/json` is the only type matched by wildcards (`*\/*`, `application/*`): the
6
+ * GraphQL-over-HTTP draft asks for `application/json` when the client accepts anything, and the
7
+ * other types change the protocol (`application/graphql-response+json` statuses, an event stream,
8
+ * the GraphiQL page), so they are chosen only when listed. Among types of equal quality,
9
+ * `application/graphql-response+json` wins, then `application/json`, then HTML (a browser listing
10
+ * `text/html` first gets GraphiQL). An event stream is chosen only when it is strictly preferred, or
11
+ * the only acceptable type: a client listing JSON and `text/event-stream` alike (urql, for
12
+ * incremental delivery) gets its queries and mutations as one JSON body, and its subscriptions as
13
+ * a stream ({@link STREAMABLE}).
14
+ */
15
+
16
+ /** What a response is encoded as. */
17
+ export const Media: {
18
+ readonly EventStream: 0
19
+ readonly GraphQLJson: 1
20
+ readonly Json: 2
21
+ readonly Html: 3
22
+ /** Nothing the endpoint produces is acceptable: 406. */
23
+ readonly None: 4
24
+ } = Object.freeze({ EventStream: 0, GraphQLJson: 1, Json: 2, Html: 3, None: 4 })
25
+
26
+ /** One of the {@link Media} values. */
27
+ export type MediaKind = (typeof Media)[keyof typeof Media]
28
+
29
+ /**
30
+ * Added to a negotiated JSON type when an event stream is acceptable too, though not preferred: a
31
+ * subscription, which has no JSON answer, is then streamed. `answer & MEDIA` is the {@link MediaKind}.
32
+ */
33
+ export const STREAMABLE = 8
34
+
35
+ /** The {@link MediaKind} bits of a {@link negotiate} answer. */
36
+ export const MEDIA = 7
37
+
38
+ /** The media type names, by {@link Media} value (`None` excluded). */
39
+ const NAMES: readonly (readonly [type: string, subtype: string])[] = [
40
+ ['text', 'event-stream'],
41
+ ['application', 'graphql-response+json'],
42
+ ['application', 'json'],
43
+ ['text', 'html'],
44
+ ]
45
+
46
+ /** A media range of an `accept` header. */
47
+ interface Range {
48
+ readonly type: string
49
+ readonly subtype: string
50
+ readonly q: number
51
+ }
52
+
53
+ /** The media ranges of an `accept` header (lower-case, without parameters other than `q`). */
54
+ function parseRanges(header: string): Range[] {
55
+ const ranges: Range[] = []
56
+ for (const item of header.split(',')) {
57
+ const parts = item.split(';')
58
+ const media = (parts[0] as string).trim().toLowerCase()
59
+ const slash = media.indexOf('/')
60
+ if (slash <= 0 || slash === media.length - 1) continue
61
+ let q = 1
62
+ for (let index = 1; index < parts.length; index++) {
63
+ const parameter = (parts[index] as string).trim()
64
+ if (parameter.length < 2 || (parameter.charCodeAt(0) | 0x20) !== 0x71 /* q */ || parameter[1] !== '=') continue
65
+ const value = Number(parameter.slice(2))
66
+ // an invalid weight leaves the range as it is (RFC 9110 §12.4.2 allows 0 to 1, 3 decimals)
67
+ if (value >= 0 && value <= 1) q = value
68
+ }
69
+ ranges.push({ type: media.slice(0, slash), subtype: media.slice(slash + 1), q })
70
+ }
71
+ return ranges
72
+ }
73
+
74
+ /**
75
+ * The quality of `type/subtype` in `ranges`: that of the most specific matching range (RFC 9110
76
+ * §12.5.1), wildcards only when `wildcards` is set; 0 when nothing matches.
77
+ */
78
+ function quality(ranges: readonly Range[], type: string, subtype: string, wildcards: boolean): number {
79
+ let best = -1
80
+ let specificity = -1
81
+ for (const range of ranges) {
82
+ let level: number
83
+ if (range.type === type && range.subtype === subtype) level = 2
84
+ else if (!wildcards) continue
85
+ else if (range.type === type && range.subtype === '*') level = 1
86
+ else if (range.type === '*' && range.subtype === '*') level = 0
87
+ else continue
88
+ if (level > specificity || (level === specificity && range.q > best)) {
89
+ specificity = level
90
+ best = range.q
91
+ }
92
+ }
93
+ return best < 0 ? 0 : best
94
+ }
95
+
96
+ /**
97
+ * Chooses the media type of a response among `produced` (a bit mask of `1 << Media.X`), per
98
+ * `accept` (`undefined`: no header, i.e. `application/json`): a {@link MediaKind}, plus
99
+ * {@link STREAMABLE} when a JSON type wins and an event stream is acceptable too.
100
+ */
101
+ export function negotiate(accept: string | undefined, produced: number): number {
102
+ if (accept === undefined) return produced & (1 << Media.Json) ? Media.Json : Media.None
103
+ const ranges = parseRanges(accept)
104
+ if (ranges.length === 0) return produced & (1 << Media.Json) ? Media.Json : Media.None
105
+ let chosen: number = Media.None
106
+ let best = 0
107
+ for (let media: number = Media.GraphQLJson; media < NAMES.length; media++) {
108
+ if ((produced & (1 << media)) === 0) continue
109
+ const [type, subtype] = NAMES[media] as readonly [string, string]
110
+ const q = quality(ranges, type, subtype, media === Media.Json)
111
+ if (q > best) {
112
+ best = q
113
+ chosen = media
114
+ }
115
+ }
116
+ if (produced & (1 << Media.EventStream)) {
117
+ const q = quality(ranges, 'text', 'event-stream', false)
118
+ // strictly preferred, or the only acceptable type
119
+ if (q > best) return Media.EventStream
120
+ if (q > 0 && (chosen === Media.GraphQLJson || chosen === Media.Json)) chosen |= STREAMABLE
121
+ }
122
+ return chosen
123
+ }
124
+
125
+ const CACHE_MAX = 64
126
+
127
+ /**
128
+ * {@link negotiate} for one route, memoised by header value: a server sees a handful of `accept`
129
+ * values, so a request costs a map lookup. Bounded, since clients choose the values: cleared when
130
+ * full.
131
+ */
132
+ export class Negotiator {
133
+ readonly #produced: number
134
+ readonly #cache = new Map<string, number>()
135
+ /** The answer without `accept` header. */
136
+ readonly #absent: number
137
+
138
+ /** `produced`: a bit mask of `1 << Media.X`. */
139
+ constructor(produced: number) {
140
+ this.#produced = produced
141
+ this.#absent = negotiate(undefined, produced)
142
+ }
143
+
144
+ /** The {@link negotiate} answer: a {@link MediaKind}, possibly with {@link STREAMABLE}. */
145
+ choose(accept: string | undefined): number {
146
+ if (accept === undefined) return this.#absent
147
+ let media = this.#cache.get(accept)
148
+ if (media === undefined) {
149
+ media = negotiate(accept, this.#produced)
150
+ if (this.#cache.size === CACHE_MAX) this.#cache.clear()
151
+ this.#cache.set(accept, media)
152
+ }
153
+ return media
154
+ }
155
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The GraphiQL page (RFC 0005 §8.3, §8.9): served on `GET path` with `accept: text/html` when the
3
+ * `graphiql` option is on (default: `NODE_ENV === 'development'`). React and GraphiQL load from a
4
+ * pinned CDN with Subresource Integrity; the inline script is fixed at boot and allowed by hash in
5
+ * the page's own `content-security-policy`, which overrides the strict API default of RFC 0001 on
6
+ * this response only.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+
10
+ /** jsDelivr. */
11
+ export const GRAPHIQL_CDN = 'https://cdn.jsdelivr.net'
12
+
13
+ /** A pinned CDN asset. */
14
+ export interface GraphiQLAsset {
15
+ readonly url: string
16
+ /** Subresource Integrity of the pinned file. */
17
+ readonly integrity: string
18
+ readonly kind: 'script' | 'style'
19
+ }
20
+
21
+ /**
22
+ * The assets of the page: GraphiQL 3 (the last major with a UMD build) on React 18. Bumped by hand,
23
+ * with their SRI hashes (`sha384` of the files on the CDN).
24
+ */
25
+ export const GRAPHIQL_ASSETS: readonly GraphiQLAsset[] = Object.freeze([
26
+ {
27
+ url: `${GRAPHIQL_CDN}/npm/graphiql@3.9.0/graphiql.min.css`,
28
+ integrity: 'sha384-QMux00XgRtwRLSYIY3kw2rj1ovk5AuuliAchk+HSQbqdbGFnz9GYuqIlOqxhwCE2',
29
+ kind: 'style',
30
+ },
31
+ {
32
+ url: `${GRAPHIQL_CDN}/npm/react@18.3.1/umd/react.production.min.js`,
33
+ integrity: 'sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z',
34
+ kind: 'script',
35
+ },
36
+ {
37
+ url: `${GRAPHIQL_CDN}/npm/react-dom@18.3.1/umd/react-dom.production.min.js`,
38
+ integrity: 'sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1',
39
+ kind: 'script',
40
+ },
41
+ {
42
+ url: `${GRAPHIQL_CDN}/npm/graphiql@3.9.0/graphiql.min.js`,
43
+ integrity: 'sha384-8NGfVj4CVlqHajlZj+bPJT4thxPMHMJYn7DWK2CvtopLd02E7qsPHazniBYvVjOO',
44
+ kind: 'script',
45
+ },
46
+ ])
47
+
48
+ /** The rendered page. */
49
+ export interface GraphiQLPage {
50
+ readonly html: string
51
+ readonly csp: string
52
+ }
53
+
54
+ /** `<`, `>` and `&` escaped: JSON safe inside a `<script>` element. */
55
+ function scriptJson(value: unknown): string {
56
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
57
+ }
58
+
59
+ function escapeHtml(text: string): string {
60
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
61
+ }
62
+
63
+ /** The GraphiQL page of the endpoint `endpoint` (an absolute path, prefix included) and its CSP. */
64
+ export function renderGraphiQL(endpoint: string): GraphiQLPage {
65
+ const tags = GRAPHIQL_ASSETS.map(asset =>
66
+ asset.kind === 'style'
67
+ ? `<link rel="stylesheet" href="${escapeHtml(asset.url)}" integrity="${asset.integrity}" crossorigin="anonymous">`
68
+ : `<script src="${escapeHtml(asset.url)}" integrity="${asset.integrity}" crossorigin="anonymous"></script>`,
69
+ )
70
+ const inline = [
71
+ `const fetcher = GraphiQL.createFetcher({ url: ${scriptJson(endpoint)} })`,
72
+ `ReactDOM.createRoot(document.getElementById('graphiql')).render(React.createElement(GraphiQL, { fetcher }))`,
73
+ ].join('\n')
74
+ const hash = `'sha256-${createHash('sha256').update(inline).digest('base64')}'`
75
+ const html = [
76
+ '<!doctype html>',
77
+ '<html lang="en">',
78
+ '<head>',
79
+ '<meta charset="utf-8">',
80
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
81
+ '<title>GraphiQL</title>',
82
+ '<style>body { margin: 0; } #graphiql { height: 100dvh; }</style>',
83
+ ...tags.filter(tag => tag.startsWith('<link')),
84
+ '</head>',
85
+ '<body>',
86
+ '<div id="graphiql">Loading GraphiQL…</div>',
87
+ ...tags.filter(tag => tag.startsWith('<script')),
88
+ `<script>${inline}</script>`,
89
+ '</body>',
90
+ '</html>',
91
+ '',
92
+ ].join('\n')
93
+ const csp = [
94
+ "default-src 'none'",
95
+ `script-src ${GRAPHIQL_CDN} ${hash}`,
96
+ // GraphiQL and CodeMirror set inline styles
97
+ `style-src 'unsafe-inline' ${GRAPHIQL_CDN}`,
98
+ `font-src data: ${GRAPHIQL_CDN}`,
99
+ "img-src 'self' data:",
100
+ "connect-src 'self'",
101
+ "base-uri 'none'",
102
+ "form-action 'none'",
103
+ "frame-ancestors 'self'",
104
+ ].join('; ')
105
+ return { html, csp }
106
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * GraphQL over HTTP (RFC 0005 §8.3, GraphQL-over-HTTP specification draft), the handlers of the two
3
+ * plugin routes of the endpoint (`GET` and `POST` on `path`).
4
+ *
5
+ * - `POST`: `application/json` bodies only (415 otherwise: no form or text body, so cross-site
6
+ * requests are always preflighted), read by the handler so that every malformed request gets a
7
+ * GraphQL-shaped error (malformed JSON, missing query, batching: 400; body limit: 413).
8
+ * - `GET`: queries only (405 with `allow: POST` otherwise), when `http.get` is on; GraphiQL with
9
+ * `accept: text/html` when enabled.
10
+ * - The response is `application/graphql-response+json` when the client lists it (200 with data,
11
+ * 400 for request errors), otherwise `application/json` (200 for every well-formed request); no
12
+ * `accept` header is `application/json`; nothing acceptable is 406. `accept: text/event-stream`
13
+ * selects GraphQL over SSE for any operation when it is preferred to the JSON types (or the only
14
+ * one listed); listed at the same quality as a JSON type, only subscriptions are streamed.
15
+ *
16
+ * Hot path (a cached query whose resolvers are synchronous): a memoised negotiation (a map lookup),
17
+ * the query string read without `URLSearchParams` or the body read and parsed once, one options
18
+ * object and one deferred abort per request, the executor's synchronous result, and a single
19
+ * `JSON.stringify` by the serialiser. Every answer stays on the fast lane of `@znight/http` (no
20
+ * promise while the route is synchronous, headers from a template) unless the application adds
21
+ * CORS, guards, `http:request` listeners or wrappers: a `GET` answered as `application/json` with
22
+ * status 200 returns the plain result; the other answers are an `HttpResponse` with a plain JSON
23
+ * body, at most a `content-type` header and an explicit status (a `POST` route answers 201 by
24
+ * default), which the fast lane serves too.
25
+ */
26
+ import type { HttpContext, SseOptions } from '@znight/http'
27
+ import type { FormattedExecutionResult } from 'graphql'
28
+ import type { ExecuteOptions, GraphQLExecutor, GraphQLRequest, GraphQLResponse } from '../execution.ts'
29
+
30
+ import { HttpResponse, isHttpException, parseMediaType } from '@znight/http'
31
+
32
+ import { GraphQLErrorCode } from '../errors.ts'
33
+ import { SUBSCRIBE } from '../execution.ts'
34
+ import { DeferredAbort } from './abort.ts'
35
+ import { MEDIA, Media, type MediaKind, Negotiator, STREAMABLE } from './accept.ts'
36
+ import { renderGraphiQL } from './graphiql.ts'
37
+ import { ResultEvents } from './sse.ts'
38
+
39
+ /** What the transport is built from (resolved plugin options). */
40
+ export interface HttpTransportOptions {
41
+ /** The executor of the plugin, built at bootstrap (after the routes may have been collected). */
42
+ readonly executor: () => GraphQLExecutor
43
+ /** Absolute path of the endpoint, prefix included (GraphiQL's fetcher). */
44
+ readonly endpoint: string
45
+ /** Queries over `GET`. */
46
+ readonly get: boolean
47
+ readonly graphiql: boolean
48
+ /** `HttpResponse.sse()` options of GraphQL over SSE; `false`: no event streams. */
49
+ readonly sse: SseOptions | false
50
+ }
51
+
52
+ const JSON_TYPE = 'application/json; charset=utf-8'
53
+ const GRAPHQL_JSON_TYPE = 'application/graphql-response+json; charset=utf-8'
54
+
55
+ /** Response options of `application/graphql-response+json` replies, by status. */
56
+ const GRAPHQL_JSON_200 = Object.freeze({ status: 200, headers: Object.freeze({ 'content-type': GRAPHQL_JSON_TYPE }) })
57
+ const GRAPHQL_JSON_400 = Object.freeze({ status: 400, headers: Object.freeze({ 'content-type': GRAPHQL_JSON_TYPE }) })
58
+ /** `application/json` is what the serialiser gives an object body: only the status is set. */
59
+ const JSON_200 = Object.freeze({ status: 200 })
60
+
61
+ const UTF8 = new TextDecoder('utf-8', { fatal: true })
62
+
63
+ /** A request error before execution: `{ errors: [{ message, extensions: { code } }] }`. */
64
+ function errorBody(message: string, code: string = GraphQLErrorCode.BadRequest): FormattedExecutionResult {
65
+ return { errors: [{ message, extensions: { code } }] }
66
+ }
67
+
68
+ /** A reply of `status` carrying a GraphQL error body in the negotiated JSON type (`application/json` before negotiation). */
69
+ function errorReply(
70
+ media: MediaKind,
71
+ status: number,
72
+ body: FormattedExecutionResult,
73
+ headers?: Readonly<Record<string, string>>,
74
+ ): HttpResponse<FormattedExecutionResult> {
75
+ const type = media === Media.GraphQLJson ? GRAPHQL_JSON_TYPE : JSON_TYPE
76
+ return new HttpResponse(body, { status, headers: { ...headers, 'content-type': type } })
77
+ }
78
+
79
+ const NOT_ACCEPTABLE = errorReply(
80
+ Media.Json,
81
+ 406,
82
+ errorBody(
83
+ 'None of the accepted media types can be produced: accept application/graphql-response+json, application/json or text/event-stream',
84
+ ),
85
+ )
86
+ const GET_NOT_ALLOWED = errorReply(
87
+ Media.Json,
88
+ 405,
89
+ errorBody('GraphQL operations are sent with POST on this endpoint'),
90
+ Object.freeze({ allow: 'POST' }),
91
+ )
92
+
93
+ /** `content-type` values of JSON bodies seen most often, compared before parsing. */
94
+ function isJsonRequest(contentType: string | undefined): boolean {
95
+ if (contentType === 'application/json' || contentType === 'application/json; charset=utf-8') return true
96
+ // parsed once per distinct value (memoised by @znight/http)
97
+ const media = parseMediaType(contentType)
98
+ if (media === undefined || media.type !== 'application/json') return false
99
+ return media.charset === undefined || media.charset === 'utf-8' || media.charset === 'utf8'
100
+ }
101
+
102
+ /** Why the parameters of a request are invalid, `undefined` when they are valid. */
103
+ function invalidParams(params: Readonly<Record<string, unknown>>): string | undefined {
104
+ const query = params['query']
105
+ if (typeof query !== 'string') {
106
+ return query === undefined || query === null
107
+ ? 'Missing query: send { query, variables?, operationName?, extensions? }'
108
+ : 'The query must be a string'
109
+ }
110
+ const operationName = params['operationName']
111
+ if (operationName !== undefined && operationName !== null && typeof operationName !== 'string') {
112
+ return 'operationName must be a string'
113
+ }
114
+ if (!isMapOrNull(params['variables'])) return 'variables must be an object'
115
+ if (!isMapOrNull(params['extensions'])) return 'extensions must be an object'
116
+ return undefined
117
+ }
118
+
119
+ /** The parameters of a GraphQL `GET` request. */
120
+ const GET_PARAMS = ['query', 'operationName', 'variables', 'extensions'] as const
121
+
122
+ type GetParams = Partial<Record<(typeof GET_PARAMS)[number], string | string[]>>
123
+
124
+ /** A query string component decoded like `URLSearchParams` (`+` is a space); `undefined` when malformed. */
125
+ function decodeComponent(text: string): string | undefined {
126
+ if (text.indexOf('%') === -1 && text.indexOf('+') === -1) return text
127
+ try {
128
+ return decodeURIComponent(text.replaceAll('+', ' '))
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The GraphQL parameters of a request target, read directly: `ctx.query` builds a record of every
136
+ * parameter through `URLSearchParams` (about 0.35 µs of a `GET` on Node.js, 0.15 µs on Bun, see
137
+ * `test/bench/http.bench.ts`). A repeated parameter
138
+ * becomes an array, like `ctx.query`. `undefined` when a component is malformed percent-encoding:
139
+ * the caller falls back to `ctx.query` (which keeps such sequences as they are).
140
+ */
141
+ function readGetParams(url: string): GetParams | undefined {
142
+ const params: GetParams = {}
143
+ const mark = url.indexOf('?')
144
+ if (mark === -1) return params
145
+ const length = url.length
146
+ let start = mark + 1
147
+ while (start < length) {
148
+ let end = url.indexOf('&', start)
149
+ if (end === -1) end = length
150
+ if (end > start) {
151
+ let equals = url.indexOf('=', start)
152
+ if (equals === -1 || equals > end) equals = end
153
+ const name = decodeComponent(url.slice(start, equals))
154
+ if (name === undefined) return undefined
155
+ if (name === 'query' || name === 'operationName' || name === 'variables' || name === 'extensions') {
156
+ const value = equals === end ? '' : decodeComponent(url.slice(equals + 1, end))
157
+ if (value === undefined) return undefined
158
+ const existing = params[name]
159
+ if (existing === undefined) params[name] = value
160
+ else params[name] = typeof existing === 'string' ? [existing, value] : [...existing, value]
161
+ }
162
+ }
163
+ start = end + 1
164
+ }
165
+ return params
166
+ }
167
+
168
+ function isMapOrNull(value: unknown): boolean {
169
+ return value === undefined || value === null || (typeof value === 'object' && !Array.isArray(value))
170
+ }
171
+
172
+ function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
173
+ return typeof (value as { then?: unknown } | null)?.then === 'function'
174
+ }
175
+
176
+ /** The handlers of the GraphQL endpoint. */
177
+ export class HttpTransport {
178
+ readonly #resolveExecutor: () => GraphQLExecutor
179
+ #executor: GraphQLExecutor | undefined
180
+ readonly #getQueries: boolean
181
+ readonly #sse: SseOptions | false
182
+ readonly #post: Negotiator
183
+ readonly #get: Negotiator
184
+ /** The GraphiQL page, `undefined` when disabled. */
185
+ readonly #graphiql: HttpResponse<Uint8Array> | undefined
186
+
187
+ constructor(options: HttpTransportOptions) {
188
+ this.#resolveExecutor = options.executor
189
+ this.#getQueries = options.get
190
+ this.#sse = options.sse
191
+ const json = (1 << Media.GraphQLJson) | (1 << Media.Json) | (options.sse === false ? 0 : 1 << Media.EventStream)
192
+ this.#post = new Negotiator(json)
193
+ this.#get = new Negotiator(options.graphiql ? json | (1 << Media.Html) : json)
194
+ if (options.graphiql) {
195
+ const page = renderGraphiQL(options.endpoint)
196
+ // encoded once: the same immutable response serves every request
197
+ this.#graphiql = new HttpResponse(new TextEncoder().encode(page.html), {
198
+ status: 200,
199
+ headers: {
200
+ 'content-type': 'text/html; charset=utf-8',
201
+ 'content-security-policy': page.csp,
202
+ 'cache-control': 'no-store',
203
+ },
204
+ })
205
+ }
206
+ }
207
+
208
+ /** `POST path`. */
209
+ readonly post = (ctx: HttpContext): unknown => {
210
+ const headers = ctx.headers
211
+ const negotiated = this.#post.choose(headers['accept'])
212
+ const media = (negotiated & MEDIA) as MediaKind
213
+ if (media === Media.None) return NOT_ACCEPTABLE
214
+ if (!isJsonRequest(headers['content-type'])) {
215
+ return errorReply(
216
+ media,
217
+ 415,
218
+ errorBody('Send GraphQL requests as application/json (content-type: application/json)'),
219
+ )
220
+ }
221
+ const encoding = headers['content-encoding']
222
+ if (encoding !== undefined && encoding.trim().toLowerCase() !== 'identity' && encoding.trim() !== '') {
223
+ return errorReply(media, 415, errorBody(`Unsupported content encoding "${encoding}"`))
224
+ }
225
+ return ctx.rawBody().then(
226
+ bytes => this.#posted(ctx, bytes, media, negotiated >= STREAMABLE),
227
+ (error: unknown) => transportError(error, media),
228
+ )
229
+ }
230
+
231
+ /** `GET path`. */
232
+ readonly get = (ctx: HttpContext): unknown => {
233
+ const media = (this.#get.choose(ctx.headers['accept']) & MEDIA) as MediaKind
234
+ if (media === Media.Html) return this.#graphiql
235
+ if (media === Media.None) return NOT_ACCEPTABLE
236
+ if (!this.#getQueries) return GET_NOT_ALLOWED
237
+ const params = readGetParams(ctx.url) ?? ctx.query
238
+ const request: Record<string, unknown> = {}
239
+ for (const name of GET_PARAMS) {
240
+ const value = params[name]
241
+ if (value === undefined) continue
242
+ if (typeof value !== 'string') return errorReply(media, 400, errorBody(`Repeated ${name} parameter`))
243
+ if (name === 'variables' || name === 'extensions') {
244
+ try {
245
+ request[name] = JSON.parse(value)
246
+ } catch {
247
+ return errorReply(media, 400, errorBody(`The ${name} parameter must be JSON`))
248
+ }
249
+ } else {
250
+ request[name] = value
251
+ }
252
+ }
253
+ const invalid = invalidParams(request)
254
+ if (invalid !== undefined) return errorReply(media, 400, errorBody(invalid))
255
+ if (media === Media.EventStream) return this.#stream(ctx, request as unknown as GraphQLRequest, true)
256
+ // GET carries queries only: no subscription to stream
257
+ return this.#execute(ctx, request as unknown as GraphQLRequest, media, true, false)
258
+ }
259
+
260
+ /** A `POST` body read: parsed, checked, executed or streamed. */
261
+ #posted(ctx: HttpContext, bytes: Uint8Array, media: MediaKind, streamable: boolean): unknown {
262
+ if (bytes.byteLength === 0) {
263
+ return errorReply(media, 400, errorBody('Missing request body: send { query, variables?, operationName? }'))
264
+ }
265
+ let body: unknown
266
+ try {
267
+ body = JSON.parse(UTF8.decode(bytes))
268
+ } catch {
269
+ return errorReply(media, 400, errorBody('Malformed JSON body'))
270
+ }
271
+ if (body === null || typeof body !== 'object') {
272
+ return errorReply(media, 400, errorBody('The body must be a JSON object: { query, variables?, operationName? }'))
273
+ }
274
+ if (Array.isArray(body)) {
275
+ return errorReply(media, 400, errorBody('Operation batching is not supported: send one operation per request'))
276
+ }
277
+ const invalid = invalidParams(body as Record<string, unknown>)
278
+ if (invalid !== undefined) return errorReply(media, 400, errorBody(invalid))
279
+ if (media === Media.EventStream) return this.#stream(ctx, body as GraphQLRequest, false)
280
+ return this.#execute(ctx, body as GraphQLRequest, media, false, streamable)
281
+ }
282
+
283
+ /**
284
+ * Executes a query or a mutation; synchronous while the execution is. A subscription is streamed
285
+ * when the client accepts an event stream (`streamable`), a 400 otherwise.
286
+ */
287
+ #execute(ctx: HttpContext, request: GraphQLRequest, media: MediaKind, get: boolean, streamable: boolean): unknown {
288
+ const executor = this.#executor ?? this.#bind()
289
+ const abort = new DeferredAbort()
290
+ const options: ExecuteOptions = {
291
+ transport: 'http',
292
+ http: ctx,
293
+ abortSignal: abort.asSignal(),
294
+ queriesOnly: get,
295
+ streamSubscriptions: streamable,
296
+ }
297
+ const response = executor.execute(request, options)
298
+ if (response === SUBSCRIBE) return this.#stream(ctx, request, get)
299
+ if (!isPromise(response)) return reply(response, media, get)
300
+ // asynchronous resolvers: a client leaving now aborts the execution
301
+ abort.follow(() => ctx.signal)
302
+ return response.then(
303
+ settled => reply(settled, media, get),
304
+ (error: unknown) => {
305
+ if (!abort.aborted || error !== abort.reason) throw error
306
+ // nobody reads it: the client is gone (or the server stops)
307
+ return errorReply(media, 503, errorBody('The request was aborted', GraphQLErrorCode.ServiceUnavailable))
308
+ },
309
+ )
310
+ }
311
+
312
+ /** GraphQL over SSE: any operation, its results as `next` events. */
313
+ #stream(ctx: HttpContext, request: GraphQLRequest, get: boolean): Promise<unknown> {
314
+ const executor = this.#executor ?? this.#bind()
315
+ const signal = ctx.signal
316
+ const options: ExecuteOptions = { transport: 'sse', http: ctx, signal, queriesOnly: get }
317
+ return executor.subscribe(request, options).then(
318
+ outcome => {
319
+ if ('status' in outcome) {
320
+ return errorReply(Media.Json, outcome.status, outcome.result, outcome.headers)
321
+ }
322
+ // the SSE options were checked at boot (see sseOptions)
323
+ return HttpResponse.sse(new ResultEvents(outcome, signal), this.#sse as SseOptions)
324
+ },
325
+ (error: unknown) => {
326
+ if (!signal.aborted || error !== signal.reason) throw error
327
+ return errorReply(Media.Json, 503, errorBody('The request was aborted', GraphQLErrorCode.ServiceUnavailable))
328
+ },
329
+ )
330
+ }
331
+
332
+ #bind(): GraphQLExecutor {
333
+ this.#executor = this.#resolveExecutor()
334
+ return this.#executor
335
+ }
336
+ }
337
+
338
+ /**
339
+ * The HTTP reply of an executor response. `application/json` answers 200 to every well-formed
340
+ * request (request errors included); `application/graphql-response+json` keeps the status.
341
+ */
342
+ function reply(response: GraphQLResponse, media: MediaKind, get: boolean): unknown {
343
+ const status = response.status
344
+ const result = response.result
345
+ if (media === Media.Json) {
346
+ if (status === 200 || status === 400) {
347
+ // a plain object on a GET route: 200 application/json (a POST route would answer 201)
348
+ return get ? result : new HttpResponse(result, JSON_200)
349
+ }
350
+ return errorReply(media, status, result, response.headers)
351
+ }
352
+ if (response.headers === undefined) {
353
+ if (status === 200) return new HttpResponse(result, GRAPHQL_JSON_200)
354
+ if (status === 400) return new HttpResponse(result, GRAPHQL_JSON_400)
355
+ }
356
+ return errorReply(media, status, result, response.headers)
357
+ }
358
+
359
+ /** A failure reading the body (413 past the body limit, 400 for an aborted body) with a GraphQL error body. */
360
+ function transportError(error: unknown, media: MediaKind): HttpResponse<FormattedExecutionResult> {
361
+ if (!isHttpException(error)) throw error
362
+ return errorReply(media, error.status, errorBody(error.message))
363
+ }
364
+
365
+ /** Checks the SSE options once (`HttpResponse.sse()` validates them): `undefined` when they are valid. */
366
+ export function checkSseOptions(options: SseOptions): string | undefined {
367
+ try {
368
+ HttpResponse.sse([], options)
369
+ return undefined
370
+ } catch (error) {
371
+ return error instanceof Error ? error.message : String(error)
372
+ }
373
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * GraphQL over SSE, distinct connections mode (RFC 0005 §5.6, §8.3, §8.11): the results of an
3
+ * operation as `next` events, then a `complete` event. Everything else (headers, heartbeats,
4
+ * backpressure, client abort, graceful close) is RFC 0006's `HttpResponse.sse()`.
5
+ */
6
+ import type { SseEvent } from '@znight/http'
7
+ import type { GraphQLResultStream } from '../execution.ts'
8
+
9
+ /** The last event of a stream that ended normally. */
10
+ const COMPLETE: SseEvent<string> = Object.freeze({ event: 'complete', data: '' })
11
+ const DONE: IteratorResult<SseEvent> = Object.freeze({ done: true, value: undefined })
12
+
13
+ /**
14
+ * The event source of an operation: one `next` event per result, then `complete` when the
15
+ * operation ended by itself. A stream ended by `ctx.signal` (client gone, shutdown, `maxDuration`)
16
+ * gets no `complete`: GraphQL-over-SSE clients then treat it as a lost connection and retry.
17
+ */
18
+ export class ResultEvents implements AsyncIterableIterator<SseEvent> {
19
+ readonly #results: GraphQLResultStream
20
+ readonly #signal: AbortSignal
21
+ #done = false
22
+
23
+ constructor(results: GraphQLResultStream, signal: AbortSignal) {
24
+ this.#results = results
25
+ this.#signal = signal
26
+ }
27
+
28
+ async next(): Promise<IteratorResult<SseEvent>> {
29
+ if (this.#done) return DONE
30
+ const step = await this.#results.next()
31
+ if (step.done !== true) return { done: false, value: { event: 'next', data: step.value } }
32
+ this.#done = true
33
+ return this.#signal.aborted ? DONE : { done: false, value: COMPLETE }
34
+ }
35
+
36
+ async return(): Promise<IteratorResult<SseEvent>> {
37
+ this.#done = true
38
+ await this.#results.return()
39
+ return DONE
40
+ }
41
+
42
+ [Symbol.asyncIterator](): AsyncIterableIterator<SseEvent> {
43
+ return this
44
+ }
45
+ }