@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.
- package/README.md +100 -0
- package/index.cjs +385 -0
- package/index.cjs.map +1 -0
- package/index.d.cts +403 -0
- package/index.d.ts +403 -0
- package/index.js +372 -0
- package/index.js.map +1 -0
- package/package.json +14 -26
- package/dist/config.d.ts +0 -106
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -27
- package/dist/config.js.map +0 -1
- package/dist/errors.d.ts +0 -77
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -77
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts +0 -56
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -71
- package/dist/index.js.map +0 -1
- package/dist/memory-store.d.ts +0 -67
- package/dist/memory-store.d.ts.map +0 -1
- package/dist/memory-store.js +0 -104
- package/dist/memory-store.js.map +0 -1
- package/dist/persisted-queries-router.d.ts +0 -58
- package/dist/persisted-queries-router.d.ts.map +0 -1
- package/dist/persisted-queries-router.js +0 -277
- package/dist/persisted-queries-router.js.map +0 -1
- package/dist/store.d.ts +0 -49
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -29
- package/dist/store.js.map +0 -1
- package/dist/utils.d.ts +0 -51
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -73
- package/dist/utils.js.map +0 -1
- package/src/config.ts +0 -122
- package/src/errors.ts +0 -107
- package/src/index.ts +0 -77
- package/src/memory-store.ts +0 -133
- package/src/persisted-queries-router.ts +0 -421
- package/src/store.ts +0 -54
- package/src/utils.ts +0 -111
|
@@ -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
|
-
})
|