@ultimabase/graphql 0.0.0-stage → 0.1.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 (99) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -2
  3. package/dist/data-types.d.ts +15 -0
  4. package/dist/data-types.d.ts.map +1 -0
  5. package/dist/data-types.js +53 -0
  6. package/dist/data.d.ts +34 -0
  7. package/dist/data.d.ts.map +1 -0
  8. package/dist/data.js +207 -0
  9. package/dist/depth.d.ts +6 -0
  10. package/dist/depth.d.ts.map +1 -0
  11. package/dist/depth.js +67 -0
  12. package/dist/errors.d.ts +7 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +42 -0
  15. package/dist/functions.d.ts +28 -0
  16. package/dist/functions.d.ts.map +1 -0
  17. package/dist/functions.js +195 -0
  18. package/dist/graphiql.d.ts +3 -0
  19. package/dist/graphiql.d.ts.map +1 -0
  20. package/dist/graphiql.js +46 -0
  21. package/dist/index.d.ts +47 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +165 -0
  24. package/dist/inputs.d.ts +20 -0
  25. package/dist/inputs.d.ts.map +1 -0
  26. package/dist/inputs.js +236 -0
  27. package/dist/json-fields.d.ts +12 -0
  28. package/dist/json-fields.d.ts.map +1 -0
  29. package/dist/json-fields.js +66 -0
  30. package/dist/json-schema.d.ts +7 -0
  31. package/dist/json-schema.d.ts.map +1 -0
  32. package/dist/json-schema.js +16 -0
  33. package/dist/live-stream.d.ts +59 -0
  34. package/dist/live-stream.d.ts.map +1 -0
  35. package/dist/live-stream.js +136 -0
  36. package/dist/mutations.d.ts +9 -0
  37. package/dist/mutations.d.ts.map +1 -0
  38. package/dist/mutations.js +228 -0
  39. package/dist/names.d.ts +8 -0
  40. package/dist/names.d.ts.map +1 -0
  41. package/dist/names.js +14 -0
  42. package/dist/outputs.d.ts +16 -0
  43. package/dist/outputs.d.ts.map +1 -0
  44. package/dist/outputs.js +177 -0
  45. package/dist/overlap.d.ts +5 -0
  46. package/dist/overlap.d.ts.map +1 -0
  47. package/dist/overlap.js +82 -0
  48. package/dist/pages.d.ts +11 -0
  49. package/dist/pages.d.ts.map +1 -0
  50. package/dist/pages.js +22 -0
  51. package/dist/registry.d.ts +80 -0
  52. package/dist/registry.d.ts.map +1 -0
  53. package/dist/registry.js +97 -0
  54. package/dist/reread.d.ts +9 -0
  55. package/dist/reread.d.ts.map +1 -0
  56. package/dist/reread.js +116 -0
  57. package/dist/rows.d.ts +23 -0
  58. package/dist/rows.d.ts.map +1 -0
  59. package/dist/rows.js +106 -0
  60. package/dist/scalars.d.ts +13 -0
  61. package/dist/scalars.d.ts.map +1 -0
  62. package/dist/scalars.js +88 -0
  63. package/dist/schema.d.ts +17 -0
  64. package/dist/schema.d.ts.map +1 -0
  65. package/dist/schema.js +130 -0
  66. package/dist/select.d.ts +24 -0
  67. package/dist/select.d.ts.map +1 -0
  68. package/dist/select.js +152 -0
  69. package/dist/sockets.d.ts +25 -0
  70. package/dist/sockets.d.ts.map +1 -0
  71. package/dist/sockets.js +234 -0
  72. package/dist/subscriptions.d.ts +14 -0
  73. package/dist/subscriptions.d.ts.map +1 -0
  74. package/dist/subscriptions.js +242 -0
  75. package/package.json +58 -3
  76. package/src/data-types.ts +100 -0
  77. package/src/data.ts +366 -0
  78. package/src/depth.ts +98 -0
  79. package/src/errors.ts +48 -0
  80. package/src/functions.ts +291 -0
  81. package/src/graphiql.ts +54 -0
  82. package/src/index.ts +231 -0
  83. package/src/inputs.ts +348 -0
  84. package/src/json-fields.ts +95 -0
  85. package/src/json-schema.ts +21 -0
  86. package/src/live-stream.ts +192 -0
  87. package/src/mutations.ts +317 -0
  88. package/src/names.ts +25 -0
  89. package/src/outputs.ts +267 -0
  90. package/src/overlap.ts +97 -0
  91. package/src/pages.ts +39 -0
  92. package/src/registry.ts +189 -0
  93. package/src/reread.ts +159 -0
  94. package/src/rows.ts +151 -0
  95. package/src/scalars.ts +110 -0
  96. package/src/schema.ts +189 -0
  97. package/src/select.ts +230 -0
  98. package/src/sockets.ts +291 -0
  99. package/src/subscriptions.ts +334 -0
