@effect-gql/persisted-queries 0.1.0 → 1.0.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.
@@ -1,421 +0,0 @@
1
- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform"
2
- import { Effect, Layer, Option } from "effect"
3
- import {
4
- GraphQLSchema,
5
- parse,
6
- validate,
7
- specifiedRules,
8
- NoSchemaIntrospectionCustomRule,
9
- execute as graphqlExecute,
10
- type DocumentNode,
11
- } from "graphql"
12
- import {
13
- normalizeConfig,
14
- graphiqlHtml,
15
- validateComplexity,
16
- ComplexityLimitExceededError,
17
- type FieldComplexityMap,
18
- type ErrorHandler,
19
- defaultErrorHandler,
20
- } from "@effect-gql/core/server"
21
- import type { GraphQLEffectContext } from "@effect-gql/core"
22
- import {
23
- ExtensionsService,
24
- makeExtensionsService,
25
- runParseHooks,
26
- runValidateHooks,
27
- runExecuteStartHooks,
28
- runExecuteEndHooks,
29
- type GraphQLExtension,
30
- } from "@effect-gql/core"
31
- import { PersistedQueryStore } from "./store"
32
- import { makeMemoryStore } from "./memory-store"
33
- import type { PersistedQueriesRouterOptions, PersistedQueryMode, HashAlgorithm } from "./config"
34
- import {
35
- PersistedQueryNotFoundError,
36
- PersistedQueryVersionError,
37
- PersistedQueryHashMismatchError,
38
- PersistedQueryNotAllowedError,
39
- } from "./errors"
40
- import {
41
- computeHash,
42
- parsePersistedQueryExtension,
43
- parseGetRequestBody,
44
- type GraphQLRequestBody,
45
- } from "./utils"
46
-
47
- /**
48
- * Create a GraphQL router with Apollo Persisted Queries support.
49
- *
50
- * This creates a complete GraphQL router that includes:
51
- * - Apollo Persisted Queries (APQ) support
52
- * - GET request support for CDN caching
53
- * - All standard GraphQL router features (validation, execution, extensions)
54
- *
55
- * ## Apollo APQ Protocol
56
- *
57
- * 1. Client sends request with `extensions.persistedQuery.sha256Hash`
58
- * 2. If hash found in store, execute the stored query
59
- * 3. If hash NOT found and query provided (APQ mode), store it and execute
60
- * 4. If hash NOT found and NO query, return `PERSISTED_QUERY_NOT_FOUND`
61
- * 5. Client retries with both hash and query
62
- *
63
- * ## Modes
64
- *
65
- * - **APQ mode** (`mode: "apq"`): Automatic runtime registration.
66
- * Unknown queries trigger NOT_FOUND, prompting client retry with full query.
67
- *
68
- * - **Safelist mode** (`mode: "safelist"`): Pre-registered queries only.
69
- * Unknown queries return NOT_ALLOWED error. Use with `makeSafelistStore()`.
70
- *
71
- * @example APQ Mode (default)
72
- * ```typescript
73
- * import { makePersistedQueriesRouter } from "@effect-gql/persisted-queries"
74
- *
75
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
76
- * mode: "apq",
77
- * enableGet: true,
78
- * graphiql: { path: "/graphiql" },
79
- * })
80
- * ```
81
- *
82
- * @example Safelist Mode
83
- * ```typescript
84
- * import { makePersistedQueriesRouter, makeSafelistStore } from "@effect-gql/persisted-queries"
85
- *
86
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
87
- * mode: "safelist",
88
- * store: makeSafelistStore({
89
- * "abc123...": "query GetUser($id: ID!) { user(id: $id) { name } }",
90
- * }),
91
- * })
92
- * ```
93
- *
94
- * @param schema - The GraphQL schema
95
- * @param layer - Effect layer providing services required by resolvers
96
- * @param options - Router and persisted query configuration
97
- * @returns An HttpRouter with persisted query support
98
- */
99
- export const makePersistedQueriesRouter = <R>(
100
- schema: GraphQLSchema,
101
- layer: Layer.Layer<R>,
102
- options: PersistedQueriesRouterOptions = {}
103
- ): HttpRouter.HttpRouter<never, never> => {
104
- const mode: PersistedQueryMode = options.mode ?? "apq"
105
- const enableGet = options.enableGet ?? true
106
- const validateHashOption = options.validateHash ?? true
107
- const hashAlgorithm: HashAlgorithm = options.hashAlgorithm ?? "sha256"
108
-
109
- // Core router config
110
- const resolvedConfig = normalizeConfig(options)
111
- const fieldComplexities: FieldComplexityMap = options.fieldComplexities ?? new Map()
112
- const extensions: readonly GraphQLExtension<R>[] = options.extensions ?? []
113
- const errorHandler: ErrorHandler = options.errorHandler ?? defaultErrorHandler
114
-
115
- // Create the store layer (default to in-memory)
116
- const baseStoreLayer = options.store ?? makeMemoryStore()
117
-
118
- /**
119
- * Resolve a persisted query from the store or register it.
120
- * Returns the resolved request body with the query filled in.
121
- */
122
- const resolvePersistedQuery = (
123
- body: GraphQLRequestBody
124
- ): Effect.Effect<
125
- GraphQLRequestBody,
126
- | PersistedQueryNotFoundError
127
- | PersistedQueryVersionError
128
- | PersistedQueryHashMismatchError
129
- | PersistedQueryNotAllowedError,
130
- PersistedQueryStore
131
- > =>
132
- Effect.gen(function* () {
133
- const persistedQuery = parsePersistedQueryExtension(body.extensions)
134
-
135
- if (!persistedQuery) {
136
- // No persisted query extension - pass through unchanged
137
- return body
138
- }
139
-
140
- // Validate version
141
- if (persistedQuery.version !== 1) {
142
- return yield* Effect.fail(
143
- new PersistedQueryVersionError({ version: persistedQuery.version })
144
- )
145
- }
146
-
147
- const hash = persistedQuery.sha256Hash
148
- const store = yield* PersistedQueryStore
149
-
150
- // Check if we have the query stored
151
- const storedQuery = yield* store.get(hash)
152
-
153
- if (Option.isSome(storedQuery)) {
154
- // Query found - use it
155
- return {
156
- ...body,
157
- query: storedQuery.value,
158
- }
159
- }
160
-
161
- // Query not found in store
162
- if (!body.query) {
163
- // No query provided - client needs to send it
164
- return yield* Effect.fail(new PersistedQueryNotFoundError({ hash }))
165
- }
166
-
167
- // Query provided - check mode
168
- if (mode === "safelist") {
169
- // Safelist mode: reject unknown queries
170
- return yield* Effect.fail(new PersistedQueryNotAllowedError({ hash }))
171
- }
172
-
173
- // APQ mode: validate hash and store
174
- if (validateHashOption) {
175
- const computed = yield* computeHash(body.query, hashAlgorithm)
176
- if (computed !== hash) {
177
- return yield* Effect.fail(
178
- new PersistedQueryHashMismatchError({
179
- providedHash: hash,
180
- computedHash: computed,
181
- })
182
- )
183
- }
184
- }
185
-
186
- // Store the query for future requests
187
- yield* store.set(hash, body.query)
188
-
189
- return body
190
- })
191
-
192
- /**
193
- * Main GraphQL handler with APQ support
194
- */
195
- const createHandler = <RE>(parseBody: Effect.Effect<GraphQLRequestBody, Error, RE>) =>
196
- Effect.gen(function* () {
197
- // Parse request body
198
- const rawBody = yield* parseBody.pipe(
199
- Effect.catchAll((error) =>
200
- Effect.succeed({
201
- _parseError: true,
202
- message: error.message,
203
- } as any)
204
- )
205
- )
206
-
207
- if ("_parseError" in rawBody) {
208
- return yield* HttpServerResponse.json(
209
- { errors: [{ message: rawBody.message }] },
210
- { status: 400 }
211
- )
212
- }
213
-
214
- // Resolve persisted query
215
- const bodyResult = yield* resolvePersistedQuery(rawBody).pipe(
216
- Effect.provide(baseStoreLayer),
217
- Effect.either
218
- )
219
-
220
- if (bodyResult._tag === "Left") {
221
- // APQ error - return appropriate GraphQL error response
222
- const error = bodyResult.left
223
- return yield* HttpServerResponse.json({
224
- errors: [error.toGraphQLError()],
225
- })
226
- }
227
-
228
- const body = bodyResult.right
229
-
230
- // Check if we have a query to execute
231
- if (!body.query) {
232
- return yield* HttpServerResponse.json(
233
- { errors: [{ message: "No query provided" }] },
234
- { status: 400 }
235
- )
236
- }
237
-
238
- // Create the ExtensionsService for this request
239
- const extensionsService = yield* makeExtensionsService()
240
-
241
- // Get the runtime from the layer
242
- const runtime = yield* Effect.runtime<R>()
243
-
244
- // Phase 1: Parse
245
- let document: DocumentNode
246
- try {
247
- document = parse(body.query)
248
- } catch (parseError) {
249
- const extensionData = yield* extensionsService.get()
250
- return yield* HttpServerResponse.json({
251
- errors: [{ message: String(parseError) }],
252
- extensions: Object.keys(extensionData).length > 0 ? extensionData : undefined,
253
- })
254
- }
255
-
256
- // Run onParse hooks
257
- yield* runParseHooks(extensions, body.query, document).pipe(
258
- Effect.provideService(ExtensionsService, extensionsService)
259
- )
260
-
261
- // Phase 2: Validate
262
- // Add NoSchemaIntrospectionCustomRule if introspection is disabled
263
- const validationRules = resolvedConfig.introspection
264
- ? undefined
265
- : [...specifiedRules, NoSchemaIntrospectionCustomRule]
266
- const validationErrors = validate(schema, document, validationRules)
267
-
268
- // Run onValidate hooks
269
- yield* runValidateHooks(extensions, document, validationErrors).pipe(
270
- Effect.provideService(ExtensionsService, extensionsService)
271
- )
272
-
273
- // If validation failed, return errors without executing
274
- if (validationErrors.length > 0) {
275
- const extensionData = yield* extensionsService.get()
276
- return yield* HttpServerResponse.json(
277
- {
278
- errors: validationErrors.map((e) => ({
279
- message: e.message,
280
- locations: e.locations,
281
- path: e.path,
282
- })),
283
- extensions: Object.keys(extensionData).length > 0 ? extensionData : undefined,
284
- },
285
- { status: 400 }
286
- )
287
- }
288
-
289
- // Validate query complexity if configured
290
- if (resolvedConfig.complexity) {
291
- yield* validateComplexity(
292
- body.query,
293
- body.operationName,
294
- body.variables,
295
- schema,
296
- fieldComplexities,
297
- resolvedConfig.complexity
298
- ).pipe(
299
- Effect.catchTag("ComplexityLimitExceededError", (error) => Effect.fail(error)),
300
- Effect.catchTag("ComplexityAnalysisError", (error) =>
301
- Effect.logWarning("Complexity analysis failed", error)
302
- )
303
- )
304
- }
305
-
306
- // Phase 3: Execute
307
- const executionArgs = {
308
- source: body.query,
309
- document,
310
- variableValues: body.variables,
311
- operationName: body.operationName,
312
- schema,
313
- fieldComplexities,
314
- }
315
-
316
- // Run onExecuteStart hooks
317
- yield* runExecuteStartHooks(extensions, executionArgs).pipe(
318
- Effect.provideService(ExtensionsService, extensionsService)
319
- )
320
-
321
- // Execute GraphQL query
322
- const executeResult = yield* Effect.try({
323
- try: () =>
324
- graphqlExecute({
325
- schema,
326
- document,
327
- variableValues: body.variables,
328
- operationName: body.operationName,
329
- contextValue: { runtime } satisfies GraphQLEffectContext<R>,
330
- }),
331
- catch: (error) => new Error(String(error)),
332
- })
333
-
334
- // Await result if it's a promise
335
- const resolvedResult: Awaited<typeof executeResult> =
336
- executeResult && typeof executeResult === "object" && "then" in executeResult
337
- ? yield* Effect.promise(() => executeResult)
338
- : executeResult
339
-
340
- // Run onExecuteEnd hooks
341
- yield* runExecuteEndHooks(extensions, resolvedResult).pipe(
342
- Effect.provideService(ExtensionsService, extensionsService)
343
- )
344
-
345
- // Merge extension data into result
346
- const extensionData = yield* extensionsService.get()
347
- const finalResult =
348
- Object.keys(extensionData).length > 0
349
- ? {
350
- ...resolvedResult,
351
- extensions: {
352
- ...resolvedResult.extensions,
353
- ...extensionData,
354
- },
355
- }
356
- : resolvedResult
357
-
358
- return yield* HttpServerResponse.json(finalResult)
359
- }).pipe(
360
- Effect.provide(layer),
361
- Effect.catchAll((error) => {
362
- if (error instanceof ComplexityLimitExceededError) {
363
- return HttpServerResponse.json(
364
- {
365
- errors: [
366
- {
367
- message: error.message,
368
- extensions: {
369
- code: "COMPLEXITY_LIMIT_EXCEEDED",
370
- limitType: error.limitType,
371
- limit: error.limit,
372
- actual: error.actual,
373
- },
374
- },
375
- ],
376
- },
377
- { status: 400 }
378
- ).pipe(Effect.orDie)
379
- }
380
- return Effect.fail(error)
381
- }),
382
- Effect.catchAllCause(errorHandler)
383
- )
384
-
385
- // POST handler
386
- const postHandler = createHandler(
387
- Effect.gen(function* () {
388
- const request = yield* HttpServerRequest.HttpServerRequest
389
- return yield* request.json as Effect.Effect<GraphQLRequestBody, Error>
390
- })
391
- )
392
-
393
- // GET handler for CDN-cacheable persisted queries
394
- const getHandler = createHandler(
395
- Effect.gen(function* () {
396
- const request = yield* HttpServerRequest.HttpServerRequest
397
- const url = new URL(request.url, "http://localhost")
398
- return yield* parseGetRequestBody(url.searchParams)
399
- })
400
- )
401
-
402
- // Build router
403
- let router = HttpRouter.empty.pipe(
404
- HttpRouter.post(resolvedConfig.path as HttpRouter.PathInput, postHandler)
405
- )
406
-
407
- // Add GET handler if enabled
408
- if (enableGet) {
409
- router = router.pipe(HttpRouter.get(resolvedConfig.path as HttpRouter.PathInput, getHandler))
410
- }
411
-
412
- // Add GraphiQL route if enabled
413
- if (resolvedConfig.graphiql) {
414
- const { path, endpoint } = resolvedConfig.graphiql
415
- router = router.pipe(
416
- HttpRouter.get(path as HttpRouter.PathInput, HttpServerResponse.html(graphiqlHtml(endpoint)))
417
- )
418
- }
419
-
420
- return router
421
- }
package/src/store.ts DELETED
@@ -1,54 +0,0 @@
1
- import { Context, Effect, Option } from "effect"
2
-
3
- /**
4
- * Interface for persisted query storage.
5
- *
6
- * Implementations can be in-memory LRU cache, Redis, database, etc.
7
- * The interface uses Effect for consistency with the rest of the framework.
8
- */
9
- export interface PersistedQueryStore {
10
- /**
11
- * Get a query by its hash.
12
- * Returns Option.none() if not found.
13
- */
14
- readonly get: (hash: string) => Effect.Effect<Option.Option<string>>
15
-
16
- /**
17
- * Store a query with its hash.
18
- * In safelist mode, this may be a no-op.
19
- */
20
- readonly set: (hash: string, query: string) => Effect.Effect<void>
21
-
22
- /**
23
- * Check if a hash exists in the store without retrieving the query.
24
- * More efficient for large queries when you only need existence check.
25
- */
26
- readonly has: (hash: string) => Effect.Effect<boolean>
27
- }
28
-
29
- /**
30
- * Service tag for the PersistedQueryStore.
31
- *
32
- * Use this tag to provide a store implementation via Effect's dependency injection.
33
- *
34
- * @example
35
- * ```typescript
36
- * import { PersistedQueryStore, makeMemoryStore } from "@effect-gql/persisted-queries"
37
- *
38
- * // Create a layer with the memory store
39
- * const storeLayer = makeMemoryStore({ maxSize: 1000 })
40
- *
41
- * // Use in an Effect
42
- * const program = Effect.gen(function* () {
43
- * const store = yield* PersistedQueryStore
44
- * yield* store.set("abc123", "query { hello }")
45
- * const query = yield* store.get("abc123")
46
- * // query: Option.some("query { hello }")
47
- * })
48
- *
49
- * Effect.runPromise(Effect.provide(program, storeLayer))
50
- * ```
51
- */
52
- export const PersistedQueryStore = Context.GenericTag<PersistedQueryStore>(
53
- "@effect-gql/persisted-queries/PersistedQueryStore"
54
- )
package/src/utils.ts DELETED
@@ -1,111 +0,0 @@
1
- import { Effect } from "effect"
2
- import { createHash } from "crypto"
3
- import type { HashAlgorithm } from "./config"
4
-
5
- /**
6
- * Compute the hash of a query string.
7
- *
8
- * @param query - The GraphQL query string to hash
9
- * @param algorithm - Hash algorithm to use (default: sha256)
10
- * @returns Effect that resolves to the hex-encoded hash
11
- */
12
- export const computeHash = (
13
- query: string,
14
- algorithm: HashAlgorithm = "sha256"
15
- ): Effect.Effect<string> => Effect.sync(() => createHash(algorithm).update(query).digest("hex"))
16
-
17
- /**
18
- * Structure of the persisted query extension in GraphQL requests.
19
- * This follows the Apollo APQ protocol.
20
- */
21
- export interface PersistedQueryExtension {
22
- readonly version: number
23
- readonly sha256Hash: string
24
- }
25
-
26
- /**
27
- * Parse and validate the persisted query extension from request extensions.
28
- *
29
- * @param extensions - The extensions object from the GraphQL request
30
- * @returns The parsed persisted query extension, or null if not present/invalid
31
- */
32
- export const parsePersistedQueryExtension = (
33
- extensions: unknown
34
- ): PersistedQueryExtension | null => {
35
- if (typeof extensions !== "object" || extensions === null || !("persistedQuery" in extensions)) {
36
- return null
37
- }
38
-
39
- const pq = (extensions as Record<string, unknown>).persistedQuery
40
-
41
- if (typeof pq !== "object" || pq === null || !("version" in pq) || !("sha256Hash" in pq)) {
42
- return null
43
- }
44
-
45
- const version = (pq as Record<string, unknown>).version
46
- const sha256Hash = (pq as Record<string, unknown>).sha256Hash
47
-
48
- if (typeof version !== "number" || typeof sha256Hash !== "string") {
49
- return null
50
- }
51
-
52
- return { version, sha256Hash }
53
- }
54
-
55
- /**
56
- * GraphQL request body structure with optional persisted query extension.
57
- */
58
- export interface GraphQLRequestBody {
59
- query?: string
60
- variables?: Record<string, unknown>
61
- operationName?: string
62
- extensions?: {
63
- persistedQuery?: PersistedQueryExtension
64
- [key: string]: unknown
65
- }
66
- }
67
-
68
- /**
69
- * Parse a GET request's query parameters into a GraphQL request body.
70
- *
71
- * Supports the following query parameters:
72
- * - `query`: The GraphQL query string (optional with persisted queries)
73
- * - `variables`: JSON-encoded variables object
74
- * - `operationName`: Name of the operation to execute
75
- * - `extensions`: JSON-encoded extensions object containing persistedQuery
76
- *
77
- * @param searchParams - URLSearchParams from the request URL
78
- * @returns Effect that resolves to the parsed request body or fails with parse error
79
- */
80
- export const parseGetRequestBody = (
81
- searchParams: URLSearchParams
82
- ): Effect.Effect<GraphQLRequestBody, Error> =>
83
- Effect.try({
84
- try: () => {
85
- const extensionsRaw = searchParams.get("extensions")
86
- const variablesRaw = searchParams.get("variables")
87
-
88
- const result: GraphQLRequestBody = {}
89
-
90
- const query = searchParams.get("query")
91
- if (query) {
92
- result.query = query
93
- }
94
-
95
- const operationName = searchParams.get("operationName")
96
- if (operationName) {
97
- result.operationName = operationName
98
- }
99
-
100
- if (variablesRaw) {
101
- result.variables = JSON.parse(variablesRaw)
102
- }
103
-
104
- if (extensionsRaw) {
105
- result.extensions = JSON.parse(extensionsRaw)
106
- }
107
-
108
- return result
109
- },
110
- catch: (e) => new Error(`Failed to parse query parameters: ${e}`),
111
- })