@effect-gql/core 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.
- package/LICENSE +7 -0
- package/dist/analyzer-extension.d.ts +105 -0
- package/dist/analyzer-extension.d.ts.map +1 -0
- package/dist/analyzer-extension.js +137 -0
- package/dist/analyzer-extension.js.map +1 -0
- package/dist/builder/execute.d.ts +26 -0
- package/dist/builder/execute.d.ts.map +1 -0
- package/dist/builder/execute.js +104 -0
- package/dist/builder/execute.js.map +1 -0
- package/dist/builder/field-builders.d.ts +30 -0
- package/dist/builder/field-builders.d.ts.map +1 -0
- package/dist/builder/field-builders.js +200 -0
- package/dist/builder/field-builders.js.map +1 -0
- package/dist/builder/index.d.ts +7 -0
- package/dist/builder/index.d.ts.map +1 -0
- package/dist/builder/index.js +31 -0
- package/dist/builder/index.js.map +1 -0
- package/dist/builder/pipe-api.d.ts +231 -0
- package/dist/builder/pipe-api.d.ts.map +1 -0
- package/dist/builder/pipe-api.js +151 -0
- package/dist/builder/pipe-api.js.map +1 -0
- package/dist/builder/schema-builder.d.ts +301 -0
- package/dist/builder/schema-builder.d.ts.map +1 -0
- package/dist/builder/schema-builder.js +566 -0
- package/dist/builder/schema-builder.js.map +1 -0
- package/dist/builder/type-registry.d.ts +80 -0
- package/dist/builder/type-registry.d.ts.map +1 -0
- package/dist/builder/type-registry.js +505 -0
- package/dist/builder/type-registry.js.map +1 -0
- package/dist/builder/types.d.ts +283 -0
- package/dist/builder/types.d.ts.map +1 -0
- package/dist/builder/types.js +3 -0
- package/dist/builder/types.js.map +1 -0
- package/dist/cli/generate-schema.d.ts +29 -0
- package/dist/cli/generate-schema.d.ts.map +1 -0
- package/dist/cli/generate-schema.js +233 -0
- package/dist/cli/generate-schema.js.map +1 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +24 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +11 -0
- package/dist/context.js.map +1 -0
- package/dist/error.d.ts +45 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +29 -0
- package/dist/error.js.map +1 -0
- package/dist/extensions.d.ts +130 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +78 -0
- package/dist/extensions.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +169 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +237 -0
- package/dist/loader.js.map +1 -0
- package/dist/resolver-context.d.ts +154 -0
- package/dist/resolver-context.d.ts.map +1 -0
- package/dist/resolver-context.js +184 -0
- package/dist/resolver-context.js.map +1 -0
- package/dist/schema-mapping.d.ts +30 -0
- package/dist/schema-mapping.d.ts.map +1 -0
- package/dist/schema-mapping.js +280 -0
- package/dist/schema-mapping.js.map +1 -0
- package/dist/server/cache-control.d.ts +96 -0
- package/dist/server/cache-control.d.ts.map +1 -0
- package/dist/server/cache-control.js +308 -0
- package/dist/server/cache-control.js.map +1 -0
- package/dist/server/complexity.d.ts +165 -0
- package/dist/server/complexity.d.ts.map +1 -0
- package/dist/server/complexity.js +433 -0
- package/dist/server/complexity.js.map +1 -0
- package/dist/server/config.d.ts +66 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +104 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/graphiql.d.ts +5 -0
- package/dist/server/graphiql.d.ts.map +1 -0
- package/dist/server/graphiql.js +43 -0
- package/dist/server/graphiql.js.map +1 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +48 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/router.d.ts +79 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +232 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema-builder-extensions.d.ts +42 -0
- package/dist/server/schema-builder-extensions.d.ts.map +1 -0
- package/dist/server/schema-builder-extensions.js +48 -0
- package/dist/server/schema-builder-extensions.js.map +1 -0
- package/dist/server/sse-adapter.d.ts +64 -0
- package/dist/server/sse-adapter.d.ts.map +1 -0
- package/dist/server/sse-adapter.js +227 -0
- package/dist/server/sse-adapter.js.map +1 -0
- package/dist/server/sse-types.d.ts +192 -0
- package/dist/server/sse-types.d.ts.map +1 -0
- package/dist/server/sse-types.js +63 -0
- package/dist/server/sse-types.js.map +1 -0
- package/dist/server/ws-adapter.d.ts +39 -0
- package/dist/server/ws-adapter.d.ts.map +1 -0
- package/dist/server/ws-adapter.js +247 -0
- package/dist/server/ws-adapter.js.map +1 -0
- package/dist/server/ws-types.d.ts +169 -0
- package/dist/server/ws-types.d.ts.map +1 -0
- package/dist/server/ws-types.js +11 -0
- package/dist/server/ws-types.js.map +1 -0
- package/dist/server/ws-utils.d.ts +42 -0
- package/dist/server/ws-utils.d.ts.map +1 -0
- package/dist/server/ws-utils.js +99 -0
- package/dist/server/ws-utils.js.map +1 -0
- package/package.json +61 -0
- package/src/analyzer-extension.ts +254 -0
- package/src/builder/execute.ts +153 -0
- package/src/builder/field-builders.ts +322 -0
- package/src/builder/index.ts +48 -0
- package/src/builder/pipe-api.ts +312 -0
- package/src/builder/schema-builder.ts +970 -0
- package/src/builder/type-registry.ts +670 -0
- package/src/builder/types.ts +305 -0
- package/src/context.ts +23 -0
- package/src/error.ts +32 -0
- package/src/extensions.ts +240 -0
- package/src/index.ts +32 -0
- package/src/loader.ts +363 -0
- package/src/resolver-context.ts +253 -0
- package/src/schema-mapping.ts +307 -0
- package/src/server/cache-control.ts +590 -0
- package/src/server/complexity.ts +774 -0
- package/src/server/config.ts +174 -0
- package/src/server/graphiql.ts +38 -0
- package/src/server/index.ts +96 -0
- package/src/server/router.ts +432 -0
- package/src/server/schema-builder-extensions.ts +51 -0
- package/src/server/sse-adapter.ts +327 -0
- package/src/server/sse-types.ts +234 -0
- package/src/server/ws-adapter.ts +355 -0
- package/src/server/ws-types.ts +192 -0
- package/src/server/ws-utils.ts +136 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { Effect, Layer, Stream } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
GraphQLSchema,
|
|
4
|
+
parse,
|
|
5
|
+
validate,
|
|
6
|
+
subscribe,
|
|
7
|
+
GraphQLError,
|
|
8
|
+
Kind,
|
|
9
|
+
type ExecutionResult,
|
|
10
|
+
type DocumentNode,
|
|
11
|
+
type OperationDefinitionNode,
|
|
12
|
+
} from "graphql"
|
|
13
|
+
import type { GraphQLEffectContext } from "../builder/types"
|
|
14
|
+
import {
|
|
15
|
+
SSEError,
|
|
16
|
+
type GraphQLSSEOptions,
|
|
17
|
+
type SSEConnectionContext,
|
|
18
|
+
type SSESubscriptionRequest,
|
|
19
|
+
type SSEEvent,
|
|
20
|
+
formatNextEvent,
|
|
21
|
+
formatErrorEvent,
|
|
22
|
+
formatCompleteEvent,
|
|
23
|
+
} from "./sse-types"
|
|
24
|
+
import { validateComplexity, type FieldComplexityMap } from "./complexity"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a subscription event stream for SSE.
|
|
28
|
+
*
|
|
29
|
+
* This function handles the GraphQL subscription lifecycle:
|
|
30
|
+
* 1. Parse and validate the query
|
|
31
|
+
* 2. Check complexity limits if configured
|
|
32
|
+
* 3. Execute the subscription
|
|
33
|
+
* 4. Stream results as SSE events
|
|
34
|
+
*
|
|
35
|
+
* @param schema - The GraphQL schema with subscription definitions
|
|
36
|
+
* @param layer - Effect layer providing services required by resolvers
|
|
37
|
+
* @param request - The subscription request (query, variables, operationName)
|
|
38
|
+
* @param headers - HTTP headers from the request (for auth)
|
|
39
|
+
* @param options - Optional lifecycle hooks and configuration
|
|
40
|
+
* @returns A Stream of SSE events to send to the client
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* const eventStream = makeSSESubscriptionStream(
|
|
45
|
+
* schema,
|
|
46
|
+
* serviceLayer,
|
|
47
|
+
* { query: "subscription { tick { count } }" },
|
|
48
|
+
* new Headers(),
|
|
49
|
+
* { onConnect: (req, headers) => Effect.succeed({ user: "alice" }) }
|
|
50
|
+
* )
|
|
51
|
+
*
|
|
52
|
+
* // In platform-specific code, consume and send events:
|
|
53
|
+
* Stream.runForEach(eventStream, (event) =>
|
|
54
|
+
* Effect.sync(() => res.write(formatSSEMessage(event)))
|
|
55
|
+
* )
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export const makeSSESubscriptionStream = <R>(
|
|
59
|
+
schema: GraphQLSchema,
|
|
60
|
+
layer: Layer.Layer<R>,
|
|
61
|
+
request: SSESubscriptionRequest,
|
|
62
|
+
headers: Headers,
|
|
63
|
+
options?: GraphQLSSEOptions<R>
|
|
64
|
+
): Stream.Stream<SSEEvent, SSEError> => {
|
|
65
|
+
const complexityConfig = options?.complexity
|
|
66
|
+
const fieldComplexities: FieldComplexityMap = options?.fieldComplexities ?? new Map()
|
|
67
|
+
|
|
68
|
+
return Stream.unwrap(
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
// Create a runtime from the layer
|
|
71
|
+
const runtime = yield* Effect.provide(Effect.runtime<R>(), layer)
|
|
72
|
+
|
|
73
|
+
// Run onConnect hook if provided
|
|
74
|
+
let connectionContext: Record<string, unknown> = {}
|
|
75
|
+
if (options?.onConnect) {
|
|
76
|
+
try {
|
|
77
|
+
connectionContext = yield* Effect.provide(options.onConnect(request, headers), layer)
|
|
78
|
+
} catch {
|
|
79
|
+
// Connection rejected
|
|
80
|
+
return Stream.make(
|
|
81
|
+
formatErrorEvent([
|
|
82
|
+
new GraphQLError("Subscription connection rejected", {
|
|
83
|
+
extensions: { code: "CONNECTION_REJECTED" },
|
|
84
|
+
}),
|
|
85
|
+
]),
|
|
86
|
+
formatCompleteEvent()
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Parse the query
|
|
92
|
+
let document: DocumentNode
|
|
93
|
+
try {
|
|
94
|
+
document = parse(request.query)
|
|
95
|
+
} catch (syntaxError) {
|
|
96
|
+
return Stream.make(formatErrorEvent([syntaxError]), formatCompleteEvent())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate the query
|
|
100
|
+
const validationErrors = validate(schema, document)
|
|
101
|
+
if (validationErrors.length > 0) {
|
|
102
|
+
return Stream.make(formatErrorEvent(validationErrors), formatCompleteEvent())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Find the subscription operation
|
|
106
|
+
const operations = document.definitions.filter(
|
|
107
|
+
(d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const operation = request.operationName
|
|
111
|
+
? operations.find((o) => o.name?.value === request.operationName)
|
|
112
|
+
: operations[0]
|
|
113
|
+
|
|
114
|
+
if (!operation) {
|
|
115
|
+
return Stream.make(
|
|
116
|
+
formatErrorEvent([new GraphQLError("No operation found in query")]),
|
|
117
|
+
formatCompleteEvent()
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (operation.operation !== "subscription") {
|
|
122
|
+
return Stream.make(
|
|
123
|
+
formatErrorEvent([
|
|
124
|
+
new GraphQLError(
|
|
125
|
+
`SSE endpoint only supports subscriptions, received: ${operation.operation}`,
|
|
126
|
+
{ extensions: { code: "OPERATION_NOT_SUPPORTED" } }
|
|
127
|
+
),
|
|
128
|
+
]),
|
|
129
|
+
formatCompleteEvent()
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate complexity if configured
|
|
134
|
+
if (complexityConfig) {
|
|
135
|
+
const complexityResult = yield* validateComplexity(
|
|
136
|
+
request.query,
|
|
137
|
+
request.operationName,
|
|
138
|
+
request.variables,
|
|
139
|
+
schema,
|
|
140
|
+
fieldComplexities,
|
|
141
|
+
complexityConfig
|
|
142
|
+
).pipe(
|
|
143
|
+
Effect.map(() => null),
|
|
144
|
+
Effect.catchAll((error) => {
|
|
145
|
+
if (error._tag === "ComplexityLimitExceededError") {
|
|
146
|
+
return Effect.succeed(
|
|
147
|
+
new GraphQLError(error.message, {
|
|
148
|
+
extensions: {
|
|
149
|
+
code: "COMPLEXITY_LIMIT_EXCEEDED",
|
|
150
|
+
limitType: error.limitType,
|
|
151
|
+
limit: error.limit,
|
|
152
|
+
actual: error.actual,
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
// Log analysis errors but don't block (fail open)
|
|
158
|
+
return Effect.logWarning("Complexity analysis failed for SSE subscription", error).pipe(
|
|
159
|
+
Effect.map(() => null)
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if (complexityResult) {
|
|
165
|
+
return Stream.make(formatErrorEvent([complexityResult]), formatCompleteEvent())
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build the context for the subscription
|
|
170
|
+
const ctx: SSEConnectionContext<R> = {
|
|
171
|
+
runtime,
|
|
172
|
+
request,
|
|
173
|
+
connectionContext,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Call onSubscribe hook if provided
|
|
177
|
+
if (options?.onSubscribe) {
|
|
178
|
+
yield* Effect.provide(options.onSubscribe(ctx), layer).pipe(
|
|
179
|
+
Effect.catchAll(() => Effect.void)
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Execute the subscription
|
|
184
|
+
const graphqlContext: GraphQLEffectContext<R> & Record<string, unknown> = {
|
|
185
|
+
runtime,
|
|
186
|
+
...connectionContext,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const subscriptionResult = yield* Effect.tryPromise({
|
|
190
|
+
try: () =>
|
|
191
|
+
subscribe({
|
|
192
|
+
schema,
|
|
193
|
+
document,
|
|
194
|
+
variableValues: request.variables,
|
|
195
|
+
operationName: request.operationName ?? undefined,
|
|
196
|
+
contextValue: graphqlContext,
|
|
197
|
+
}),
|
|
198
|
+
catch: (error) => new SSEError({ cause: error }),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Check if subscribe returned an error result instead of async iterator
|
|
202
|
+
if (!isAsyncIterable(subscriptionResult)) {
|
|
203
|
+
// It's an ExecutionResult with errors
|
|
204
|
+
const result = subscriptionResult as ExecutionResult
|
|
205
|
+
if (result.errors) {
|
|
206
|
+
return Stream.make(formatErrorEvent(result.errors), formatCompleteEvent())
|
|
207
|
+
}
|
|
208
|
+
// Shouldn't happen, but handle gracefully
|
|
209
|
+
return Stream.make(formatNextEvent(result), formatCompleteEvent())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Create a stream from the async iterator
|
|
213
|
+
const asyncIterator = subscriptionResult[Symbol.asyncIterator]()
|
|
214
|
+
|
|
215
|
+
const eventStream = Stream.async<SSEEvent, SSEError>((emit) => {
|
|
216
|
+
let done = false
|
|
217
|
+
|
|
218
|
+
const iterate = async () => {
|
|
219
|
+
try {
|
|
220
|
+
while (!done) {
|
|
221
|
+
const result = await asyncIterator.next()
|
|
222
|
+
if (result.done) {
|
|
223
|
+
emit.end()
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
emit.single(formatNextEvent(result.value))
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (!done) {
|
|
230
|
+
emit.single(
|
|
231
|
+
formatErrorEvent([
|
|
232
|
+
error instanceof GraphQLError
|
|
233
|
+
? error
|
|
234
|
+
: new GraphQLError(
|
|
235
|
+
error instanceof Error ? error.message : "Subscription error",
|
|
236
|
+
{ extensions: { code: "SUBSCRIPTION_ERROR" } }
|
|
237
|
+
),
|
|
238
|
+
])
|
|
239
|
+
)
|
|
240
|
+
emit.end()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
iterate()
|
|
246
|
+
|
|
247
|
+
// Return cleanup function
|
|
248
|
+
return Effect.sync(() => {
|
|
249
|
+
done = true
|
|
250
|
+
asyncIterator.return?.()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Add complete event at the end and handle cleanup
|
|
255
|
+
return eventStream.pipe(
|
|
256
|
+
Stream.onDone(() =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
yield* Effect.sync(() => {})
|
|
259
|
+
}).pipe(Effect.asVoid)
|
|
260
|
+
),
|
|
261
|
+
Stream.concat(Stream.make(formatCompleteEvent())),
|
|
262
|
+
Stream.onDone(() => {
|
|
263
|
+
if (options?.onComplete) {
|
|
264
|
+
return Effect.provide(options.onComplete(ctx), layer).pipe(
|
|
265
|
+
Effect.catchAll(() => Effect.void)
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
return Effect.void
|
|
269
|
+
})
|
|
270
|
+
)
|
|
271
|
+
}).pipe(
|
|
272
|
+
Effect.catchAll((error) =>
|
|
273
|
+
Effect.succeed(
|
|
274
|
+
Stream.make(
|
|
275
|
+
formatErrorEvent([
|
|
276
|
+
new GraphQLError(error instanceof Error ? error.message : "Internal error", {
|
|
277
|
+
extensions: { code: "INTERNAL_ERROR" },
|
|
278
|
+
}),
|
|
279
|
+
]),
|
|
280
|
+
formatCompleteEvent()
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create an SSE subscription handler that can be used with platform-specific servers.
|
|
290
|
+
*
|
|
291
|
+
* This is a higher-level API that returns a handler function. The handler
|
|
292
|
+
* takes a request and headers, and returns a Stream of SSE events.
|
|
293
|
+
*
|
|
294
|
+
* @param schema - The GraphQL schema with subscription definitions
|
|
295
|
+
* @param layer - Effect layer providing services required by resolvers
|
|
296
|
+
* @param options - Optional lifecycle hooks and configuration
|
|
297
|
+
* @returns A handler function for SSE subscription requests
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```typescript
|
|
301
|
+
* const handler = makeGraphQLSSEHandler(schema, serviceLayer, {
|
|
302
|
+
* onConnect: (request, headers) => Effect.gen(function* () {
|
|
303
|
+
* const token = headers.get("authorization")
|
|
304
|
+
* const user = yield* AuthService.validateToken(token)
|
|
305
|
+
* return { user }
|
|
306
|
+
* }),
|
|
307
|
+
* })
|
|
308
|
+
*
|
|
309
|
+
* // In platform-specific code:
|
|
310
|
+
* const events = handler(request, headers)
|
|
311
|
+
* // Stream events to client...
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
export const makeGraphQLSSEHandler = <R>(
|
|
315
|
+
schema: GraphQLSchema,
|
|
316
|
+
layer: Layer.Layer<R>,
|
|
317
|
+
options?: GraphQLSSEOptions<R>
|
|
318
|
+
): ((request: SSESubscriptionRequest, headers: Headers) => Stream.Stream<SSEEvent, SSEError>) => {
|
|
319
|
+
return (request, headers) => makeSSESubscriptionStream(schema, layer, request, headers, options)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Type guard to check if a value is an AsyncIterable (subscription result)
|
|
324
|
+
*/
|
|
325
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
326
|
+
return typeof value === "object" && value !== null && Symbol.asyncIterator in value
|
|
327
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Data, Effect, Runtime, Stream } from "effect"
|
|
2
|
+
import type { ExecutionResult } from "graphql"
|
|
3
|
+
import type { ComplexityConfig, FieldComplexityMap } from "./complexity"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Standard SSE response headers following the graphql-sse protocol.
|
|
7
|
+
* Use these headers when writing SSE responses in platform adapters.
|
|
8
|
+
*/
|
|
9
|
+
export const SSE_HEADERS: Record<string, string> = {
|
|
10
|
+
"Content-Type": "text/event-stream",
|
|
11
|
+
"Cache-Control": "no-cache",
|
|
12
|
+
Connection: "keep-alive",
|
|
13
|
+
"X-Accel-Buffering": "no", // Disable nginx buffering
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error type for SSE operations
|
|
18
|
+
*/
|
|
19
|
+
export class SSEError extends Data.TaggedError("SSEError")<{
|
|
20
|
+
readonly cause: unknown
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* SSE event types following the graphql-sse protocol (distinct connections mode).
|
|
25
|
+
* @see https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md
|
|
26
|
+
*/
|
|
27
|
+
export type SSEEventType = "next" | "error" | "complete"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An SSE event to be sent to the client.
|
|
31
|
+
*/
|
|
32
|
+
export interface SSEEvent {
|
|
33
|
+
readonly event: SSEEventType
|
|
34
|
+
readonly data: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Platform-neutral SSE response interface using Effect types.
|
|
39
|
+
*
|
|
40
|
+
* This interface abstracts SSE operations across different platforms
|
|
41
|
+
* (Node.js, Bun, Deno, Workers). Platform packages implement this
|
|
42
|
+
* interface to bridge their specific HTTP response implementations.
|
|
43
|
+
*
|
|
44
|
+
* Unlike WebSocket which is bidirectional, SSE is unidirectional
|
|
45
|
+
* (server to client only). The subscription query is provided
|
|
46
|
+
* upfront when creating the SSE connection.
|
|
47
|
+
*/
|
|
48
|
+
export interface EffectSSE {
|
|
49
|
+
/**
|
|
50
|
+
* Send an SSE event to the client.
|
|
51
|
+
* The platform adapter formats this as proper SSE format:
|
|
52
|
+
* ```
|
|
53
|
+
* event: next
|
|
54
|
+
* data: {"data":{"field":"value"}}
|
|
55
|
+
*
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
readonly sendEvent: (event: SSEEvent) => Effect.Effect<void, SSEError>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Effect that completes when the client disconnects.
|
|
62
|
+
* Use this to detect client disconnection and cleanup.
|
|
63
|
+
*/
|
|
64
|
+
readonly closed: Effect.Effect<void, SSEError>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The GraphQL request payload for SSE subscriptions.
|
|
69
|
+
* Same as a regular GraphQL HTTP request.
|
|
70
|
+
*/
|
|
71
|
+
export interface SSESubscriptionRequest {
|
|
72
|
+
readonly query: string
|
|
73
|
+
readonly variables?: Record<string, unknown>
|
|
74
|
+
readonly operationName?: string
|
|
75
|
+
readonly extensions?: Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Context available during an SSE subscription.
|
|
80
|
+
* This is passed to lifecycle hooks.
|
|
81
|
+
*/
|
|
82
|
+
export interface SSEConnectionContext<R> {
|
|
83
|
+
/**
|
|
84
|
+
* The Effect runtime for this connection.
|
|
85
|
+
* Use this to run Effects within the connection scope.
|
|
86
|
+
*/
|
|
87
|
+
readonly runtime: Runtime.Runtime<R>
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The original subscription request.
|
|
91
|
+
*/
|
|
92
|
+
readonly request: SSESubscriptionRequest
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Optional authentication/authorization context.
|
|
96
|
+
* Populated by the onConnect hook.
|
|
97
|
+
*/
|
|
98
|
+
readonly connectionContext: Record<string, unknown>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Options for configuring the GraphQL SSE handler.
|
|
103
|
+
*
|
|
104
|
+
* @template R - Service requirements for lifecycle hooks
|
|
105
|
+
*/
|
|
106
|
+
export interface GraphQLSSEOptions<R> {
|
|
107
|
+
/**
|
|
108
|
+
* Query complexity limiting configuration.
|
|
109
|
+
* When provided, subscriptions are validated against complexity limits
|
|
110
|
+
* before execution begins.
|
|
111
|
+
*/
|
|
112
|
+
readonly complexity?: ComplexityConfig
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Field complexity definitions from the schema builder.
|
|
116
|
+
* If using the platform serve() functions, this is typically
|
|
117
|
+
* passed automatically.
|
|
118
|
+
*/
|
|
119
|
+
readonly fieldComplexities?: FieldComplexityMap
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Called before a subscription starts.
|
|
123
|
+
*
|
|
124
|
+
* Use this for authentication/authorization. Return:
|
|
125
|
+
* - A context object to accept the subscription
|
|
126
|
+
* - Throw/fail to reject the subscription
|
|
127
|
+
*
|
|
128
|
+
* The returned object is available in the GraphQL context.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* onConnect: (request, headers) => Effect.gen(function* () {
|
|
133
|
+
* const token = headers.get("authorization")
|
|
134
|
+
* const user = yield* AuthService.validateToken(token)
|
|
135
|
+
* return { user } // Available in GraphQL context
|
|
136
|
+
* })
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
readonly onConnect?: (
|
|
140
|
+
request: SSESubscriptionRequest,
|
|
141
|
+
headers: Headers
|
|
142
|
+
) => Effect.Effect<Record<string, unknown>, unknown, R>
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Called when the subscription starts streaming.
|
|
146
|
+
*/
|
|
147
|
+
readonly onSubscribe?: (ctx: SSEConnectionContext<R>) => Effect.Effect<void, never, R>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Called when the subscription completes (normally or due to error).
|
|
151
|
+
*/
|
|
152
|
+
readonly onComplete?: (ctx: SSEConnectionContext<R>) => Effect.Effect<void, never, R>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Called when the client disconnects.
|
|
156
|
+
*/
|
|
157
|
+
readonly onDisconnect?: (ctx: SSEConnectionContext<R>) => Effect.Effect<void, never, R>
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Called when an error occurs during subscription execution.
|
|
161
|
+
*/
|
|
162
|
+
readonly onError?: (ctx: SSEConnectionContext<R>, error: unknown) => Effect.Effect<void, never, R>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Configuration for the SSE endpoint
|
|
167
|
+
*/
|
|
168
|
+
export interface GraphQLSSEConfig {
|
|
169
|
+
/**
|
|
170
|
+
* Path for SSE connections.
|
|
171
|
+
* @default "/graphql/stream"
|
|
172
|
+
*/
|
|
173
|
+
readonly path?: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Result of SSE subscription handler creation.
|
|
178
|
+
* This is used by platform packages to implement their SSE response.
|
|
179
|
+
*/
|
|
180
|
+
export interface SSESubscriptionResult {
|
|
181
|
+
/**
|
|
182
|
+
* Stream of SSE events to send to the client.
|
|
183
|
+
* The platform adapter should consume this stream and send events.
|
|
184
|
+
*/
|
|
185
|
+
readonly events: Stream.Stream<SSEEvent, SSEError>
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Effect that should be run when client disconnects.
|
|
189
|
+
* This allows cleanup of resources.
|
|
190
|
+
*/
|
|
191
|
+
readonly cleanup: Effect.Effect<void, never, never>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Format an ExecutionResult as an SSE "next" event.
|
|
196
|
+
*/
|
|
197
|
+
export const formatNextEvent = (result: ExecutionResult): SSEEvent => ({
|
|
198
|
+
event: "next",
|
|
199
|
+
data: JSON.stringify(result),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Format errors as an SSE "error" event.
|
|
204
|
+
*/
|
|
205
|
+
export const formatErrorEvent = (errors: readonly unknown[]): SSEEvent => ({
|
|
206
|
+
event: "error",
|
|
207
|
+
data: JSON.stringify({ errors }),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format a "complete" event.
|
|
212
|
+
*/
|
|
213
|
+
export const formatCompleteEvent = (): SSEEvent => ({
|
|
214
|
+
event: "complete",
|
|
215
|
+
data: "",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Format an SSE event to the wire format.
|
|
220
|
+
* Each event is formatted as:
|
|
221
|
+
* ```
|
|
222
|
+
* event: <type>
|
|
223
|
+
* data: <json>
|
|
224
|
+
*
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export const formatSSEMessage = (event: SSEEvent): string => {
|
|
228
|
+
const lines = [`event: ${event.event}`]
|
|
229
|
+
if (event.data) {
|
|
230
|
+
lines.push(`data: ${event.data}`)
|
|
231
|
+
}
|
|
232
|
+
lines.push("", "") // Two newlines to end the event
|
|
233
|
+
return lines.join("\n")
|
|
234
|
+
}
|