package/src/schema.ts ADDED
@@ -0,0 +1,189 @@
1
+ // The schema (D-107): for each visible model, `post(where)` and `posts(...)`,
2
+ // each read in one kernel query as its caller, and its mutations.
3
+ import { connection, rowsOf, run } from '@ultimabase/http/internal'
4
+ import type { GraphQLFieldConfig, GraphQLNamedType } from 'graphql'
5
+ import {
6
+ GraphQLList,
7
+ GraphQLNonNull,
8
+ GraphQLObjectType,
9
+ GraphQLSchema,
10
+ } from 'graphql'
11
+ import { objectOf, uniqueInput, uniqueWhere } from './inputs.ts'
12
+ import { connectionType, isMaster, listArgs, objectType } from './outputs.ts'
13
+ import { listRead, whereOf } from './pages.ts'
14
+ import type { Args, Context, Entry, Registry } from './registry.ts'
15
+ import { as } from './registry.ts'
16
+ import { rowNodes, selectOf } from './select.ts'
17
+ import { mutationFields } from './mutations.ts'
18
+ import type { RootField } from './functions.ts'
19
+ import { Refused } from 'ultimabase'
20
+
21
+ type Fields = { [field: string]: GraphQLFieldConfig<unknown, Context, Args> }
22
+
23
+ function rootFields(registry: Registry, entry: Entry): Fields {
24
+ const name = entry.model.name
25
+ const type = objectType(registry, name)
26
+ const { queries } = entry.exposed
27
+ const list =
28
+ registry.settings.pagination === 'list'
29
+ ? new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)))
30
+ : new GraphQLNonNull(connectionType(registry, `${name}Connection`, name))
31
+ return {
32
+ [entry.single]: {
33
+ type,
34
+ args: {
35
+ where: { type: new GraphQLNonNull(uniqueInput(registry, name)) },
36
+ },
37
+ resolve: async (_, args, context, info) => {
38
+ const where = uniqueWhere(registry, name, objectOf(args['where']))
39
+ const select = selectOf(
40
+ registry,
41
+ info,
42
+ type,
43
+ info.fieldNodes,
44
+ isMaster(context),
45
+ )
46
+ return run(() => queries.findUnique({ where, select }, as(context)))
47
+ },
48
+ },
49
+ [entry.list]: {
50
+ type: list,
51
+ args: listArgs(registry, name),
52
+ resolve: async (_, args, context, info) => {
53
+ const master = isMaster(context)
54
+ const { read, page } = listRead(registry, entry, args, master)
55
+ const select = selectOf(
56
+ registry,
57
+ info,
58
+ type,
59
+ rowNodes(registry, info),
60
+ master,
61
+ )
62
+ const rows = await run(() =>
63
+ queries.findMany({ ...read, select }, as(context)),
64
+ )
65
+ if (page === undefined) return rows
66
+ let counted: Promise<unknown> | undefined
67
+ return {
68
+ ...connection(rowsOf(rows), page, (row) => row),
69
+ // Called by the default resolver, so counted only when selected,
70
+ // and once however many aliases ask.
71
+ totalCount: async () => {
72
+ counted ??= run(() =>
73
+ queries.count(whereOf(registry, name, args), as(context)),
74
+ )
75
+ return counted
76
+ },
77
+ }
78
+ },
79
+ },
80
+ }
81
+ }
82
+
83
+ const taken = (name: string, owner: string): Error =>
84
+ new Refused(`${owner}: the type ${name} is a generated type's name.`)
85
+
86
+ /** The models' types, a json field's among them, built once to name them. */
87
+ function generatedTypes(
88
+ fields: Fields,
89
+ mutations: Fields,
90
+ typeOwners: ReadonlyMap<string, string>,
91
+ ): ReturnType<GraphQLSchema['getTypeMap']> {
92
+ try {
93
+ return new GraphQLSchema({
94
+ query: new GraphQLObjectType({ name: 'Query', fields: { ...fields } }),
95
+ mutation: new GraphQLObjectType({
96
+ name: 'Mutation',
97
+ fields: { ...mutations },
98
+ }),
99
+ }).getTypeMap()
100
+ } catch (error) {
101
+ // A json field's type named like a model's, as `PostStatus`.
102
+ const name = /multiple types named "(\w+)"/.exec(String(error))?.[1]
103
+ const owner = name === undefined ? undefined : typeOwners.get(name)
104
+ if (name === undefined || owner === undefined) throw error
105
+ throw taken(name, owner)
106
+ }
107
+ }
108
+
109
+ /** Refuses two root fields of one name, naming both owners. */
110
+ export function schemaOf(
111
+ registry: Registry,
112
+ called: {
113
+ readonly fields: readonly RootField[]
114
+ readonly owners: ReadonlyMap<string, string>
115
+ readonly named: (name: string) => GraphQLNamedType | undefined
116
+ /** The live models' subscriptions, named as their queries (D-154). */
117
+ readonly subscriptions: Fields
118
+ },
119
+ ): GraphQLSchema {
120
+ const { fields: functions, owners: typeOwners, named, subscriptions } = called
121
+ const roots = {
122
+ query: new Map<string, string>(),
123
+ mutation: new Map<string, string>(),
124
+ }
125
+ const fields: Fields = {}
126
+ const mutations: Fields = {}
127
+ const add = (
128
+ into: Fields,
129
+ owners: Map<string, string>,
130
+ owner: string,
131
+ hint: string,
132
+ added: Fields,
133
+ ) => {
134
+ for (const [name, field] of Object.entries(added)) {
135
+ const other = owners.get(name)
136
+ if (other !== undefined) {
137
+ throw new Refused(
138
+ `${owner}: the root field ${name} is ${other}'s; set .with({ graphql: { ${hint} } }).`,
139
+ )
140
+ }
141
+ owners.set(name, owner)
142
+ into[name] = field
143
+ }
144
+ }
145
+ for (const entry of registry.entries.values()) {
146
+ if (!entry.visible) continue
147
+ const owner = entry.model.name
148
+ add(
149
+ mutations,
150
+ roots.mutation,
151
+ owner,
152
+ 'plural',
153
+ mutationFields(registry, entry),
154
+ )
155
+ add(fields, roots.query, owner, 'plural', rootFields(registry, entry))
156
+ }
157
+ // The models' types, which no function's or field's type may be named like.
158
+ const generated = generatedTypes(fields, mutations, typeOwners)
159
+ for (const [name, owner] of typeOwners) {
160
+ if (name in generated && generated[name] !== named(name)) {
161
+ throw taken(name, owner)
162
+ }
163
+ }
164
+ for (const { query, name, owner, field } of functions) {
165
+ const [into, owners] = query
166
+ ? [fields, roots.query]
167
+ : [mutations, roots.mutation]
168
+ add(into, owners, owner, 'name', { [name]: field })
169
+ }
170
+ if (Object.keys(fields).length === 0) {
171
+ throw new Refused(
172
+ 'graphqlPlugin: no query to expose; each model has .with({ graphql: false }) and no function is a query.',
173
+ )
174
+ }
175
+ return new GraphQLSchema({
176
+ query: new GraphQLObjectType({ name: 'Query', fields }),
177
+ mutation:
178
+ Object.keys(mutations).length === 0
179
+ ? undefined
180
+ : new GraphQLObjectType({ name: 'Mutation', fields: mutations }),
181
+ subscription:
182
+ Object.keys(subscriptions).length === 0
183
+ ? undefined
184
+ : new GraphQLObjectType({
185
+ name: 'Subscription',
186
+ fields: subscriptions,
187
+ }),
188
+ })
189
+ }
package/src/select.ts ADDED
@@ -0,0 +1,230 @@
1
+ // A query's selection as one kernel read (D-107): the fields asked for, and
2
+ // each `many` relation's own page and count, read with its parent's rows.
3
+ import { badInput, readable } from '@ultimabase/http/internal'
4
+ import type {
5
+ FieldNode,
6
+ GraphQLObjectType,
7
+ GraphQLResolveInfo,
8
+ SelectionSetNode,
9
+ } from 'graphql'
10
+ import type { ExposedVirtual } from 'ultimabase'
11
+ import {
12
+ getArgumentValues,
13
+ getDirectiveValues,
14
+ getNamedType,
15
+ GraphQLIncludeDirective,
16
+ GraphQLSkipDirective,
17
+ isObjectType,
18
+ Kind,
19
+ } from 'graphql'
20
+ import { objectType } from './outputs.ts'
21
+ import { plain } from './scalars.ts'
22
+ import { listRead } from './pages.ts'
23
+ import type { Args, Registry } from './registry.ts'
24
+
25
+ export type Info = Pick<GraphQLResolveInfo, 'fragments' | 'variableValues'>
26
+ /** A field's name, or its alias: the key of its value in the response. */
27
+ export type Key = (node: FieldNode) => string
28
+ /** Fields under one key, the first of them included. */
29
+ export type Fields = readonly [FieldNode, ...FieldNode[]]
30
+ /** A field or an operation: what selects fields. */
31
+ export type Selecting = { readonly selectionSet?: SelectionSetNode | undefined }
32
+
33
+ function included(info: Info, node: Parameters<typeof getDirectiveValues>[1]) {
34
+ const skip = getDirectiveValues(
35
+ GraphQLSkipDirective,
36
+ node,
37
+ info.variableValues,
38
+ )
39
+ if (skip?.['if'] === true) return false
40
+ const include = getDirectiveValues(
41
+ GraphQLIncludeDirective,
42
+ node,
43
+ info.variableValues,
44
+ )
45
+ return include?.['if'] !== false
46
+ }
47
+
48
+ // A fragment spread again adds nothing the first spread did not, so each is
49
+ // collected once: spreading each fragment twice into the next would otherwise
50
+ // cost 2^n visits.
51
+ function collect(
52
+ info: Info,
53
+ set: SelectionSetNode | undefined,
54
+ into: Map<string, [FieldNode, ...FieldNode[]]>,
55
+ key: Key,
56
+ spread: Set<string>,
57
+ ): void {
58
+ for (const selection of set?.selections ?? []) {
59
+ if (!included(info, selection)) continue
60
+ if (selection.kind === Kind.FIELD) {
61
+ const found = into.get(key(selection))
62
+ if (found === undefined) into.set(key(selection), [selection])
63
+ else found.push(selection)
64
+ } else if (selection.kind === Kind.INLINE_FRAGMENT) {
65
+ collect(info, selection.selectionSet, into, key, spread)
66
+ } else if (!spread.has(selection.name.value)) {
67
+ spread.add(selection.name.value)
68
+ const fragment = info.fragments[selection.name.value]
69
+ collect(info, fragment?.selectionSet, into, key, spread)
70
+ }
71
+ }
72
+ }
73
+
74
+ const byName: Key = (node) => node.name.value
75
+
76
+ /** The fields under some nodes, by name, through fragments and directives. */
77
+ export function grouped(
78
+ info: Info,
79
+ nodes: readonly Selecting[],
80
+ key: Key = byName,
81
+ ): ReadonlyMap<string, Fields> {
82
+ const into = new Map<string, [FieldNode, ...FieldNode[]]>()
83
+ const spread = new Set<string>()
84
+ for (const node of nodes) collect(info, node.selectionSet, into, key, spread)
85
+ return into
86
+ }
87
+
88
+ /** One relation's arguments, the same under every alias. */
89
+ function argsOf(
90
+ type: GraphQLObjectType,
91
+ name: string,
92
+ nodes: readonly FieldNode[],
93
+ info: Info,
94
+ ): Args {
95
+ const field = type.getFields()[name]
96
+ const [first, ...others] = nodes.map((node) =>
97
+ field === undefined
98
+ ? {}
99
+ : getArgumentValues(field, node, info.variableValues),
100
+ )
101
+ const key = JSON.stringify(first)
102
+ if (others.some((args) => JSON.stringify(args) !== key)) {
103
+ throw badInput(
104
+ `${name} is selected twice with different arguments; read it once.`,
105
+ )
106
+ }
107
+ return first ?? {}
108
+ }
109
+
110
+ /** Under a connection, the nodes its edges select. */
111
+ export function nodesOf(info: Info, nodes: readonly FieldNode[]): FieldNode[] {
112
+ const edges = grouped(info, nodes).get('edges') ?? []
113
+ return [...(grouped(info, edges).get('node') ?? [])]
114
+ }
115
+
116
+ /** What a root list selects of its rows: under `edges { node }`. */
117
+ export function rowNodes(
118
+ registry: Registry,
119
+ info: GraphQLResolveInfo,
120
+ ): readonly FieldNode[] {
121
+ return registry.settings.pagination === 'list'
122
+ ? info.fieldNodes
123
+ : nodesOf(info, info.fieldNodes)
124
+ }
125
+
126
+ const system = new Set(['id', 'createdAt', 'updatedAt'])
127
+
128
+ /** A virtual's read: `true`, its arguments, and a model's `select` (D-151). */
129
+ function virtualRead(
130
+ registry: Registry,
131
+ info: Info,
132
+ type: GraphQLObjectType,
133
+ virtual: ExposedVirtual,
134
+ nodes: readonly FieldNode[],
135
+ master: boolean,
136
+ ): unknown {
137
+ const { output } = virtual
138
+ const args = plain(argsOf(type, virtual.name, nodes, info))
139
+ const input = typeof args === 'object' && args !== null ? args : {}
140
+ if (output.kind !== 'model') return virtual.input === undefined || input
141
+ const target = objectType(registry, output.name)
142
+ return { ...input, select: selectOf(registry, info, target, nodes, master) }
143
+ }
144
+
145
+ /** The kernel's `select` for what a query asks of a model's rows. */
146
+ export function selectOf(
147
+ registry: Registry,
148
+ info: Info,
149
+ type: GraphQLObjectType,
150
+ nodes: readonly FieldNode[],
151
+ master: boolean,
152
+ ): Args {
153
+ const entry = registry.entry(type.name)
154
+ const select: { [field: string]: unknown } = { id: true }
155
+ // The connections' `totalCount`, each the count of its `where` (D-134).
156
+ const counts: { [field: string]: unknown } = {}
157
+ for (const [name, fieldNodes] of grouped(info, nodes)) {
158
+ if (name === '__typename') continue
159
+ if (system.has(name)) {
160
+ select[name] = true
161
+ continue
162
+ }
163
+ const virtual = entry.exposed.virtuals.find((v) => v.name === name)
164
+ if (virtual !== undefined) {
165
+ select[name] = virtualRead(
166
+ registry,
167
+ info,
168
+ type,
169
+ virtual,
170
+ fieldNodes,
171
+ master,
172
+ )
173
+ continue
174
+ }
175
+ const field = entry.model.fields.find((f) => f.name === name)
176
+ if (field === undefined) {
177
+ // A field `extend` added: its resolver gets the row's scalars.
178
+ for (const scalar of readable(entry)) {
179
+ if (scalar.kind === 'scalar') select[scalar.name] = true
180
+ }
181
+ for (const key of system) select[key] = true
182
+ continue
183
+ }
184
+ if (field.kind === 'scalar') {
185
+ select[name] = true
186
+ continue
187
+ }
188
+ const target = registry.entry(field.target)
189
+ const targetObject = objectType(registry, field.target)
190
+ if (field.kind === 'one') {
191
+ select[name] = {
192
+ select: selectOf(registry, info, targetObject, fieldNodes, master),
193
+ }
194
+ continue
195
+ }
196
+ const args = argsOf(type, name, fieldNodes, info)
197
+ const { read } = listRead(registry, target, args, master)
198
+ const list = registry.settings.pagination === 'list'
199
+ const below = list ? fieldNodes : nodesOf(info, fieldNodes)
200
+ select[name] = {
201
+ ...read,
202
+ select: selectOf(registry, info, targetObject, below, master),
203
+ }
204
+ if (!list && grouped(info, fieldNodes).has('totalCount')) {
205
+ counts[name] =
206
+ read['where'] === undefined ? true : { where: read['where'] }
207
+ }
208
+ }
209
+ if (Object.keys(counts).length > 0) select['_count'] = { select: counts }
210
+ return select
211
+ }
212
+
213
+ /** `context.select(info)`, for a field that returns a model or a list of one. */
214
+ export function selectFor(
215
+ registry: Registry,
216
+ master: boolean,
217
+ ): (info: GraphQLResolveInfo) => Args & { readonly id: true } {
218
+ return (info) => {
219
+ const type = getNamedType(info.returnType)
220
+ if (!isObjectType(type) || !registry.entries.has(type.name)) {
221
+ throw new Error(
222
+ `context.select(info) reads a model's rows; ${info.parentType.name}.${info.fieldName} returns ${type.name}.`,
223
+ )
224
+ }
225
+ return {
226
+ ...selectOf(registry, info, type, info.fieldNodes, master),
227
+ id: true,
228
+ }
229
+ }
230
+ }
package/src/sockets.ts ADDED
@@ -0,0 +1,291 @@
1
+ // GraphQL over WebSocket (D-154): the `graphql-ws` protocol on the GraphQL
2
+ // path, each connection one viewer of its caller, its subscriptions live
3
+ // reads and its queries and mutations run as over HTTP.
4
+ import type { HttpApi, Upgrade } from '@ultimabase/http'
5
+ import type { RealtimeApi, Viewer } from '@ultimabase/realtime'
6
+ import type {
7
+ DocumentNode,
8
+ ExecutionArgs,
9
+ ExecutionResult,
10
+ GraphQLSchema,
11
+ Source,
12
+ ValidationRule,
13
+ } from 'graphql'
14
+ import {
15
+ createSourceEventStream,
16
+ executeSubscriptionEvent,
17
+ GraphQLError,
18
+ mapSourceToResponseEvent,
19
+ validateSubscriptionArgs,
20
+ } from 'graphql'
21
+ import { CloseCode, handleProtocols, makeServer } from 'graphql-ws'
22
+ import type { RawData, WebSocket } from 'ws'
23
+ import { WebSocketServer } from 'ws'
24
+ import type { Caller, Client, Logger } from 'ultimabase'
25
+ import { PublicError } from 'ultimabase'
26
+ import type { formatter } from './errors.ts'
27
+ import type { Channel } from './live-stream.ts'
28
+ import { LiveEvent, lives } from './live-stream.ts'
29
+ import type { Context } from './registry.ts'
30
+
31
+ /** How the GraphQL plugin runs a document, over HTTP as over WebSocket. */
32
+ export type Operations = {
33
+ readonly schema: GraphQLSchema
34
+ readonly parse: (source: string | Source) => DocumentNode
35
+ readonly rules: (master: boolean) => readonly ValidationRule[]
36
+ readonly validate: (
37
+ document: DocumentNode,
38
+ rules?: readonly ValidationRule[],
39
+ ) => readonly GraphQLError[]
40
+ readonly execute: (args: ExecutionArgs) => Promise<ExecutionResult>
41
+ readonly context: (caller: Caller, client: Client) => Context
42
+ readonly hideSuggestions: boolean
43
+ readonly format: ReturnType<typeof formatter>
44
+ readonly logger: Logger
45
+ }
46
+
47
+ /** A connection's socket, and its viewer once `connection_init` came. */
48
+ type Extra = {
49
+ readonly upgrade: Upgrade
50
+ readonly socket: WebSocket
51
+ viewer?: Viewer
52
+ channel?: Channel
53
+ }
54
+
55
+ /** A client's buffer past which its changes are dropped, and the most it sends us. */
56
+ const maxBuffer = 1024 * 1024
57
+ /** Between pings; a socket that has not answered the last one is cut. */
58
+ const keepAlive = 12_000
59
+
60
+ const unset = () => {
61
+ throw new Error('A cookie is set over HTTP: a WebSocket cannot set one.')
62
+ }
63
+
64
+ /** The upgrade request's cookies, which a WebSocket cannot set. */
65
+ function cookiesOf(request: Request): Client['cookies'] {
66
+ const cookies = new Map<string, string>()
67
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
68
+ const at = part.indexOf('=')
69
+ if (at <= 0) continue
70
+ const name = part.slice(0, at).trim()
71
+ if (cookies.has(name)) continue
72
+ try {
73
+ cookies.set(name, decodeURIComponent(part.slice(at + 1).trim()))
74
+ } catch {
75
+ // A value that is no URI component is no cookie this API wrote.
76
+ }
77
+ }
78
+ return { get: (name) => cookies.get(name), set: unset, clear: unset }
79
+ }
80
+
81
+ // How each socket sends, counted by its channel once it has one.
82
+ const sends = new WeakMap<WebSocket, (data: string) => void>()
83
+
84
+ /** The socket's channel: what it holds unsent, and when that is written. */
85
+ function channelOf(viewer: Viewer, socket: WebSocket): Channel {
86
+ let pending = 0
87
+ const waiters: (() => void)[] = []
88
+ const written = () => {
89
+ pending -= 1
90
+ if (pending > 0) return
91
+ for (const then of waiters.splice(0)) then()
92
+ }
93
+ sends.set(socket, (data) => {
94
+ pending += 1
95
+ socket.send(data, written)
96
+ })
97
+ return {
98
+ viewer,
99
+ full: () => socket.bufferedAmount > maxBuffer,
100
+ drained: (then) => {
101
+ if (pending === 0) then()
102
+ else waiters.push(then)
103
+ },
104
+ forbid: () => socket.close(CloseCode.Forbidden, 'Forbidden'),
105
+ kept: new Map(),
106
+ }
107
+ }
108
+
109
+ const text = (data: RawData): string =>
110
+ Buffer.concat([data].flat().map((part) => new Uint8Array(part))).toString()
111
+
112
+ const isLive = (
113
+ value: unknown,
114
+ ): value is { readonly mode?: unknown; readonly resume?: unknown } =>
115
+ typeof value === 'object' && value !== null
116
+
117
+ /** The authorization `connection_init` gives, over the upgrade's own. */
118
+ function requestOf(upgrade: Upgrade, params: unknown): Request {
119
+ const headers = new Headers(upgrade.request.headers)
120
+ const authorization: unknown =
121
+ typeof params === 'object' && params !== null
122
+ ? Reflect.get(params, 'authorization')
123
+ : undefined
124
+ if (typeof authorization === 'string') {
125
+ headers.set('authorization', authorization)
126
+ }
127
+ return new Request(upgrade.request.url, { headers })
128
+ }
129
+
130
+ /**
131
+ * Serves `path`'s WebSocket upgrades with `operations`, each connection read
132
+ * by `realtime`; returns what closes them.
133
+ */
134
+ export function serveSockets(
135
+ http: HttpApi,
136
+ path: string,
137
+ realtime: RealtimeApi,
138
+ operations: Operations,
139
+ ): () => Promise<void> {
140
+ const { schema, logger, format, hideSuggestions } = operations
141
+ const formatted = (errors: readonly GraphQLError[]) =>
142
+ errors.map((error) => {
143
+ const shown = format(error)
144
+ return shown instanceof GraphQLError
145
+ ? shown.toJSON()
146
+ : { message: shown.message }
147
+ })
148
+ const server = makeServer<Record<string, unknown> | undefined, Extra>({
149
+ schema,
150
+ onConnect: async (ctx) => {
151
+ const { upgrade, socket } = ctx.extra
152
+ try {
153
+ const request = requestOf(upgrade, ctx.connectionParams)
154
+ const viewer = await realtime.connect(request)
155
+ if (socket.readyState !== socket.OPEN) {
156
+ viewer.close()
157
+ return false
158
+ }
159
+ ctx.extra.viewer = viewer
160
+ ctx.extra.channel = channelOf(viewer, socket)
161
+ return true
162
+ } catch (error) {
163
+ if (!(error instanceof PublicError)) {
164
+ logger.error({ err: error }, 'A WebSocket caller was not read.')
165
+ }
166
+ return false
167
+ }
168
+ },
169
+ onSubscribe: (ctx, _, payload) => {
170
+ const { viewer, channel, upgrade, socket } = ctx.extra
171
+ if (viewer === undefined || channel === undefined || viewer.closed) {
172
+ socket.close(CloseCode.Forbidden, 'Forbidden')
173
+ return [new GraphQLError('Forbidden')]
174
+ }
175
+ let document: DocumentNode
176
+ try {
177
+ document = operations.parse(payload.query)
178
+ } catch (error) {
179
+ if (error instanceof GraphQLError) return [error]
180
+ throw error
181
+ }
182
+ const { caller } = viewer
183
+ const master = caller.kind === 'master'
184
+ const refused = operations.validate(document, operations.rules(master))
185
+ if (refused.length > 0) return refused
186
+ const client = {
187
+ address: upgrade.address,
188
+ cookies: cookiesOf(upgrade.request),
189
+ }
190
+ const contextValue = operations.context(caller, client)
191
+ const live: unknown = payload.extensions?.['live']
192
+ const page = isLive(live) && live.mode === 'page'
193
+ const resume = isLive(live) ? live.resume : undefined
194
+ lives.set(contextValue, {
195
+ channel,
196
+ page,
197
+ ...(typeof resume === 'string' && { resume }),
198
+ })
199
+ return {
200
+ schema,
201
+ document,
202
+ operationName: payload.operationName,
203
+ variableValues: payload.variables,
204
+ contextValue,
205
+ }
206
+ },
207
+ execute: async (args) => operations.execute(args),
208
+ subscribe: async (args) => {
209
+ const validated = validateSubscriptionArgs({ ...args, hideSuggestions })
210
+ if (!('schema' in validated)) return { errors: validated }
211
+ const source = await createSourceEventStream(validated)
212
+ if (!(Symbol.asyncIterator in source)) return source
213
+ return mapSourceToResponseEvent(validated, source, async (event) => {
214
+ const result = await executeSubscriptionEvent(event)
215
+ const { rootValue } = event
216
+ return rootValue instanceof LiveEvent
217
+ ? rootValue.finish(result)
218
+ : result
219
+ })
220
+ },
221
+ onNext: (_ctx, _id, _payload, _args, { data, errors, extensions }) => ({
222
+ ...(data === undefined ? {} : { data }),
223
+ ...(errors === undefined ? {} : { errors: formatted(errors) }),
224
+ ...(extensions === undefined ? {} : { extensions }),
225
+ }),
226
+ onError: (_ctx, _id, _payload, errors) => formatted(errors),
227
+ })
228
+ const sockets = new WebSocketServer({
229
+ noServer: true,
230
+ maxPayload: maxBuffer,
231
+ perMessageDeflate: { threshold: 1024 },
232
+ handleProtocols,
233
+ })
234
+ const beats = new WeakMap<WebSocket, () => void>()
235
+ const opened = (socket: WebSocket, upgrade: Upgrade) => {
236
+ const extra: Extra = { upgrade, socket }
237
+ let alive = true
238
+ socket.on('pong', () => {
239
+ alive = true
240
+ })
241
+ beats.set(socket, () => {
242
+ if (!alive) return socket.terminate()
243
+ alive = false
244
+ socket.ping()
245
+ })
246
+ const closed = server.opened(
247
+ {
248
+ protocol: socket.protocol,
249
+ send: async (data) => {
250
+ if (socket.readyState !== socket.OPEN) return
251
+ const send = sends.get(socket)
252
+ if (send === undefined) socket.send(data)
253
+ else send(data)
254
+ },
255
+ close: (code, reason) => socket.close(code, reason),
256
+ onMessage: (received) => {
257
+ socket.on('message', (data: RawData) => {
258
+ received(text(data)).catch((error: unknown) => {
259
+ logger.error({ err: error }, 'A WebSocket message failed.')
260
+ socket.close(CloseCode.InternalServerError, 'Internal error')
261
+ })
262
+ })
263
+ },
264
+ },
265
+ extra,
266
+ )
267
+ socket.once('close', (code, reason) => {
268
+ void closed(code, String(reason))
269
+ extra.viewer?.close()
270
+ })
271
+ }
272
+ const beating = setInterval(() => {
273
+ for (const socket of sockets.clients) beats.get(socket)?.()
274
+ }, keepAlive)
275
+ beating.unref()
276
+ http.upgrade(path, (upgrade) => {
277
+ sockets.handleUpgrade(
278
+ upgrade.incoming,
279
+ upgrade.socket,
280
+ upgrade.head,
281
+ (socket) => opened(socket, upgrade),
282
+ )
283
+ })
284
+ return async () => {
285
+ clearInterval(beating)
286
+ for (const socket of sockets.clients) socket.close(1001, 'Going away')
287
+ await new Promise<void>((done) => {
288
+ sockets.close(() => done())
289
+ })
290
+ }
291
+ }