From dc1e24b5340ed7eba300a702b17f9be5cff65a8f Mon Sep 17 00:00:00 2001 From: Saihajpreet Singh Date: Wed, 12 Oct 2022 08:59:21 -0500 Subject: [PATCH] V3 (#1487) * feat: non-breaking GraphQL Engine * feat: make envelop take in engine functions * structural typings * remove grapqhl as peer dep from types pkg * make core more agnostic * chore(dependencies): updated changesets for modified dependencies * remove traced schema * remove traced orchestrator * make plugin agnostic * drop EnvelopError * remove more graphql import * Drop useTiming * make core completely free of graphql-js * add eslint rule * eslint disallow in types too * more agnostic packages * remove introspection util * chore(dependencies): updated changesets for modified dependencies * prettier * TEMP: make bot calm down * update all docs * test matrix for core * experimenting traced schema (#1501) * experimenting traced schema * Fix * remove comment * do optional chaining since we are not strongly typing * document * cleanup Co-authored-by: Arda TANRIKULU * feat: drop node 12 (#1505) * feat: remove `enableIf` utility (#1504) * feat: remove enableIf utility * make types happpy * make types happy * add changeset * feat: remove async schema plugin and rename lazy loaded schema plugin (#1506) * remove useAsyncSchema * rename to useSchemaByContext * add eslint rule (#1509) * feat: `@envelop/on-resolve` plugin for hooking into schema resolvers (#1500) * on resolve plugin * changeset * no more onResolverCalled * unused import * args is a record * integrate and useOnResolve * resolversHooksSymbol does not exist * plugincontext for OnPluginInit * on-resolve uses addPlugin * onresolvercalled is no more * refactor for new on-resolve * fix open-telemetry tests * fix newrelic * opentelemetry graphql as peer dep * tests * addPlugin doesnt need to be used * reorder * respects onPluginInit context * drop unused import * fixes false positive eslint warnings Co-authored-by: Dimitri POSTOLOV * docs: order of plugins matter (#1513) * feat: remove handler for validation and parse errors (#1510) * feat: remove hanlder for validation and parse errors * tests * make it work * Add docs * Fix serialization issue * Go * .. * update docs * update test * make it work * feat: add originalError in dev mode (#1514) * remove Fn appendix * name graphql error * make ts happy * make toJSON required Co-authored-by: Arda TANRIKULU Co-authored-by: Laurin Quast * add changeset * Update .changeset/nervous-seas-own.md * Update .changeset/rude-cats-peel.md * no-use-before-define (#1522) * feat: trigger on context, validate and parse errors (#1511) * feat: trigger on context, validate and parse errors * trying * make it work * pass in phase * add phase details * feedback * docs: migration guide (#1520) * docs: migration guide * remove slashes * Update website/docs/guides/migrating-from-v2-to-v3.mdx Co-authored-by: Denis Badurina * feedback * document removing of orchestrated tracer * more feedback * update docs * update examples * async schema example Co-authored-by: Denis Badurina * Add redirects * remove deafult skip error from sentry plugin * export masked error plugin * sentry plugin default skip GraphQLError * sentry: og error is not graphql error then send to sentry * doc: drop introspection utils * should fix ts issues on v15 * test: stack error we should not match the error name since it can be different across impl * feat: use engine plugin (#1535) * feat: use engine plugin * remove assertion * make useEngine plugin the only way to pass engine functions (#1536) * make useEngine plugin the only way to pass engine fns * update types * update docs * Update packages/plugins/apollo-datasources/README.md Co-authored-by: Arda TANRIKULU * prettier: Co-authored-by: Arda TANRIKULU Co-authored-by: Arda TANRIKULU * fix * versioned docs with v3 default * fix build * fix * feat this is cool * docs reword schema tracing * docs add graphql error example * docs restructuring, fix typos * chore remove autogenerated changeset * cleanup changeset * docs sycn patch Co-authored-by: github-actions[bot] Co-authored-by: Arda TANRIKULU Co-authored-by: Denis Badurina Co-authored-by: Dimitri POSTOLOV Co-authored-by: Laurin Quast Co-authored-by: Denis Badurina --- .changeset/bright-fishes-learn.md | 5 + .changeset/curvy-bottles-repeat.md | 5 + .changeset/curvy-cheetahs-bathe.md | 33 + .changeset/dirty-birds-rush.md | 5 + .changeset/dry-donuts-guess.md | 7 + .changeset/grumpy-windows-behave.md | 5 + .changeset/late-students-float.md | 7 + .changeset/light-bees-dream.md | 7 + .changeset/light-tomatoes-enjoy.md | 7 + .changeset/lovely-clocks-type.md | 19 + .changeset/nervous-seas-own.md | 5 + .changeset/quiet-mice-jam.md | 27 + .changeset/rude-cats-peel.md | 10 + .changeset/silent-impalas-retire.md | 5 + .changeset/warm-bulldogs-do.md | 7 + .eslintrc.json | 61 +- .github/workflows/tests.yml | 35 +- examples/apollo-server/index.ts | 9 +- examples/apollo-server/package.json | 3 +- examples/azure-functions/index.ts | 9 +- examples/azure-functions/package.json | 3 +- examples/cloudflare-workers/index.ts | 9 +- examples/cloudflare-workers/package.json | 3 +- examples/express-graphql/index.ts | 9 +- examples/express-graphql/package.json | 3 +- examples/google-cloud-functions/index.ts | 9 +- examples/google-cloud-functions/package.json | 3 +- examples/graphql-helix-auth0/index.ts | 5 + examples/graphql-helix-auth0/package.json | 3 +- examples/graphql-helix-defer-stream/index.ts | 9 +- examples/graphql-helix/index.ts | 9 +- examples/graphql-helix/package.json | 3 +- examples/graphql-socket.io/index.ts | 7 +- examples/graphql-socket.io/package.json | 3 +- examples/graphql-sse/index.ts | 9 +- examples/graphql-sse/package.json | 3 +- examples/graphql-ws/index.ts | 9 +- examples/graphql-ws/package.json | 3 +- examples/lambda-aws/index.ts | 9 +- examples/lambda-aws/package.json | 3 +- examples/nexus/index.ts | 9 +- examples/simple-http/index.ts | 9 +- examples/simple-http/package.json | 3 +- examples/typegraphql/index.ts | 15 +- examples/with-esm/package.json | 3 +- examples/with-esm/src/index.ts | 9 +- package.json | 8 +- packages/core/README.md | 3 +- packages/core/docs/use-async-schema.md | 19 - packages/core/docs/use-engine.md | 19 + packages/core/docs/use-error-handler.md | 5 +- packages/core/docs/use-extend-context.md | 5 +- packages/core/docs/use-logger.md | 5 +- packages/core/docs/use-masked-errors.md | 33 +- packages/core/docs/use-payload-formatter.md | 5 +- ...ded-schema.md => use-schema-by-context.md} | 7 +- packages/core/docs/use-schema.md | 5 +- packages/core/docs/use-timing.md | 24 - packages/core/package.json | 3 - packages/core/src/create.ts | 43 +- packages/core/src/enable-if.ts | 27 - packages/core/src/index.ts | 4 +- packages/core/src/orchestrator.ts | 81 +- packages/core/src/plugins/use-engine.ts | 33 + .../core/src/plugins/use-error-handler.ts | 40 +- .../core/src/plugins/use-masked-errors.ts | 159 +- .../core/src/plugins/use-payload-formatter.ts | 3 +- packages/core/src/plugins/use-schema.ts | 15 +- packages/core/src/plugins/use-timing.ts | 185 -- packages/core/src/traced-orchestrator.ts | 176 -- packages/core/src/traced-schema.ts | 75 - packages/core/src/utils.ts | 50 +- packages/core/test/context.spec.ts | 6 +- packages/core/test/execute.spec.ts | 63 - packages/core/test/extends.spec.ts | 6 +- packages/core/test/plugins/use-engine.spec.ts | 34 + .../test/plugins/use-error-handler.spec.ts | 69 +- .../test/plugins/use-masked-errors.spec.ts | 253 +-- packages/core/test/utils.spec.ts | 73 - packages/core/test/validate.spec.ts | 2 +- packages/plugins/apollo-datasources/README.md | 4 +- packages/plugins/apollo-federation/README.md | 4 +- .../plugins/apollo-server-errors/README.md | 4 +- .../test/apollo-server-errors.spec.ts | 6 +- packages/plugins/apollo-tracing/README.md | 4 +- packages/plugins/apollo-tracing/package.json | 2 + packages/plugins/apollo-tracing/src/index.ts | 34 +- packages/plugins/auth0/README.md | 4 +- packages/plugins/auth0/package.json | 4 +- packages/plugins/auth0/src/index.ts | 9 +- packages/plugins/dataloader/README.md | 4 +- packages/plugins/dataloader/package.json | 3 +- packages/plugins/depth-limit/README.md | 4 +- .../plugins/disable-introspection/README.md | 5 +- .../execute-subscription-event/README.md | 8 +- .../plugins/extended-validation/README.md | 10 +- .../plugins/extended-validation/src/plugin.ts | 5 +- .../plugins/filter-operation-type/README.md | 6 +- packages/plugins/fragment-arguments/README.md | 4 +- .../test/use-fragment-arguments.spec.ts | 5 +- packages/plugins/generic-auth/README.md | 12 +- packages/plugins/graphql-jit/README.md | 12 +- packages/plugins/graphql-middleware/README.md | 4 +- .../plugins/graphql-middleware/src/index.ts | 1 - packages/plugins/graphql-modules/README.md | 4 +- .../immediate-introspection/.npmignore | 2 + .../immediate-introspection/README.md} | 18 +- .../immediate-introspection/package.json | 67 + .../immediate-introspection/src/index.ts} | 0 .../test}/use-immediate-introspection.spec.ts | 6 +- packages/plugins/live-query/README.md | 4 +- packages/plugins/live-query/src/index.ts | 6 +- packages/plugins/newrelic/README.md | 4 +- packages/plugins/newrelic/package.json | 1 + packages/plugins/newrelic/src/index.ts | 108 +- packages/plugins/on-resolve/README.md | 102 + packages/plugins/on-resolve/package.json | 66 + packages/plugins/on-resolve/src/index.ts | 85 + .../on-resolve/test/use-on-resolve.spec.ts | 63 + packages/plugins/opentelemetry/README.md | 8 +- packages/plugins/opentelemetry/package.json | 1 + packages/plugins/opentelemetry/src/index.ts | 73 +- .../operation-field-permissions/README.md | 4 +- .../operation-field-permissions/src/index.ts | 10 +- .../test/use-operation-permissions.spec.ts | 21 - packages/plugins/parser-cache/README.md | 4 +- .../plugins/persisted-operations/README.md | 11 +- packages/plugins/preload-assets/README.md | 5 +- packages/plugins/preload-assets/package.json | 3 +- packages/plugins/prometheus/README.md | 11 +- packages/plugins/prometheus/package.json | 1 + packages/plugins/prometheus/src/index.ts | 45 +- packages/plugins/rate-limiter/README.md | 4 +- packages/plugins/rate-limiter/package.json | 1 + packages/plugins/rate-limiter/src/index.ts | 98 +- .../plugins/resource-limitations/README.md | 4 +- .../plugins/response-cache-redis/README.md | 8 +- packages/plugins/response-cache/README.md | 84 +- packages/plugins/sentry/README.md | 4 +- packages/plugins/sentry/package.json | 1 + packages/plugins/sentry/src/index.ts | 24 +- packages/plugins/statsd/README.md | 4 +- packages/plugins/statsd/package.json | 1 - packages/plugins/statsd/src/index.ts | 5 +- packages/plugins/validation-cache/README.md | 4 +- .../plugins/validation-cache/src/index.ts | 1 + packages/testing/src/index.ts | 33 +- packages/testing/test/test.spec.ts | 3 +- packages/types/package.json | 4 - packages/types/src/get-enveloped.ts | 3 +- packages/types/src/graphql.ts | 87 +- packages/types/src/hooks.ts | 81 +- packages/types/src/plugin.ts | 8 +- packages/types/src/utils.ts | 17 + patches/nextra-theme-docs+2.0.0-beta.29.patch | 1885 +++++++++++++++++ tsconfig.json | 2 +- website/algolia-lockfile.json | 5 - website/next.config.mjs | 4 + website/src/lib/plugins.ts | 39 +- website/src/pages/_meta.json | 10 +- website/src/pages/v3/_meta.json | 10 + website/src/pages/v3/composing-envelop.mdx | 37 + website/src/pages/v3/core.mdx | 178 ++ website/src/pages/v3/getting-started.mdx | 168 ++ website/src/pages/v3/guides/_meta.json | 10 + .../adding-a-graphql-response-cache.mdx | 341 +++ .../adding-authentication-with-auth0.mdx | 292 +++ .../v3/guides/integrating-with-databases.mdx | 215 ++ .../v3/guides/migrating-from-v2-to-v3.mdx | 153 ++ .../v3/guides/monitoring-and-tracing.mdx | 89 + ...ubscription-data-loader-caching-issues.mdx | 70 + .../v3/guides/securing-your-graphql-api.mdx | 416 ++++ ...using-graphql-features-from-the-future.mdx | 301 +++ website/src/pages/v3/index.mdx | 58 + website/src/pages/v3/integrations.mdx | 37 + website/src/pages/v3/plugins/_meta.json | 7 + .../src/pages/v3/plugins/custom-plugin.mdx | 91 + website/src/pages/v3/plugins/index.mdx | 27 + website/src/pages/v3/plugins/lifecycle.mdx | 241 +++ website/src/pages/v3/plugins/testing.mdx | 135 ++ website/src/pages/v3/plugins/typescript.mdx | 51 + website/src/pages/v3/tracing.mdx | 41 + yarn.lock | 150 +- 183 files changed, 6797 insertions(+), 1607 deletions(-) create mode 100644 .changeset/bright-fishes-learn.md create mode 100644 .changeset/curvy-bottles-repeat.md create mode 100644 .changeset/curvy-cheetahs-bathe.md create mode 100644 .changeset/dirty-birds-rush.md create mode 100644 .changeset/dry-donuts-guess.md create mode 100644 .changeset/grumpy-windows-behave.md create mode 100644 .changeset/late-students-float.md create mode 100644 .changeset/light-bees-dream.md create mode 100644 .changeset/light-tomatoes-enjoy.md create mode 100644 .changeset/lovely-clocks-type.md create mode 100644 .changeset/nervous-seas-own.md create mode 100644 .changeset/quiet-mice-jam.md create mode 100644 .changeset/rude-cats-peel.md create mode 100644 .changeset/silent-impalas-retire.md create mode 100644 .changeset/warm-bulldogs-do.md delete mode 100644 packages/core/docs/use-async-schema.md create mode 100644 packages/core/docs/use-engine.md rename packages/core/docs/{use-lazy-loaded-schema.md => use-schema-by-context.md} (68%) delete mode 100644 packages/core/docs/use-timing.md delete mode 100644 packages/core/src/enable-if.ts create mode 100644 packages/core/src/plugins/use-engine.ts delete mode 100644 packages/core/src/plugins/use-timing.ts delete mode 100644 packages/core/src/traced-orchestrator.ts delete mode 100644 packages/core/src/traced-schema.ts create mode 100644 packages/core/test/plugins/use-engine.spec.ts delete mode 100644 packages/core/test/utils.spec.ts create mode 100644 packages/plugins/immediate-introspection/.npmignore rename packages/{core/docs/use-immediate-introspection.md => plugins/immediate-introspection/README.md} (65%) create mode 100644 packages/plugins/immediate-introspection/package.json rename packages/{core/src/plugins/use-immediate-introspection.ts => plugins/immediate-introspection/src/index.ts} (100%) rename packages/{core/test/plugins => plugins/immediate-introspection/test}/use-immediate-introspection.spec.ts (94%) create mode 100644 packages/plugins/on-resolve/README.md create mode 100644 packages/plugins/on-resolve/package.json create mode 100644 packages/plugins/on-resolve/src/index.ts create mode 100644 packages/plugins/on-resolve/test/use-on-resolve.spec.ts create mode 100644 patches/nextra-theme-docs+2.0.0-beta.29.patch create mode 100644 website/src/pages/v3/_meta.json create mode 100644 website/src/pages/v3/composing-envelop.mdx create mode 100644 website/src/pages/v3/core.mdx create mode 100644 website/src/pages/v3/getting-started.mdx create mode 100644 website/src/pages/v3/guides/_meta.json create mode 100644 website/src/pages/v3/guides/adding-a-graphql-response-cache.mdx create mode 100644 website/src/pages/v3/guides/adding-authentication-with-auth0.mdx create mode 100644 website/src/pages/v3/guides/integrating-with-databases.mdx create mode 100644 website/src/pages/v3/guides/migrating-from-v2-to-v3.mdx create mode 100644 website/src/pages/v3/guides/monitoring-and-tracing.mdx create mode 100644 website/src/pages/v3/guides/resolving-subscription-data-loader-caching-issues.mdx create mode 100644 website/src/pages/v3/guides/securing-your-graphql-api.mdx create mode 100644 website/src/pages/v3/guides/using-graphql-features-from-the-future.mdx create mode 100644 website/src/pages/v3/index.mdx create mode 100644 website/src/pages/v3/integrations.mdx create mode 100644 website/src/pages/v3/plugins/_meta.json create mode 100644 website/src/pages/v3/plugins/custom-plugin.mdx create mode 100644 website/src/pages/v3/plugins/index.mdx create mode 100644 website/src/pages/v3/plugins/lifecycle.mdx create mode 100644 website/src/pages/v3/plugins/testing.mdx create mode 100644 website/src/pages/v3/plugins/typescript.mdx create mode 100644 website/src/pages/v3/tracing.mdx diff --git a/.changeset/bright-fishes-learn.md b/.changeset/bright-fishes-learn.md new file mode 100644 index 000000000..70de59eef --- /dev/null +++ b/.changeset/bright-fishes-learn.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': major +--- + +## Remove `isIntrospectionQuery` utility diff --git a/.changeset/curvy-bottles-repeat.md b/.changeset/curvy-bottles-repeat.md new file mode 100644 index 000000000..151440079 --- /dev/null +++ b/.changeset/curvy-bottles-repeat.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': major +--- + +Remove async schema loading plugin. This was a mistake from beginning as we cannot asynchronously `validate` and `parse` since with GraphQL.js are synchronous in nature. diff --git a/.changeset/curvy-cheetahs-bathe.md b/.changeset/curvy-cheetahs-bathe.md new file mode 100644 index 000000000..0aa847448 --- /dev/null +++ b/.changeset/curvy-cheetahs-bathe.md @@ -0,0 +1,33 @@ +--- +'@envelop/core': major +'@envelop/on-resolve': major +--- + +## Remove `onResolverCalled` + +We decided to drop onResolverCalled hook and instead [provide a new plugin](https://github.com/n1ru4l/envelop/pull/1500) that will let you hook into this phase. + +```diff +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, Plugin, useEngine } from '@envelop/core' ++ import { useOnResolve } from '@envelop/on-resolve' + +import { onResolverCalled } from './my-resolver' + +function useResolve(): Plugin { + return { +- onResolverCalled: onResolverCalled, ++ onPluginInit: ({ addPlugin }) => { ++ addPlugin(useOnResolve(onResolverCalled)) ++ }, + } +} + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + // ... other plugins ... + useResolve(), + ], +}); +``` diff --git a/.changeset/dirty-birds-rush.md b/.changeset/dirty-birds-rush.md new file mode 100644 index 000000000..ee52d0511 --- /dev/null +++ b/.changeset/dirty-birds-rush.md @@ -0,0 +1,5 @@ +--- +'@envelop/sentry': major +--- + +Default skip reporting `GraphQLError` diff --git a/.changeset/dry-donuts-guess.md b/.changeset/dry-donuts-guess.md new file mode 100644 index 000000000..4e8168252 --- /dev/null +++ b/.changeset/dry-donuts-guess.md @@ -0,0 +1,7 @@ +--- +'@envelop/core': major +--- + +## Drop `useTiming` plugin + +This plugin was dependent on tracing the schema. As we no longer support wrap the schema out of the box we decided to drop this plugin. diff --git a/.changeset/grumpy-windows-behave.md b/.changeset/grumpy-windows-behave.md new file mode 100644 index 000000000..cbf860b0f --- /dev/null +++ b/.changeset/grumpy-windows-behave.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': major +--- + +## Remove `isIntrospectionDocument` utility diff --git a/.changeset/late-students-float.md b/.changeset/late-students-float.md new file mode 100644 index 000000000..1ebfaa685 --- /dev/null +++ b/.changeset/late-students-float.md @@ -0,0 +1,7 @@ +--- +'@envelop/core': major +--- + +## Drop Node v12 support + +Node.js v12 is no longer supported by the Node.js team. https://github.com/nodejs/Release/#end-of-life-releases diff --git a/.changeset/light-bees-dream.md b/.changeset/light-bees-dream.md new file mode 100644 index 000000000..15c8e5081 --- /dev/null +++ b/.changeset/light-bees-dream.md @@ -0,0 +1,7 @@ +--- +'@envelop/core': major +--- + +## Drop `EnvelopError` class + +To keep the core agnostic from a specific implementation we no longer provide the `EnvelopError` class. diff --git a/.changeset/light-tomatoes-enjoy.md b/.changeset/light-tomatoes-enjoy.md new file mode 100644 index 000000000..888e3abbe --- /dev/null +++ b/.changeset/light-tomatoes-enjoy.md @@ -0,0 +1,7 @@ +--- +'@envelop/core': major +--- + +## Remove `useAsyncSchema` plugin + +This was a mistake from beginning as we cannot asynchronously validate and parse since with [graphql](https://github.com/graphql/graphql-js) these functions are synchronous in nature. diff --git a/.changeset/lovely-clocks-type.md b/.changeset/lovely-clocks-type.md new file mode 100644 index 000000000..54ed08da3 --- /dev/null +++ b/.changeset/lovely-clocks-type.md @@ -0,0 +1,19 @@ +--- +'@envelop/core': major +--- + +## Remove `graphql` as a peer dependency + +We have built the new `envelop` to be engine agnostic. `graphql-js` is no longer a peer dependency. Now you can use any spec compliant GraphQL engine with `envelop` and get the benefit of building a plugin system. We have introduced a new plugin that can be used to customize the GraphQL Engine. + +```diff +- import { envelop } from '@envelop/core' ++ import { envelop, useEngine } from '@envelop/core' ++ import { parse, validate, execute, subscribe } from 'graphql'; + +- const getEnveloped = envelop([ ... ]) ++ const getEnveloped = envelop({ plugins: [useEngine({ parse, validate, execute, subscribe })] }) + +``` + +Checkout the [migration guide](https://www.the-guild.dev/graphql/envelop/v3/guides/migrating-from-v2-to-v3) for more details. diff --git a/.changeset/nervous-seas-own.md b/.changeset/nervous-seas-own.md new file mode 100644 index 000000000..d1fbd263f --- /dev/null +++ b/.changeset/nervous-seas-own.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': major +--- + +## Rename `useLazyLoadedSchema` to `useSchemaByContext` since the original name was vert misleading. diff --git a/.changeset/quiet-mice-jam.md b/.changeset/quiet-mice-jam.md new file mode 100644 index 000000000..fe54fc024 --- /dev/null +++ b/.changeset/quiet-mice-jam.md @@ -0,0 +1,27 @@ +--- +'@envelop/core': major +--- + +Remove `enableIf` utility in favor of more type safe way to conditionally enable plugins. It wasn't a great experience to have a utility + +We can easily replace usage like this: + +```diff +- import { envelop, useMaskedErrors, enableIf } from '@envelop/core' ++ import { envelop, useMaskedErrors } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const isProd = process.env.NODE_ENV === 'production' + +const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, + plugins: [ + // This plugin is enabled only in production +- enableIf(isProd, useMaskedErrors()) ++ isProd && useMaskedErrors() + ] +}) +``` diff --git a/.changeset/rude-cats-peel.md b/.changeset/rude-cats-peel.md new file mode 100644 index 000000000..77ab8534a --- /dev/null +++ b/.changeset/rude-cats-peel.md @@ -0,0 +1,10 @@ +--- +'@envelop/core': major +--- + +Remove `handleValidationErrors` and `handleParseErrors` options from `useMaskedErrors`. + +> ONLY masking validation errors OR ONLY disabling introspection errors does not make sense, as both can be abused for reverse-engineering the GraphQL schema (see https://github.com/nikitastupin/clairvoyance for reverse-engineering the schema based on validation error suggestions). +> https://github.com/n1ru4l/envelop/issues/1482#issue-1340015060 + +Rename `formatError` function option to `maskError` diff --git a/.changeset/silent-impalas-retire.md b/.changeset/silent-impalas-retire.md new file mode 100644 index 000000000..303ae2e14 --- /dev/null +++ b/.changeset/silent-impalas-retire.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': minor +--- + +respond to context, parse and validate errors in `useErrorHandler` plugin diff --git a/.changeset/warm-bulldogs-do.md b/.changeset/warm-bulldogs-do.md new file mode 100644 index 000000000..531f1348c --- /dev/null +++ b/.changeset/warm-bulldogs-do.md @@ -0,0 +1,7 @@ +--- +'@envelop/core': major +--- + +## Removed orchestrator tracing + +`GraphQLSchema` was wrapped to provide resolvers/fields tracing from the schema. Issue with this approach was it was very specific to the underlying engine's implementation. With the new version we no longer want to depend to a specific implementation. Now users can wrap their schemas and add tracing themselves. diff --git a/.eslintrc.json b/.eslintrc.json index 4c797a04e..c09453752 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,13 @@ { "parser": "@typescript-eslint/parser", - "extends": ["eslint:recommended", "standard", "prettier", "plugin:@typescript-eslint/recommended"], - "plugins": ["@typescript-eslint", "unicorn"], + "extends": [ + "eslint:recommended", + "standard", + "prettier", + "plugin:@typescript-eslint/recommended", + "plugin:package-json/recommended" + ], + "plugins": ["@typescript-eslint", "unicorn", "package-json"], "rules": { "unicorn/filename-case": "error", "no-lonely-if": "error", @@ -9,9 +15,20 @@ "no-empty": "off", "no-console": "error", "no-prototype-builtins": "off", - "prefer-arrow-callback": ["error", { "allowNamedFunctions": true }], + "prefer-arrow-callback": [ + "error", + { + "allowNamedFunctions": true + } + ], "no-useless-constructor": "off", - "@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }], + "no-use-before-define": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "args": "none" + } + ], "@typescript-eslint/no-use-before-define": "off", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-empty-interface": "off", @@ -26,7 +43,9 @@ "unicorn/no-useless-fallback-in-spread": "error", "import/no-extraneous-dependencies": [ "error", - { "devDependencies": ["**/*.test.ts", "**/*.spec.ts", "**/test/**/*.ts"] } + { + "devDependencies": ["**/*.test.ts", "**/*.spec.ts", "**/test/**/*.ts"] + } ] }, "env": { @@ -43,6 +62,38 @@ "@typescript-eslint/no-unused-vars": "off", "import/no-extraneous-dependencies": "off" } + }, + // Disallow `graphql-js` specific things to get re-introduced in agnostic packages. + { + "files": [ + "packages/core/**", + "packages/types/**", + "packages/plugins/apollo-datasources/**", + "packages/plugins/auth0/**", + "packages/plugins/dataloader/**", + "packages/plugins/preload-assets/**", + "packages/plugins/statsd/**" + ], + "env": { + "jest": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "graphql", + "message": "You chose violence. Try to make it work without using GraphQL.js" + }, + { + "name": "@graphql-tools/*", + "message": "You chose violence. Try to make it work without using `graphql-tools`" + } + ] + } + ] + } } ], "ignorePatterns": ["dist", "node_modules", "dev-test", "website"] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 27efc754c..d50fe456f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,13 +48,38 @@ jobs: run: yarn ts:check unit: - name: unit / ${{matrix.os}} / node v${{matrix.node-version}} / graphql v${{matrix.graphql_version}} + name: Unit Test runs-on: ${{matrix.os}} strategy: matrix: os: [ubuntu-latest] # remove windows to speed up the tests - node-version: [12, 17] - graphql_version: [15, 16] + steps: + - name: Checkout Master + uses: actions/checkout@v2 + - name: Setup env + uses: the-guild-org/shared-config/setup@main + with: + nodeVersion: 18 + - name: Install Dependencies + run: yarn install --ignore-engines && git checkout yarn.lock + - name: Cache Jest + uses: actions/cache@v2 + with: + path: .cache/jest + key: ${{ runner.os }}-${{matrix.node-version}}-${{matrix.graphql_version}}-jest-${{ hashFiles('yarn.lock') }}-${{ hashFiles('patches/*.patch') }} + - name: Test + run: yarn test + env: + CI: true + + core: + name: Core Test / ${{matrix.os}} / node v${{matrix.node-version}} / graphql v${{matrix.graphql_version}} + runs-on: ${{matrix.os}} + strategy: + matrix: + os: [ubuntu-latest] # remove windows to speed up the tests + node-version: [14, 16, 17, 18] + graphql_version: [15, 16, 'npm:@graphql-tools/graphql@0.1.0-alpha-20220815193214-83898018'] steps: - name: Checkout Master uses: actions/checkout@v2 @@ -71,8 +96,8 @@ jobs: with: path: .cache/jest key: ${{ runner.os }}-${{matrix.node-version}}-${{matrix.graphql_version}}-jest-${{ hashFiles('yarn.lock') }}-${{ hashFiles('patches/*.patch') }} - - name: Test - run: yarn test --ci + - name: Test Core + run: yarn test:core --ci env: CI: true diff --git a/examples/apollo-server/index.ts b/examples/apollo-server/index.ts index 5ef3d636e..0deb297fd 100644 --- a/examples/apollo-server/index.ts +++ b/examples/apollo-server/index.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { ApolloServer } from 'apollo-server'; -import { envelop, useSchema, useTiming } from '@envelop/core'; +import { envelop, useSchema } from '@envelop/core'; +import { parse, validate, subscribe, execute } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { ApolloServerPluginLandingPageGraphQLPlayground } from 'apollo-server-core'; @@ -18,7 +19,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useTiming()], + parse, + validate, + subscribe, + execute, + plugins: [useSchema(schema)], }); const server = new ApolloServer({ diff --git a/examples/apollo-server/package.json b/examples/apollo-server/package.json index 48f6af6ca..90e82f157 100644 --- a/examples/apollo-server/package.json +++ b/examples/apollo-server/package.json @@ -9,7 +9,8 @@ "@envelop/core": "*", "apollo-server": "3.5.0", "apollo-server-core": "3.5.0", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/azure-functions/index.ts b/examples/azure-functions/index.ts index 87ff811d9..9b7b0ef44 100644 --- a/examples/azure-functions/index.ts +++ b/examples/azure-functions/index.ts @@ -1,4 +1,5 @@ -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, subscribe, execute } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { AzureFunction, Context, HttpRequest } from '@azure/functions'; import { getGraphQLParameters, processRequest, Response } from 'graphql-helix'; @@ -17,7 +18,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); export const index: AzureFunction = async (context: Context, req: HttpRequest): Promise => { diff --git a/examples/azure-functions/package.json b/examples/azure-functions/package.json index db521ad1e..89532bb99 100644 --- a/examples/azure-functions/package.json +++ b/examples/azure-functions/package.json @@ -8,7 +8,8 @@ "dependencies": { "graphql-helix": "1.8.3", "@envelop/core": "*", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@azure/functions": "1.2.3", diff --git a/examples/cloudflare-workers/index.ts b/examples/cloudflare-workers/index.ts index 41fac195f..bf2becd79 100644 --- a/examples/cloudflare-workers/index.ts +++ b/examples/cloudflare-workers/index.ts @@ -1,4 +1,5 @@ -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { getGraphQLParameters, processRequest, Response } from 'graphql-helix'; import { Router } from 'worktop'; @@ -20,7 +21,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); router.add('POST', '/graphql', async (req, res) => { diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json index 702ff4766..607d107be 100644 --- a/examples/cloudflare-workers/package.json +++ b/examples/cloudflare-workers/package.json @@ -9,7 +9,8 @@ "graphql-helix": "1.8.3", "@envelop/core": "*", "@graphql-tools/schema": "8.5.0", - "worktop": "0.7.0" + "worktop": "0.7.0", + "graphql": "16.6.0" }, "devDependencies": { "@cloudflare/workers-types": "2.2.2", diff --git a/examples/express-graphql/index.ts b/examples/express-graphql/index.ts index 943a3580a..cd33899d6 100644 --- a/examples/express-graphql/index.ts +++ b/examples/express-graphql/index.ts @@ -1,4 +1,5 @@ -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import express from 'express'; import { graphqlHTTP } from 'express-graphql'; @@ -17,7 +18,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const app = express(); diff --git a/examples/express-graphql/package.json b/examples/express-graphql/package.json index 7c14c6c5f..a0787a1a6 100644 --- a/examples/express-graphql/package.json +++ b/examples/express-graphql/package.json @@ -9,7 +9,8 @@ "express": "3.14.0", "express-graphql": "0.12.0", "@envelop/core": "*", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/google-cloud-functions/index.ts b/examples/google-cloud-functions/index.ts index 33fc5c7d2..fad779a9f 100644 --- a/examples/google-cloud-functions/index.ts +++ b/examples/google-cloud-functions/index.ts @@ -1,4 +1,5 @@ -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import * as functions from 'firebase-functions'; import { getGraphQLParameters, processRequest, Response } from 'graphql-helix'; @@ -17,7 +18,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); // https://firebase.google.com/docs/functions/typescript diff --git a/examples/google-cloud-functions/package.json b/examples/google-cloud-functions/package.json index 9cc92dd45..2affb5b9b 100644 --- a/examples/google-cloud-functions/package.json +++ b/examples/google-cloud-functions/package.json @@ -9,7 +9,8 @@ "@envelop/core": "*", "firebase-admin": "9.9.0", "firebase-functions": "3.14.1", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "main": "lib/index.js", "devDependencies": { diff --git a/examples/graphql-helix-auth0/index.ts b/examples/graphql-helix-auth0/index.ts index 43a0b818e..f0940177f 100644 --- a/examples/graphql-helix-auth0/index.ts +++ b/examples/graphql-helix-auth0/index.ts @@ -2,6 +2,7 @@ import fastify from 'fastify'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; import { envelop, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { useAuth0 } from '@envelop/auth0'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -40,6 +41,10 @@ const auth0Config = { }; const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ useSchema(schema), useAuth0({ diff --git a/examples/graphql-helix-auth0/package.json b/examples/graphql-helix-auth0/package.json index fce86e90d..ce79381df 100644 --- a/examples/graphql-helix-auth0/package.json +++ b/examples/graphql-helix-auth0/package.json @@ -10,7 +10,8 @@ "@envelop/core": "*", "@envelop/auth0": "*", "graphql-helix": "1.8.3", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/graphql-helix-defer-stream/index.ts b/examples/graphql-helix-defer-stream/index.ts index ccb20bf76..c071bf455 100644 --- a/examples/graphql-helix-defer-stream/index.ts +++ b/examples/graphql-helix-defer-stream/index.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console */ import fastify from 'fastify'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; +import { envelop, useLogger, useSchema } from '@envelop/core'; import { makeExecutableSchema } from '@graphql-tools/schema'; const sleep = (t = 1000) => new Promise(resolve => setTimeout(resolve, t)); @@ -140,7 +141,11 @@ const graphiQLContent = /* GraphQL */ ` `; const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const app = fastify(); diff --git a/examples/graphql-helix/index.ts b/examples/graphql-helix/index.ts index 20d936390..d64beb126 100644 --- a/examples/graphql-helix/index.ts +++ b/examples/graphql-helix/index.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console */ import fastify from 'fastify'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; const schema = makeExecutableSchema({ @@ -18,7 +19,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const app = fastify(); diff --git a/examples/graphql-helix/package.json b/examples/graphql-helix/package.json index e73837b22..b3c4d0bf3 100644 --- a/examples/graphql-helix/package.json +++ b/examples/graphql-helix/package.json @@ -9,7 +9,8 @@ "fastify": "3.14.0", "@envelop/core": "*", "graphql-helix": "1.8.3", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/graphql-socket.io/index.ts b/examples/graphql-socket.io/index.ts index a16765350..57b4e6969 100644 --- a/examples/graphql-socket.io/index.ts +++ b/examples/graphql-socket.io/index.ts @@ -1,7 +1,8 @@ import { Server } from 'socket.io'; import * as http from 'http'; import { registerSocketIOGraphQLServer } from '@n1ru4l/socket-io-graphql-server'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; const schema = makeExecutableSchema({ @@ -17,9 +18,7 @@ const schema = makeExecutableSchema({ }, }); -const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], -}); +const getEnveloped = envelop({ parse, validate, execute, subscribe, plugins: [useSchema(schema), useLogger()] }); const httpServer = http.createServer(); const socketServer = new Server(httpServer); diff --git a/examples/graphql-socket.io/package.json b/examples/graphql-socket.io/package.json index bcb8db5b5..2e2c4a0f4 100644 --- a/examples/graphql-socket.io/package.json +++ b/examples/graphql-socket.io/package.json @@ -13,7 +13,8 @@ "graphql-ws": "^4.4.2", "ws": "7.4.5", "socket.io": "4.1.2", - "socket.io-client": "4.1.2" + "socket.io-client": "4.1.2", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/graphql-sse/index.ts b/examples/graphql-sse/index.ts index ae9f95fde..0a0090724 100644 --- a/examples/graphql-sse/index.ts +++ b/examples/graphql-sse/index.ts @@ -1,5 +1,6 @@ import http from 'http'; -import { envelop, useSchema, useLogger, useTiming } from '@envelop/core'; +import { envelop, useSchema, useLogger } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { createHandler } from 'graphql-sse'; @@ -30,7 +31,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const handler = createHandler({ diff --git a/examples/graphql-sse/package.json b/examples/graphql-sse/package.json index da4933b4c..4e86e7262 100644 --- a/examples/graphql-sse/package.json +++ b/examples/graphql-sse/package.json @@ -8,7 +8,8 @@ "dependencies": { "@envelop/core": "*", "@graphql-tools/schema": "8.5.0", - "graphql-sse": "^1.0.1" + "graphql-sse": "^1.0.1", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/graphql-ws/index.ts b/examples/graphql-ws/index.ts index 87020b79c..2cf47ad19 100644 --- a/examples/graphql-ws/index.ts +++ b/examples/graphql-ws/index.ts @@ -1,5 +1,6 @@ -import { envelop, useSchema, useLogger, useTiming } from '@envelop/core'; +import { envelop, useSchema, useLogger } from '@envelop/core'; import { makeExecutableSchema } from '@graphql-tools/schema'; +import { parse, validate, execute, subscribe } from 'graphql'; import ws from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; @@ -30,7 +31,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); useServer( diff --git a/examples/graphql-ws/package.json b/examples/graphql-ws/package.json index 65e0ac8c2..66f5c1d33 100644 --- a/examples/graphql-ws/package.json +++ b/examples/graphql-ws/package.json @@ -9,7 +9,8 @@ "@envelop/core": "*", "@graphql-tools/schema": "8.5.0", "graphql-ws": "^4.4.2", - "ws": "7.4.5" + "ws": "7.4.5", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/lambda-aws/index.ts b/examples/lambda-aws/index.ts index 4d86131d6..ae790dc82 100644 --- a/examples/lambda-aws/index.ts +++ b/examples/lambda-aws/index.ts @@ -1,5 +1,6 @@ -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; import { makeExecutableSchema } from '@graphql-tools/schema'; +import { parse, validate, execute, subscribe } from 'graphql'; import { APIGatewayProxyHandler } from 'aws-lambda'; import { getGraphQLParameters, processRequest, Response } from 'graphql-helix'; @@ -17,7 +18,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); export const lambdaHandler: APIGatewayProxyHandler = async event => { diff --git a/examples/lambda-aws/package.json b/examples/lambda-aws/package.json index 9a5781a5d..fbbd4a80b 100644 --- a/examples/lambda-aws/package.json +++ b/examples/lambda-aws/package.json @@ -9,7 +9,8 @@ "graphql-helix": "1.8.3", "@envelop/core": "*", "@graphql-tools/schema": "8.5.0", - "aws-sdk": "2.918.0" + "aws-sdk": "2.918.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/aws-lambda": "8.10.76", diff --git a/examples/nexus/index.ts b/examples/nexus/index.ts index 36ffe618f..5b68b07c2 100644 --- a/examples/nexus/index.ts +++ b/examples/nexus/index.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console */ import 'reflect-metadata'; import fastify from 'fastify'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; import { arg, enumType, intArg, interfaceType, makeSchema, objectType, queryType, stringArg, list } from 'nexus'; @@ -51,7 +52,11 @@ const schema = makeSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const app = fastify(); diff --git a/examples/simple-http/index.ts b/examples/simple-http/index.ts index cdeed847d..fae397fc3 100644 --- a/examples/simple-http/index.ts +++ b/examples/simple-http/index.ts @@ -1,5 +1,6 @@ import { createServer } from 'http'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; const schema = makeExecutableSchema({ @@ -16,7 +17,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const server = createServer((req, res) => { diff --git a/examples/simple-http/package.json b/examples/simple-http/package.json index 61e4919a9..c7e674663 100644 --- a/examples/simple-http/package.json +++ b/examples/simple-http/package.json @@ -7,7 +7,8 @@ "license": "MIT", "dependencies": { "@envelop/core": "*", - "@graphql-tools/schema": "8.5.0" + "@graphql-tools/schema": "8.5.0", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.6.1", diff --git a/examples/typegraphql/index.ts b/examples/typegraphql/index.ts index 1e184f620..b566ed8ee 100644 --- a/examples/typegraphql/index.ts +++ b/examples/typegraphql/index.ts @@ -1,8 +1,9 @@ /* eslint-disable */ import 'reflect-metadata'; import fastify from 'fastify'; -import { envelop, useLogger, useAsyncSchema, useTiming } from '@envelop/core'; -import { Field, ObjectType, buildSchema, ID, Resolver, Query, Arg } from 'type-graphql'; +import { envelop, useLogger, useSchema } from '@envelop/core'; +import { parse, validate, execute, subscribe } from 'graphql'; +import { Field, ObjectType, buildSchemaSync, ID, Resolver, Query, Arg } from 'type-graphql'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; @ObjectType() @@ -40,16 +41,18 @@ class RecipeResolver { } } -// You can also use `buildSchemaSync` and `useSchema` plugin const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ - useAsyncSchema( - buildSchema({ + useSchema( + buildSchemaSync({ resolvers: [RecipeResolver], }) ), useLogger(), - useTiming(), ], }); diff --git a/examples/with-esm/package.json b/examples/with-esm/package.json index 179779f7a..d6980c384 100644 --- a/examples/with-esm/package.json +++ b/examples/with-esm/package.json @@ -10,7 +10,8 @@ "@envelop/core": "*", "@graphql-tools/schema": "8.5.0", "fastify": "3.14.0", - "graphql-helix": "1.8.3" + "graphql-helix": "1.8.3", + "graphql": "16.6.0" }, "devDependencies": { "@types/node": "15.12.2", diff --git a/examples/with-esm/src/index.ts b/examples/with-esm/src/index.ts index 699cee7e1..b4ab0ab96 100644 --- a/examples/with-esm/src/index.ts +++ b/examples/with-esm/src/index.ts @@ -1,8 +1,9 @@ /* eslint-disable no-console */ import fastify from 'fastify'; import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from 'graphql-helix'; -import { envelop, useLogger, useSchema, useTiming } from '@envelop/core'; +import { envelop, useLogger, useSchema } from '@envelop/core'; import { makeExecutableSchema } from '@graphql-tools/schema'; +import { parse, validate, execute, subscribe } from 'graphql'; const schema = makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -18,7 +19,11 @@ const schema = makeExecutableSchema({ }); const getEnveloped = envelop({ - plugins: [useSchema(schema), useLogger(), useTiming()], + parse, + validate, + execute, + subscribe, + plugins: [useSchema(schema), useLogger()], }); const app = fastify(); diff --git a/package.json b/package.json index 89f345939..f5ba53f55 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build": "bob build", "ts:check": "tsc --noEmit", "test": "jest", + "test:core": "jest ./packages/core --coverage", "test:ci": "jest --coverage", "prerelease": "yarn build", "release": "changeset publish", @@ -45,16 +46,16 @@ "@babel/plugin-proposal-decorators": "7.17.2", "@babel/preset-env": "7.16.11", "@babel/preset-typescript": "7.16.7", - "@changesets/cli": "2.24.2", "@changesets/changelog-github": "0.4.6", + "@changesets/cli": "2.24.2", "@graphql-tools/schema": "8.5.0", "@theguild/prettier-config": "0.0.2", "@types/benchmark": "2.1.1", "@types/jest": "27.4.1", "@types/k6": "0.36.0", "@types/node": "16.11.26", - "@typescript-eslint/eslint-plugin": "5.27.0", - "@typescript-eslint/parser": "5.27.0", + "@typescript-eslint/eslint-plugin": "5.36.2", + "@typescript-eslint/parser": "5.36.2", "apollo-server": "3.5.0", "benchmark": "2.1.4", "bob-the-bundler": "4.0.0", @@ -64,6 +65,7 @@ "eslint-config-standard": "17.0.0", "eslint-plugin-import": "2.26.0", "eslint-plugin-n": "15.2.1", + "eslint-plugin-package-json": "^0.1.4", "eslint-plugin-promise": "6.0.0", "eslint-plugin-unicorn": "43.0.0", "faker": "5.5.3", diff --git a/packages/core/README.md b/packages/core/README.md index cc86e2f19..37a8132f1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -5,11 +5,10 @@ This is the core package for Envelop. You can find a complete documentation here ### Built-in plugins - [`useSchema`](./docs/use-schema.md) -- [`useAsyncSchema`](./docs/use-async-schema.md) - [`useLazyLoadedSchema`](./docs/use-lazy-loaded-schema.md) - [`useErrorHandler`](./docs/use-error-handler.md) - [`useExtendContext`](./docs/use-extend-context.md) - [`useLogger`](./docs/use-logger.md) - [`useMaskedErrors`](./docs/use-masked-errors.md) - [`usePayloadFormatter`](./docs/use-payload-formatter.md) -- [`useTiming`](./docs/use-timing.md) +- [`useEngine`](./docs/use-engine.md) diff --git a/packages/core/docs/use-async-schema.md b/packages/core/docs/use-async-schema.md deleted file mode 100644 index e4754c65d..000000000 --- a/packages/core/docs/use-async-schema.md +++ /dev/null @@ -1,19 +0,0 @@ -#### `useAsyncSchema` - -This plugin is the simplest plugin for specifying your GraphQL schema. You can specify a schema created from any tool that emits `Promise` object. - -```ts -import { envelop, useAsyncSchema } from '@envelop/core' -import { buildSchema } from 'graphql' - -const getSchema = async (): Promise => { - // return schema when it's ready -} - -const getEnveloped = envelop({ - plugins: [ - useAsyncSchema(getSchema()) - // ... other plugins ... - ] -}) -``` diff --git a/packages/core/docs/use-engine.md b/packages/core/docs/use-engine.md new file mode 100644 index 000000000..1840a6a24 --- /dev/null +++ b/packages/core/docs/use-engine.md @@ -0,0 +1,19 @@ +#### `useEngine` + +This plugin can be used to customize the GraphQL Engine. + +```ts +import { envelop, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ + parse, + validate, + execute, + subscribe + }) + ] +}) +``` diff --git a/packages/core/docs/use-error-handler.md b/packages/core/docs/use-error-handler.md index a6b16d4b9..060e24953 100644 --- a/packages/core/docs/use-error-handler.md +++ b/packages/core/docs/use-error-handler.md @@ -3,11 +3,12 @@ This plugin triggers a custom function when execution encounters an error. ```ts -import { envelop, useErrorHandler } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, useEngine, useErrorHandler } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useErrorHandler((errors, args) => { // This callback is called once, containing all GraphQLError emitted during execution phase }) diff --git a/packages/core/docs/use-extend-context.md b/packages/core/docs/use-extend-context.md index 7b160f7ef..9a8137b8f 100644 --- a/packages/core/docs/use-extend-context.md +++ b/packages/core/docs/use-extend-context.md @@ -3,11 +3,12 @@ Easily extends the context with custom fields. ```ts -import { envelop, useExtendContext } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, useEngine, useExtendContext } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useExtendContext(async contextSoFar => { return { myCustomField: { diff --git a/packages/core/docs/use-logger.md b/packages/core/docs/use-logger.md index d2893b25e..87a3b6eca 100644 --- a/packages/core/docs/use-logger.md +++ b/packages/core/docs/use-logger.md @@ -3,11 +3,12 @@ Logs parameters and information about the execution phases. You can easily plug your custom logger. ```ts -import { envelop, useLogger } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, useEngine, useLogger } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useLogger({ logFn: (eventName, args) => { // Event could be `execute-start` / `execute-end` / `subscribe-start` / `subscribe-end` diff --git a/packages/core/docs/use-masked-errors.md b/packages/core/docs/use-masked-errors.md index 628b6b16a..4579c386b 100644 --- a/packages/core/docs/use-masked-errors.md +++ b/packages/core/docs/use-masked-errors.md @@ -3,8 +3,8 @@ Prevent unexpected error messages from leaking to the GraphQL clients. ```ts -import { envelop, useSchema, useMaskedErrors, EnvelopError } from '@envelop/core' -import { makeExecutableSchema } from 'graphql' +import { envelop, useSchema, useMaskedErrors, useEngine } from '@envelop/core' +import { makeExecutableSchema, GraphQLError, parse, validate, execute, subscribe } from 'graphql' const schema = makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -17,13 +17,13 @@ const schema = makeExecutableSchema({ resolvers: { Query: { something: () => { - throw new EnvelopError('Error that is propagated to the clients.') + throw new GraphQLError('Error that is propagated to the clients.') }, somethingElse: () => { throw new Error("Unsafe error that will be masked as 'Unexpected Error.'.") }, somethingSpecial: () => { - throw new EnvelopError('The error will have an extensions field.', { + throw new GraphQLError('The error will have an extensions field.', { code: 'ERR_CODE', randomNumber: 123 }) @@ -33,23 +33,34 @@ const schema = makeExecutableSchema({ }) const getEnveloped = envelop({ - plugins: [useSchema(schema), useMaskedErrors()] + plugins: [useEngine({ parse, validate, execute, subscribe }), useSchema(schema), useMaskedErrors()] }) ``` You may customize the default error message `Unexpected error.` with your own `errorMessage`: ```ts +import { envelop, useSchema, useMaskedErrors, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { schema } from './schema' + const getEnveloped = envelop({ - plugins: [useSchema(schema), useMaskedErrors({ errorMessage: 'Something went wrong.' })] + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useSchema(schema), + useMaskedErrors({ errorMessage: 'Something went wrong.' }) + ] }) ``` Or provide a custom formatter when masking the output: ```ts -export const customFormatError: FormatErrorHandler = err => { - if (err.originalError && err.originalError instanceof EnvelopError === false) { +import { isGraphQLError, MaskError, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe, GraphQLError } from 'graphql' + +export const customFormatError: MaskError = err => { + if (isGraphQLError(err)) { return new GraphQLError('Sorry, something went wrong.') } @@ -57,6 +68,10 @@ export const customFormatError: FormatErrorHandler = err => { } const getEnveloped = envelop({ - plugins: [useSchema(schema), useMaskedErrors({ formatError: customFormatError })] + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useSchema(schema), + useMaskedErrors({ maskErrorFn: customFormatError }) + ] }) ``` diff --git a/packages/core/docs/use-payload-formatter.md b/packages/core/docs/use-payload-formatter.md index 46373890c..afaa1dd11 100644 --- a/packages/core/docs/use-payload-formatter.md +++ b/packages/core/docs/use-payload-formatter.md @@ -5,11 +5,12 @@ Allow you to format/modify execution result payload before returning it to your The second argument `executionArgs` provides additional information for your formatter. It consists of contextValue, variableValues, document, operationName, and other properties. ```ts -import { envelop, usePayloadFormatter } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, usePayloadFormatter, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), usePayloadFormatter((result, executionArgs) => { // Return a modified result here, // Or `false`y value to keep it as-is. diff --git a/packages/core/docs/use-lazy-loaded-schema.md b/packages/core/docs/use-schema-by-context.md similarity index 68% rename from packages/core/docs/use-lazy-loaded-schema.md rename to packages/core/docs/use-schema-by-context.md index 783116a67..d7a13890e 100644 --- a/packages/core/docs/use-lazy-loaded-schema.md +++ b/packages/core/docs/use-schema-by-context.md @@ -3,8 +3,8 @@ This plugin is the simplest plugin for specifying your GraphQL schema. You can specify a schema created from any tool that emits `GraphQLSchema` object, and you can choose to load the schema based on the initial context (or the incoming request). ```ts -import { envelop, useLazyLoadedSchema } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, useSchemaByContext, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' async function getSchema({ req }): GraphQLSchema { if (req.isAdmin) { @@ -16,7 +16,8 @@ async function getSchema({ req }): GraphQLSchema { const getEnveloped = envelop({ plugins: [ - useLazyLoadedSchema(getSchema) + useEngine({ parse, validate, execute, subscribe }), + useSchemaByContext(getSchema) // ... other plugins ... ] }) diff --git a/packages/core/docs/use-schema.md b/packages/core/docs/use-schema.md index 10d1b8719..112a19a06 100644 --- a/packages/core/docs/use-schema.md +++ b/packages/core/docs/use-schema.md @@ -3,13 +3,14 @@ This plugin is the simplest plugin for specifying your GraphQL schema. You can specify a schema created from any tool that emits `GraphQLSchema` object. ```ts -import { envelop, useSchema } from '@envelop/core' -import { buildSchema } from 'graphql' +import { envelop, useSchema, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' const mySchema = buildSchema(/* ... */) const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useSchema(mySchema) // ... other plugins ... ] diff --git a/packages/core/docs/use-timing.md b/packages/core/docs/use-timing.md deleted file mode 100644 index 10443e899..000000000 --- a/packages/core/docs/use-timing.md +++ /dev/null @@ -1,24 +0,0 @@ -#### `useTiming` - -Simple time metric collection, for every phase in your execution. You can easily customize the behavior of each timing measurement. By default, the timing is printed to the log, using `console.log`. - -```ts -import { envelop, useTiming } from '@envelop/core' -import { buildSchema } from 'graphql' - -const getEnveloped = envelop({ - plugins: [ - useTiming({ - // All options are optional. By default it just print it to the log. - // ResultTiming is an object built with { ms, ns } (milliseconds and nanoseconds) - onContextBuildingMeasurement: (timing: ResultTiming) => {}, - onExecutionMeasurement: (args: ExecutionArgs, timing: ResultTiming) => {}, - onSubscriptionMeasurement: (args: SubscriptionArgs, timing: ResultTiming) => {}, - onParsingMeasurement: (source: Source | string, timing: ResultTiming) => {}, - onValidationMeasurement: (document: DocumentNode, timing: ResultTiming) => {}, - onResolverMeasurement: (info: GraphQLResolveInfo, timing: ResultTiming) => {} - }) - // ... other plugins ... - ] -}) -``` diff --git a/packages/core/package.json b/packages/core/package.json index 1e82fb121..879044bdc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -65,9 +65,6 @@ "graphql": "16.3.0", "typescript": "4.7.4" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" - }, "buildOptions": { "input": "./src/index.ts" }, diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index d47665016..bb99506ff 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -1,23 +1,38 @@ -import { GetEnvelopedFn, ComposeContext, Plugin, ArbitraryObject } from '@envelop/types'; -import { isPluginEnabled, PluginOrDisabledPlugin } from './enable-if.js'; +import { + GetEnvelopedFn, + ComposeContext, + Plugin, + ArbitraryObject, + ExecuteFunction, + SubscribeFunction, + ParseFunction, + ValidateFunction, + Optional, +} from '@envelop/types'; import { createEnvelopOrchestrator, EnvelopOrchestrator } from './orchestrator.js'; -import { traceOrchestrator } from './traced-orchestrator.js'; -export function envelop[]>(options: { - plugins: Array; - enableInternalTracing?: boolean; -}): GetEnvelopedFn> { - const plugins = options.plugins.filter(isPluginEnabled); - let orchestrator = createEnvelopOrchestrator>(plugins); +type ExcludeFalsy = Exclude[]; + +function notEmpty(value: Optional): value is T { + return value != null; +} - if (options.enableInternalTracing) { - orchestrator = traceOrchestrator(orchestrator); - } +export function envelop>[]>(options: { + plugins: PluginsType; + enableInternalTracing?: boolean; +}): GetEnvelopedFn>> { + const plugins = options.plugins.filter(notEmpty); + const orchestrator = createEnvelopOrchestrator>>({ + plugins, + }); const getEnveloped = ( initialContext: TInitialContext = {} as TInitialContext ) => { - const typedOrchestrator = orchestrator as EnvelopOrchestrator>; + const typedOrchestrator = orchestrator as EnvelopOrchestrator< + TInitialContext, + ComposeContext> + >; typedOrchestrator.init(initialContext); return { @@ -32,5 +47,5 @@ export function envelop[]>(options: { getEnveloped._plugins = plugins; - return getEnveloped as GetEnvelopedFn>; + return getEnveloped as GetEnvelopedFn>>; } diff --git a/packages/core/src/enable-if.ts b/packages/core/src/enable-if.ts deleted file mode 100644 index 5c1607c9c..000000000 --- a/packages/core/src/enable-if.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Plugin } from '@envelop/types'; - -/** - * This enum is used only internally in order to create nominal type for the disabled plugin - */ -enum EnableIfBranded { - DisabledPlugin, -} - -export type PluginOrDisabledPlugin = Plugin | EnableIfBranded.DisabledPlugin; - -export function isPluginEnabled(t: PluginOrDisabledPlugin): t is Plugin { - return t !== EnableIfBranded.DisabledPlugin && t !== null; -} - -/** - * Utility function to enable a plugin. - */ -export function enableIf = {}>( - condition: boolean, - plugin: Plugin | (() => Plugin) -): PluginOrDisabledPlugin { - if (condition) { - return typeof plugin === 'function' ? plugin() : plugin; - } - return EnableIfBranded.DisabledPlugin; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ffbb821aa..131064c4e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,11 +3,9 @@ export * from './create.js'; export * from './utils.js'; export * from './plugins/use-envelop.js'; export * from './plugins/use-logger.js'; -export * from './plugins/use-timing.js'; export * from './plugins/use-schema.js'; export * from './plugins/use-error-handler.js'; export * from './plugins/use-extend-context.js'; export * from './plugins/use-payload-formatter.js'; export * from './plugins/use-masked-errors.js'; -export * from './plugins/use-immediate-introspection.js'; -export * from './enable-if.js'; +export * from './plugins/use-engine.js'; diff --git a/packages/core/src/orchestrator.ts b/packages/core/src/orchestrator.ts index 759e22d91..070600d5b 100644 --- a/packages/core/src/orchestrator.ts +++ b/packages/core/src/orchestrator.ts @@ -10,7 +10,6 @@ import { OnExecuteDoneHook, OnExecuteHook, OnParseHook, - OnResolverCalledHook, OnSubscribeHook, OnValidateHook, Plugin, @@ -28,20 +27,10 @@ import { SubscribeErrorHook, DefaultContext, Maybe, -} from '@envelop/types'; -import { - DocumentNode, - execute, + ParseFunction, + ValidateFunction, ExecutionResult, - GraphQLError, - GraphQLSchema, - parse, - specifiedRules, - subscribe, - validate, - ValidationRule, -} from 'graphql'; -import { prepareTracedSchema, resolversHooksSymbol } from './traced-schema.js'; +} from '@envelop/types'; import { errorAsyncIterator, finalAsyncIterator, @@ -61,28 +50,32 @@ export type EnvelopOrchestrator< execute: ReturnType>['execute']; subscribe: ReturnType>['subscribe']; contextFactory: EnvelopContextFnWrapper>['contextFactory'], PluginsContext>; - getCurrentSchema: () => Maybe; + getCurrentSchema: () => Maybe; +}; + +type EnvelopOrchestratorOptions = { + plugins: Plugin[]; }; -export function createEnvelopOrchestrator( - plugins: Plugin[] -): EnvelopOrchestrator { - let schema: GraphQLSchema | undefined | null = null; +function throwEngineFunctionError(name: string) { + throw Error(`No \`${name}\` function found! Register it using "useEngine" plugin.`); +} + +export function createEnvelopOrchestrator({ + plugins, +}: EnvelopOrchestratorOptions): EnvelopOrchestrator { + let schema: any | undefined | null = null; let initDone = false; - const onResolversHandlers: OnResolverCalledHook[] = []; - for (const plugin of plugins) { - if (plugin.onResolverCalled) { - onResolversHandlers.push(plugin.onResolverCalled); - } - } + + const parse: ParseFunction = () => throwEngineFunctionError('parse'); + const validate: ValidateFunction = () => throwEngineFunctionError('validate'); + const execute: ExecuteFunction = () => throwEngineFunctionError('execute'); + const subscribe: SubscribeFunction = () => throwEngineFunctionError('subscribe'); // Define the initial method for replacing the GraphQL schema, this is needed in order // to allow setting the schema from the onPluginInit callback. We also need to make sure // here not to call the same plugin that initiated the schema switch. - const replaceSchema = (newSchema: GraphQLSchema, ignorePluginIndex = -1) => { - if (onResolversHandlers.length) { - prepareTracedSchema(newSchema); - } + const replaceSchema = (newSchema: any, ignorePluginIndex = -1) => { schema = newSchema; if (initDone) { @@ -152,7 +145,7 @@ export function createEnvelopOrchestrator const customParse: EnvelopContextFnWrapper = beforeCallbacks.parse.length ? initialContext => (source, parseOptions) => { - let result: DocumentNode | Error | null = null; + let result: any | Error | null = null; let parseFn: typeof parse = parse; const context = initialContext; const afterCalls: AfterParseHook[] = []; @@ -211,9 +204,9 @@ export function createEnvelopOrchestrator const customValidate: EnvelopContextFnWrapper = beforeCallbacks.validate.length ? initialContext => (schema, documentAST, rules, typeInfo, validationOptions) => { - let actualRules: undefined | ValidationRule[] = rules ? [...rules] : undefined; + let actualRules: undefined | any[] = rules ? [...rules] : undefined; let validateFn = validate; - let result: null | readonly GraphQLError[] = null; + let result: null | readonly any[] = null; const context = initialContext; const afterCalls: AfterValidateHook[] = []; @@ -234,7 +227,10 @@ export function createEnvelopOrchestrator validateFn, addValidationRule: rule => { if (!actualRules) { - actualRules = [...specifiedRules]; + // Ideally we should provide default validation rules here. + // eslint-disable-next-line no-console + console.warn('No default validation rules provided.'); + actualRules = []; } actualRules.push(rule); @@ -254,6 +250,10 @@ export function createEnvelopOrchestrator result = validateFn(schema, documentAST, actualRules, typeInfo, validationOptions); } + if (!result) { + return; + } + const valid = result.length === 0; for (const afterCb of afterCalls) { @@ -331,7 +331,7 @@ export function createEnvelopOrchestrator } : initialContext => orchestratorCtx => orchestratorCtx ? { ...initialContext, ...orchestratorCtx } : initialContext; - const useCustomSubscribe = beforeCallbacks.subscribe.length || onResolversHandlers.length; + const useCustomSubscribe = beforeCallbacks.subscribe.length; const customSubscribe = useCustomSubscribe ? makeSubscribe(async args => { @@ -372,10 +372,6 @@ export function createEnvelopOrchestrator } } - if (onResolversHandlers.length) { - context[resolversHooksSymbol] = onResolversHandlers; - } - if (result === undefined) { result = await subscribeFn({ ...args, @@ -384,6 +380,9 @@ export function createEnvelopOrchestrator // Can be removed once we drop support for GraphQL.js 15 }); } + if (!result) { + return; + } const onNextHandler: OnSubscribeResultResultOnNextHook[] = []; const onEndHandler: OnSubscribeResultResultOnEndHook[] = []; @@ -445,7 +444,7 @@ export function createEnvelopOrchestrator }) : makeSubscribe(subscribe as any); - const useCustomExecute = beforeCallbacks.execute.length || onResolversHandlers.length; + const useCustomExecute = beforeCallbacks.execute.length; const customExecute = useCustomExecute ? makeExecute(async args => { @@ -490,10 +489,6 @@ export function createEnvelopOrchestrator } } - if (onResolversHandlers.length) { - context[resolversHooksSymbol] = onResolversHandlers; - } - if (result === undefined) { result = (await executeFn({ ...args, diff --git a/packages/core/src/plugins/use-engine.ts b/packages/core/src/plugins/use-engine.ts new file mode 100644 index 000000000..896ace0f7 --- /dev/null +++ b/packages/core/src/plugins/use-engine.ts @@ -0,0 +1,33 @@ +import { ExecuteFunction, ParseFunction, Plugin, SubscribeFunction, ValidateFunction } from '@envelop/types'; + +type UseEngineOptions = { + execute?: ExecuteFunction; + parse?: ParseFunction; + validate?: ValidateFunction; + subscribe?: SubscribeFunction; +}; + +export const useEngine = (engine: UseEngineOptions): Plugin => { + return { + onExecute: ({ setExecuteFn }) => { + if (engine.execute) { + setExecuteFn(engine.execute); + } + }, + onParse: ({ setParseFn }) => { + if (engine.parse) { + setParseFn(engine.parse); + } + }, + onValidate: ({ setValidationFn }) => { + if (engine.validate) { + setValidationFn(engine.validate); + } + }, + onSubscribe: ({ setSubscribeFn }) => { + if (engine.subscribe) { + setSubscribeFn(engine.subscribe); + } + }, + }; +}; diff --git a/packages/core/src/plugins/use-error-handler.ts b/packages/core/src/plugins/use-error-handler.ts index 2c6394814..bbc33b884 100644 --- a/packages/core/src/plugins/use-error-handler.ts +++ b/packages/core/src/plugins/use-error-handler.ts @@ -1,8 +1,16 @@ -import { Plugin, DefaultContext, TypedExecutionArgs } from '@envelop/types'; -import { ExecutionResult, GraphQLError } from 'graphql'; +import { Plugin, DefaultContext, TypedExecutionArgs, ExecutionResult } from '@envelop/types'; import { handleStreamOrSingleExecutionResult } from '../utils.js'; +import { isGraphQLError, SerializableGraphQLErrorLike } from './use-masked-errors.js'; -export type ErrorHandler = (errors: readonly GraphQLError[], context: Readonly) => void; +export type ErrorHandler = ({ + errors, + context, + phase, +}: { + errors: readonly Error[] | readonly SerializableGraphQLErrorLike[]; + context: Readonly; + phase: 'parse' | 'validate' | 'context' | 'execution'; +}) => void; type ErrorHandlerCallback = { result: ExecutionResult; @@ -13,7 +21,7 @@ const makeHandleResult = >(errorHandler: ErrorHandler) => ({ result, args }: ErrorHandlerCallback) => { if (result.errors?.length) { - errorHandler(result.errors, args); + errorHandler({ errors: result.errors, context: args, phase: 'execution' }); } }; @@ -22,6 +30,30 @@ export const useErrorHandler = >( ): Plugin => { const handleResult = makeHandleResult(errorHandler); return { + onParse() { + return function onParseEnd({ result, context }) { + if (result instanceof Error) { + errorHandler({ errors: [result], context, phase: 'parse' }); + } + }; + }, + onValidate() { + return function onValidateEnd({ valid, result, context }) { + if (valid === false && result.length > 0) { + errorHandler({ errors: result as Error[], context, phase: 'validate' }); + } + }; + }, + onPluginInit(context) { + context.registerContextErrorHandler(({ error }) => { + if (isGraphQLError(error)) { + errorHandler({ errors: [error], context, phase: 'context' }); + } else { + // @ts-expect-error its not an error at this point so we just create a new one - can we handle this better? + errorHandler({ errors: [new Error(error)], context, phase: 'context' }); + } + }); + }, onExecute() { return { onExecuteDone(payload) { diff --git a/packages/core/src/plugins/use-masked-errors.ts b/packages/core/src/plugins/use-masked-errors.ts index 2328ae1bc..ebc59ca74 100644 --- a/packages/core/src/plugins/use-masked-errors.ts +++ b/packages/core/src/plugins/use-masked-errors.ts @@ -1,120 +1,95 @@ -import { Plugin } from '@envelop/types'; -import { ExecutionResult, GraphQLError, GraphQLErrorExtensions } from 'graphql'; +import { Plugin, ExecutionResult } from '@envelop/types'; import { handleStreamOrSingleExecutionResult } from '../utils.js'; export const DEFAULT_ERROR_MESSAGE = 'Unexpected error.'; -export class EnvelopError extends GraphQLError { - constructor(message: string, extensions?: GraphQLErrorExtensions) { - super(message, undefined, undefined, undefined, undefined, undefined, extensions); - } +export type MaskError = (error: unknown, message: string) => Error; + +export type SerializableGraphQLErrorLike = Error & { + name: 'GraphQLError'; + toJSON(): { message: string }; + extensions?: Record; +}; + +export function isGraphQLError(error: unknown): error is Error & { originalError?: Error } { + return error instanceof Error && error.name === 'GraphQLError'; } -export type FormatErrorHandler = (error: GraphQLError | unknown, message: string, isDev: boolean) => GraphQLError; +function createSerializableGraphQLError( + message: string, + originalError: unknown, + isDev: boolean +): SerializableGraphQLErrorLike { + const error = new Error(message) as SerializableGraphQLErrorLike; + error.name = 'GraphQLError'; + if (isDev) { + const extensions = + originalError instanceof Error + ? { message: originalError.message, stack: originalError.stack } + : { message: String(originalError) }; -export const formatError: FormatErrorHandler = (err, message, isDev) => { - if (err instanceof GraphQLError) { - if ( - /** execution error */ - (err.originalError && err.originalError instanceof EnvelopError === false) || - /** validate and parse errors */ - (err.originalError === undefined && err instanceof EnvelopError === false) - ) { - return new GraphQLError( - message, - err.nodes, - err.source, - err.positions, - err.path, - undefined, - isDev - ? { - originalError: { - message: err.originalError?.message ?? err.message, - stack: err.originalError?.stack ?? err.stack, - }, - } - : undefined - ); - } - return err; + Object.defineProperty(error, 'extensions', { + get() { + return extensions; + }, + }); } - return new GraphQLError(message); -}; + + Object.defineProperty(error, 'toJSON', { + value() { + return { + message: error.message, + extensions: error.extensions, + }; + }, + }); + + return error as SerializableGraphQLErrorLike; +} + +export const createDefaultMaskError = + (isDev: boolean): MaskError => + (error, message) => { + if (isGraphQLError(error)) { + if (error?.originalError) { + if (isGraphQLError(error.originalError)) { + return error; + } + return createSerializableGraphQLError(message, error, isDev); + } + return error; + } + return createSerializableGraphQLError(message, error, isDev); + }; + +const isDev = globalThis.process?.env?.NODE_ENV === 'development'; + +export const defaultMaskError: MaskError = createDefaultMaskError(isDev); export type UseMaskedErrorsOpts = { - /** The function used for format/identify errors. */ - formatError?: FormatErrorHandler; + /** The function used for identify and mask errors. */ + maskError?: MaskError; /** The error message that shall be used for masked errors. */ errorMessage?: string; - /** - * Additional information that is forwarded to the `formatError` function. - * The default value is `process.env['NODE_ENV'] === 'development'` - */ - isDev?: boolean; - /** - * Whether parse errors should be processed by this plugin. - * In general it is not recommend to set this flag to `true` - * as a `parse` error contains useful information for debugging a GraphQL operation. - * A `parse` error never contains any sensitive information. - * @default false - */ - handleParseErrors?: boolean; - /** - * Whether validation errors should processed by this plugin. - * In general we recommend against setting this flag to `true` - * as a `validate` error contains useful information for debugging a GraphQL operation. - * A `validate` error contains "did you mean x" suggestions that make it easier - * to reverse-introspect a GraphQL schema whose introspection capabilities got disabled. - * Instead of disabling introspection and masking validation errors, using persisted operations - * is a safer solution for avoiding the execution of unwanted/arbitrary operations. - * @default false - */ - handleValidationErrors?: boolean; }; const makeHandleResult = - (format: FormatErrorHandler, message: string, isDev: boolean) => + (maskError: MaskError, message: string) => ({ result, setResult }: { result: ExecutionResult; setResult: (result: ExecutionResult) => void }) => { if (result.errors != null) { - setResult({ ...result, errors: result.errors.map(error => format(error, message, isDev)) }); + setResult({ ...result, errors: result.errors.map(error => maskError(error, message)) }); } }; export const useMaskedErrors = (opts?: UseMaskedErrorsOpts): Plugin => { - const format = opts?.formatError ?? formatError; + const maskError = opts?.maskError ?? defaultMaskError; const message = opts?.errorMessage || DEFAULT_ERROR_MESSAGE; - // eslint-disable-next-line dot-notation - const isDev = opts?.isDev ?? (typeof process !== 'undefined' ? process.env['NODE_ENV'] === 'development' : false); - const handleResult = makeHandleResult(format, message, isDev); + const handleResult = makeHandleResult(maskError, message); return { - onParse: - opts?.handleParseErrors === true - ? function onParse() { - return function onParseEnd({ result, replaceParseResult }) { - if (result instanceof Error) { - replaceParseResult(format(result, message, isDev)); - } - }; - } - : undefined, - onValidate: - opts?.handleValidationErrors === true - ? function onValidate() { - return function onValidateEnd({ valid, result, setResult }) { - if (valid === false) { - setResult(result.map(error => format(error, message, isDev))); - } - }; - } - : undefined, onPluginInit(context) { context.registerContextErrorHandler(({ error, setError }) => { - if (error instanceof GraphQLError === false && error instanceof Error) { - error = new GraphQLError(error.message, undefined, undefined, undefined, undefined, error); - } - setError(format(error, message, isDev)); + setError(maskError(error, message)); }); }, onExecute() { @@ -130,7 +105,7 @@ export const useMaskedErrors = (opts?: UseMaskedErrorsOpts): Plugin => { return handleStreamOrSingleExecutionResult(payload, handleResult); }, onSubscribeError({ error, setError }) { - setError(format(error, message, isDev)); + setError(maskError(error, message)); }, }; }, diff --git a/packages/core/src/plugins/use-payload-formatter.ts b/packages/core/src/plugins/use-payload-formatter.ts index 55ebcb77e..ea2f6b8d9 100644 --- a/packages/core/src/plugins/use-payload-formatter.ts +++ b/packages/core/src/plugins/use-payload-formatter.ts @@ -1,6 +1,5 @@ -import { Plugin, TypedExecutionArgs } from '@envelop/types'; +import { Plugin, TypedExecutionArgs, ExecutionResult } from '@envelop/types'; import { handleStreamOrSingleExecutionResult } from '../utils.js'; -import { ExecutionResult } from 'graphql'; export type FormatterFunction = ( result: ExecutionResult, diff --git a/packages/core/src/plugins/use-schema.ts b/packages/core/src/plugins/use-schema.ts index 61b3259de..f7246567d 100644 --- a/packages/core/src/plugins/use-schema.ts +++ b/packages/core/src/plugins/use-schema.ts @@ -1,7 +1,6 @@ -import { GraphQLSchema } from 'graphql'; import { DefaultContext, Maybe, Plugin } from '@envelop/types'; -export const useSchema = (schema: GraphQLSchema): Plugin => { +export const useSchema = (schema: any): Plugin => { return { onPluginInit({ setSchema }) { setSchema(schema); @@ -9,20 +8,10 @@ export const useSchema = (schema: GraphQLSchema): Plugin => { }; }; -export const useLazyLoadedSchema = (schemaLoader: (context: Maybe) => GraphQLSchema): Plugin => { +export const useSchemaByContext = (schemaLoader: (context: Maybe) => any): Plugin => { return { onEnveloped({ setSchema, context }) { setSchema(schemaLoader(context)); }, }; }; - -export const useAsyncSchema = (schemaPromise: Promise): Plugin => { - return { - onPluginInit({ setSchema }) { - schemaPromise.then(schemaObj => { - setSchema(schemaObj); - }); - }, - }; -}; diff --git a/packages/core/src/plugins/use-timing.ts b/packages/core/src/plugins/use-timing.ts deleted file mode 100644 index 3e8886294..000000000 --- a/packages/core/src/plugins/use-timing.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* eslint-disable no-console */ -import { Plugin } from '@envelop/types'; -import { DocumentNode, ExecutionArgs, getOperationAST, GraphQLResolveInfo, Source, SubscriptionArgs } from 'graphql'; -import { isIntrospectionOperationString, envelopIsIntrospectionSymbol } from '../utils.js'; - -const HR_TO_NS = 1e9; -const NS_TO_MS = 1e6; - -export type ResultTiming = { ms: number; ns: number }; - -export type TimingPluginOptions = { - skipIntrospection?: boolean; - onContextBuildingMeasurement?: (timing: ResultTiming) => void; - onExecutionMeasurement?: (args: ExecutionArgs, timing: ResultTiming) => void; - onSubscriptionMeasurement?: (args: SubscriptionArgs, timing: ResultTiming) => void; - onParsingMeasurement?: (source: Source | string, timing: ResultTiming) => void; - onValidationMeasurement?: (document: DocumentNode, timing: ResultTiming) => void; - onResolverMeasurement?: (info: GraphQLResolveInfo, timing: ResultTiming) => void; -}; - -const DEFAULT_OPTIONS: TimingPluginOptions = { - onExecutionMeasurement: (args, timing) => - console.log(`Operation execution "${args.operationName}" done in ${timing.ms}ms`), - onSubscriptionMeasurement: (args, timing) => - console.log(`Operation subscription "${args.operationName}" done in ${timing.ms}ms`), - onParsingMeasurement: (source: Source | string, timing: ResultTiming) => - console.log(`Parsing "${source}" done in ${timing.ms}ms`), - onValidationMeasurement: (document: DocumentNode, timing: ResultTiming) => - console.log(`Validation "${getOperationAST(document)?.name?.value || '-'}" done in ${timing.ms}ms`), - onResolverMeasurement: (info: GraphQLResolveInfo, timing: ResultTiming) => - console.log(`\tResolver of "${info.parentType.toString()}.${info.fieldName}" done in ${timing.ms}ms`), - onContextBuildingMeasurement: (timing: ResultTiming) => console.log(`Context building done in ${timing.ms}ms`), -}; - -const deltaFrom = (hrtime: [number, number]): { ms: number; ns: number } => { - const delta = process.hrtime(hrtime); - const ns = delta[0] * HR_TO_NS + delta[1]; - - return { - ns, - get ms() { - return ns / NS_TO_MS; - }, - }; -}; - -type InternalPluginContext = { - [envelopIsIntrospectionSymbol]?: true; -}; - -export const useTiming = (rawOptions?: TimingPluginOptions): Plugin => { - const options = { - ...DEFAULT_OPTIONS, - ...rawOptions, - }; - - const result: Plugin = {}; - - if (options.onContextBuildingMeasurement) { - result.onContextBuilding = ({ context }) => { - if (context[envelopIsIntrospectionSymbol]) { - return; - } - - const contextStartTime = process.hrtime(); - - return () => { - options.onContextBuildingMeasurement!(deltaFrom(contextStartTime)); - }; - }; - } - - if (options.onParsingMeasurement) { - result.onParse = ({ params, extendContext }) => { - if (options.skipIntrospection && isIntrospectionOperationString(params.source)) { - extendContext({ - [envelopIsIntrospectionSymbol]: true, - }); - - return; - } - const parseStartTime = process.hrtime(); - - return () => { - options.onParsingMeasurement!(params.source, deltaFrom(parseStartTime)); - }; - }; - } - - if (options.onValidationMeasurement) { - result.onValidate = ({ params, context }) => { - if (context[envelopIsIntrospectionSymbol]) { - return; - } - - const validateStartTime = process.hrtime(); - - return () => { - options.onValidationMeasurement!(params.documentAST, deltaFrom(validateStartTime)); - }; - }; - } - - if (options.onExecutionMeasurement) { - if (options.onResolverMeasurement) { - result.onExecute = ({ args }) => { - if (args.contextValue[envelopIsIntrospectionSymbol]) { - return; - } - - const executeStartTime = process.hrtime(); - - return { - onExecuteDone: () => { - options.onExecutionMeasurement!(args, deltaFrom(executeStartTime)); - }, - }; - }; - - result.onResolverCalled = ({ info }) => { - const resolverStartTime = process.hrtime(); - - return () => { - options.onResolverMeasurement!(info, deltaFrom(resolverStartTime)); - }; - }; - } else { - result.onExecute = ({ args }) => { - if (args.contextValue[envelopIsIntrospectionSymbol]) { - return; - } - - const executeStartTime = process.hrtime(); - - return { - onExecuteDone: () => { - options.onExecutionMeasurement!(args, deltaFrom(executeStartTime)); - }, - }; - }; - } - } - - if (options.onSubscriptionMeasurement) { - if (options.onResolverMeasurement) { - result.onSubscribe = ({ args }) => { - if (args.contextValue[envelopIsIntrospectionSymbol]) { - return; - } - - const subscribeStartTime = process.hrtime(); - - return { - onSubscribeResult: () => { - options.onSubscriptionMeasurement && options.onSubscriptionMeasurement(args, deltaFrom(subscribeStartTime)); - }, - }; - }; - - result.onResolverCalled = ({ info }) => { - const resolverStartTime = process.hrtime(); - - return () => { - options.onResolverMeasurement && options.onResolverMeasurement(info, deltaFrom(resolverStartTime)); - }; - }; - } else { - result.onSubscribe = ({ args }) => { - if (args.contextValue[envelopIsIntrospectionSymbol]) { - return; - } - - const subscribeStartTime = process.hrtime(); - - return { - onSubscribeResult: () => { - options.onSubscriptionMeasurement && options.onSubscriptionMeasurement(args, deltaFrom(subscribeStartTime)); - }, - }; - }; - } - } - - return result; -}; diff --git a/packages/core/src/traced-orchestrator.ts b/packages/core/src/traced-orchestrator.ts deleted file mode 100644 index a3ca1b056..000000000 --- a/packages/core/src/traced-orchestrator.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - DocumentNode, - ExecutionArgs, - GraphQLFieldResolver, - GraphQLSchema, - GraphQLTypeResolver, - SubscriptionArgs, -} from 'graphql'; -import { ArbitraryObject, Maybe } from '@envelop/types'; -import { EnvelopOrchestrator } from './orchestrator.js'; -import { isAsyncIterable } from './utils.js'; - -const getTimestamp = - typeof globalThis !== 'undefined' && globalThis?.performance?.now - ? () => globalThis.performance.now() - : () => Date.now(); - -const measure = () => { - const start = getTimestamp(); - return () => { - const end = getTimestamp(); - return end - start; - }; -}; - -const tracingSymbol = Symbol('envelopTracing'); - -export function traceOrchestrator( - orchestrator: EnvelopOrchestrator -): EnvelopOrchestrator { - const createTracer = (name: string, ctx: Record) => { - const end = measure(); - - return () => { - ctx[tracingSymbol][name] = end(); - }; - }; - - return { - ...orchestrator, - init: (ctx = {} as TInitialContext) => { - ctx![tracingSymbol] = ctx![tracingSymbol] || {}; - const done = createTracer('init', ctx || {}); - - try { - return orchestrator.init(ctx); - } finally { - done(); - } - }, - parse: (ctx = {} as TInitialContext) => { - ctx[tracingSymbol] = ctx[tracingSymbol] || {}; - const actualFn = orchestrator.parse(ctx); - - return (...args) => { - const done = createTracer('parse', ctx); - - try { - return actualFn(...args); - } finally { - done(); - } - }; - }, - validate: (ctx = {} as TInitialContext) => { - ctx[tracingSymbol] = ctx[tracingSymbol] || {}; - const actualFn = orchestrator.validate(ctx); - - return (...args) => { - const done = createTracer('validate', ctx); - - try { - return actualFn(...args); - } finally { - done(); - } - }; - }, - execute: async ( - argsOrSchema: ExecutionArgs | GraphQLSchema, - document?: DocumentNode, - rootValue?: any, - contextValue?: any, - variableValues?: Maybe<{ [key: string]: any }>, - operationName?: Maybe, - fieldResolver?: Maybe>, - typeResolver?: Maybe> - ) => { - const args: ExecutionArgs = - argsOrSchema instanceof GraphQLSchema - ? { - schema: argsOrSchema, - document: document!, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - typeResolver, - } - : argsOrSchema; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore GraphQL.js types contextValue as unknown - const done = createTracer('execute', args.contextValue || {}); - - try { - const result = await orchestrator.execute(args); - done(); - - if (!isAsyncIterable(result)) { - result.extensions = result.extensions || {}; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore GraphQL.js types contextValue as unknown - result.extensions.envelopTracing = args.contextValue[tracingSymbol]; - } else { - // eslint-disable-next-line no-console - console.warn( - `"traceOrchestrator" encountered a AsyncIterator which is not supported yet, so tracing data is not available for the operation.` - ); - } - - return result; - } catch (e) { - done(); - - throw e; - } - }, - subscribe: async ( - argsOrSchema: SubscriptionArgs | GraphQLSchema, - document?: DocumentNode, - rootValue?: any, - contextValue?: any, - variableValues?: Maybe<{ [key: string]: any }>, - operationName?: Maybe, - fieldResolver?: Maybe>, - subscribeFieldResolver?: Maybe> - ) => { - const args: SubscriptionArgs = - argsOrSchema instanceof GraphQLSchema - ? { - schema: argsOrSchema, - document: document!, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - } - : argsOrSchema; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore GraphQL.js types contextValue as unknown - const done = createTracer('subscribe', args.contextValue || {}); - - try { - return await orchestrator.subscribe(args); - } finally { - done(); - } - }, - contextFactory: (ctx = {} as TInitialContext) => { - const actualFn = orchestrator.contextFactory(ctx); - - return async childCtx => { - const done = createTracer('contextFactory', ctx); - - try { - return await actualFn(childCtx); - } finally { - done(); - } - }; - }, - }; -} diff --git a/packages/core/src/traced-schema.ts b/packages/core/src/traced-schema.ts deleted file mode 100644 index 9de218dad..000000000 --- a/packages/core/src/traced-schema.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defaultFieldResolver, GraphQLSchema, isIntrospectionType, isObjectType } from 'graphql'; -import { AfterResolverHook, OnResolverCalledHook, ResolverFn } from '@envelop/types'; - -export const trackedSchemaSymbol = Symbol('TRACKED_SCHEMA'); -export const resolversHooksSymbol = Symbol('RESOLVERS_HOOKS'); - -export function prepareTracedSchema(schema: GraphQLSchema | null | undefined): void { - if (!schema || schema[trackedSchemaSymbol]) { - return; - } - - schema[trackedSchemaSymbol] = true; - const entries = Object.values(schema.getTypeMap()); - - for (const type of entries) { - if (!isIntrospectionType(type) && isObjectType(type)) { - const fields = Object.values(type.getFields()); - - for (const field of fields) { - let resolverFn: ResolverFn = (field.resolve || defaultFieldResolver) as ResolverFn; - - field.resolve = async (root, args, context, info) => { - if (context && context[resolversHooksSymbol]) { - const hooks: OnResolverCalledHook[] = context[resolversHooksSymbol]; - const afterCalls: AfterResolverHook[] = []; - - for (const hook of hooks) { - const afterFn = await hook({ - root, - args, - context, - info, - resolverFn, - replaceResolverFn: newFn => { - resolverFn = newFn as ResolverFn; - }, - }); - afterFn && afterCalls.push(afterFn); - } - - try { - let result = await resolverFn(root, args, context, info); - - for (const afterFn of afterCalls) { - afterFn({ - result, - setResult: newResult => { - result = newResult; - }, - }); - } - - return result; - } catch (e) { - let resultErr = e; - - for (const afterFn of afterCalls) { - afterFn({ - result: resultErr, - setResult: newResult => { - resultErr = newResult; - }, - }); - } - - throw resultErr; - } - } else { - return resolverFn(root, args, context, info); - } - }; - } - } - } -} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index b99bf574a..fe0ebd1bd 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,15 +1,3 @@ -import { - ASTNode, - DocumentNode, - Kind, - OperationDefinitionNode, - visit, - BREAK, - Source, - ExecutionResult, - SubscriptionArgs, - ExecutionArgs, -} from 'graphql'; import { AsyncIterableIteratorOrValue, ExecuteFunction, @@ -21,38 +9,16 @@ import { OnExecuteDoneEventPayload, OnExecuteDoneHookResult, OnExecuteDoneHookResultOnNextHook, + ExecutionArgs, } from '@envelop/types'; export const envelopIsIntrospectionSymbol = Symbol('ENVELOP_IS_INTROSPECTION'); -export function isOperationDefinition(def: ASTNode): def is OperationDefinitionNode { - return def.kind === Kind.OPERATION_DEFINITION; -} - -export function isIntrospectionOperation(operation: OperationDefinitionNode): boolean { - return isIntrospectionDocument({ - kind: Kind.DOCUMENT, - definitions: [operation], - }); -} -export function isIntrospectionDocument(document: DocumentNode): boolean { - let isIntrospectionOperation = false; - visit(document, { - Field: node => { - if (node.name.value === '__schema' || node.name.value === '__type') { - isIntrospectionOperation = true; - return BREAK; - } - }, - }); - return isIntrospectionOperation; -} - -export function isIntrospectionOperationString(operation: string | Source): boolean { +export function isIntrospectionOperationString(operation: string | any): boolean { return (typeof operation === 'string' ? operation : operation.body).indexOf('__schema') !== -1; } -function getSubscribeArgs(args: PolymorphicSubscribeArguments): SubscriptionArgs { +function getSubscribeArgs(args: PolymorphicSubscribeArguments): ExecutionArgs { return args.length === 1 ? args[0] : { @@ -70,10 +36,8 @@ function getSubscribeArgs(args: PolymorphicSubscribeArguments): SubscriptionArgs /** * Utility function for making a subscribe function that handles polymorphic arguments. */ -export const makeSubscribe = ( - subscribeFn: (args: SubscriptionArgs) => PromiseOrValue> -): SubscribeFunction => - ((...polyArgs: PolymorphicSubscribeArguments): PromiseOrValue> => +export const makeSubscribe = (subscribeFn: (args: ExecutionArgs) => any): SubscribeFunction => + ((...polyArgs: PolymorphicSubscribeArguments): PromiseOrValue> => subscribeFn(getSubscribeArgs(polyArgs))) as SubscribeFunction; export function mapAsyncIterator( @@ -142,9 +106,9 @@ function getExecuteArgs(args: PolymorphicExecuteArguments): ExecutionArgs { * Utility function for making a execute function that handles polymorphic arguments. */ export const makeExecute = ( - executeFn: (args: ExecutionArgs) => PromiseOrValue> + executeFn: (args: ExecutionArgs) => PromiseOrValue> ): ExecuteFunction => - ((...polyArgs: PolymorphicExecuteArguments): PromiseOrValue> => + ((...polyArgs: PolymorphicExecuteArguments): PromiseOrValue> => executeFn(getExecuteArgs(polyArgs))) as unknown as ExecuteFunction; /** diff --git a/packages/core/test/context.spec.ts b/packages/core/test/context.spec.ts index 77fae7951..740f6f75c 100644 --- a/packages/core/test/context.spec.ts +++ b/packages/core/test/context.spec.ts @@ -1,4 +1,4 @@ -import { ContextFactoryFn, EnvelopError, useExtendContext } from '@envelop/core'; +import { ContextFactoryFn, useExtendContext } from '@envelop/core'; import { createSpiedPlugin, createTestkit } from '@envelop/testing'; import { schema, query } from './common.js'; @@ -122,7 +122,7 @@ describe('contextFactory', () => { }; const throwingContextFactory: ContextFactoryFn = () => { - throw new EnvelopError('The server was about to step on a turtle'); + throw new Error('The server was about to step on a turtle'); }; const teskit = createTestkit( @@ -155,7 +155,7 @@ describe('contextFactory', () => { test: true, variables: expect.any(Object), }), - error: new EnvelopError('The server was about to step on a turtle'), + error: new Error('The server was about to step on a turtle'), setError: expect.any(Function), }) ); diff --git a/packages/core/test/execute.spec.ts b/packages/core/test/execute.spec.ts index f8ac84084..f0019dbd4 100644 --- a/packages/core/test/execute.spec.ts +++ b/packages/core/test/execute.spec.ts @@ -51,7 +51,6 @@ describe('execute', () => { const teskit = createTestkit([spiedPlugin.plugin], schema); await teskit.execute(query, {}, { test: 1 }); expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledTimes(1); - expect(spiedPlugin.spies.beforeResolver).toHaveBeenCalledTimes(3); expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledWith({ executeFn: expect.any(Function), setExecuteFn: expect.any(Function), @@ -71,7 +70,6 @@ describe('execute', () => { }, }); - expect(spiedPlugin.spies.afterResolver).toHaveBeenCalledTimes(3); expect(spiedPlugin.spies.afterExecute).toHaveBeenCalledTimes(1); expect(spiedPlugin.spies.afterExecute).toHaveBeenCalledWith({ args: expect.any(Object), @@ -210,67 +208,6 @@ describe('execute', () => { }); }); - it('Should allow to register to before and after resolver calls', async () => { - const afterResolver = jest.fn(); - const onResolverCalled = jest.fn(() => afterResolver); - - const teskit = createTestkit( - [ - { - onResolverCalled, - }, - ], - schema - ); - - await teskit.execute(query); - expect(onResolverCalled).toHaveBeenCalledTimes(3); - expect(onResolverCalled).toHaveBeenCalledWith({ - root: {}, - args: {}, - context: expect.any(Object), - info: expect.objectContaining({ - fieldName: 'me', - }), - resolverFn: expect.any(Function), - replaceResolverFn: expect.any(Function), - }); - expect(onResolverCalled).toHaveBeenCalledWith({ - root: { _id: 1, firstName: 'Dotan', lastName: 'Simha' }, - args: {}, - context: expect.any(Object), - info: expect.objectContaining({ - fieldName: 'id', - }), - resolverFn: expect.any(Function), - replaceResolverFn: expect.any(Function), - }); - expect(onResolverCalled).toHaveBeenCalledWith({ - root: { _id: 1, firstName: 'Dotan', lastName: 'Simha' }, - args: {}, - context: expect.any(Object), - info: expect.objectContaining({ - fieldName: 'name', - }), - resolverFn: expect.any(Function), - replaceResolverFn: expect.any(Function), - }); - - expect(afterResolver).toHaveBeenCalledTimes(3); - expect(afterResolver).toHaveBeenCalledWith({ - result: { _id: 1, firstName: 'Dotan', lastName: 'Simha' }, - setResult: expect.any(Function), - }); - expect(afterResolver).toHaveBeenCalledWith({ - result: 1, - setResult: expect.any(Function), - }); - expect(afterResolver).toHaveBeenCalledWith({ - result: 'Dotan Simha', - setResult: expect.any(Function), - }); - }); - it('Should be able to manipulate streams', async () => { const streamExecuteFn = async function* () { for (const value of ['a', 'b', 'c', 'd']) { diff --git a/packages/core/test/extends.spec.ts b/packages/core/test/extends.spec.ts index eebb8099d..77630af1a 100644 --- a/packages/core/test/extends.spec.ts +++ b/packages/core/test/extends.spec.ts @@ -1,5 +1,5 @@ -import { createSpiedPlugin, createTestkit } from '@envelop/testing'; -import { envelop, useExtendContext, useLogger, useSchema } from '../src/index.js'; +import { createSpiedPlugin, createTestkit, useGraphQLJSEngine } from '@envelop/testing'; +import { envelop, useLogger, useSchema } from '../src/index.js'; import { useEnvelop } from '../src/plugins/use-envelop.js'; import { schema, query } from './common.js'; @@ -8,7 +8,7 @@ describe('extending envelops', () => { const spiedPlugin = createSpiedPlugin(); const baseEnvelop = envelop({ - plugins: [useLogger(), spiedPlugin.plugin], + plugins: [useGraphQLJSEngine(), useLogger(), spiedPlugin.plugin], }); const onExecuteChildSpy = jest.fn(); diff --git a/packages/core/test/plugins/use-engine.spec.ts b/packages/core/test/plugins/use-engine.spec.ts new file mode 100644 index 000000000..a482f0418 --- /dev/null +++ b/packages/core/test/plugins/use-engine.spec.ts @@ -0,0 +1,34 @@ +import { createTestkit } from '@envelop/testing'; +import { query, schema, subscriptionOperationString } from '../common.js'; +import { useEngine } from '@envelop/core'; +import { parse, validate } from 'graphql'; + +describe('useEngine', () => { + it('should invoke custom execute', async () => { + const custom = jest.fn(); + const testInstance = createTestkit([useEngine({ execute: custom })], schema); + await testInstance.execute(query); + expect(custom).toHaveBeenCalledTimes(1); + }); + + it('should invoke custom subscribe', async () => { + const custom = jest.fn(); + const testInstance = createTestkit([useEngine({ subscribe: custom })], schema); + await testInstance.execute(subscriptionOperationString); + expect(custom).toHaveBeenCalledTimes(1); + }); + + it('should invoke custom validate', async () => { + const custom = jest.fn(validate); + const testInstance = createTestkit([useEngine({ validate: custom })], schema); + await testInstance.execute(query); + expect(custom).toHaveBeenCalledTimes(1); + }); + + it('should invoke custom parse', async () => { + const custom = jest.fn(parse); + const testInstance = createTestkit([useEngine({ parse: custom })], schema); + await testInstance.execute(query); + expect(custom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/plugins/use-error-handler.spec.ts b/packages/core/test/plugins/use-error-handler.spec.ts index 77b4c73e7..aa731b245 100644 --- a/packages/core/test/plugins/use-error-handler.spec.ts +++ b/packages/core/test/plugins/use-error-handler.spec.ts @@ -1,7 +1,11 @@ import { useErrorHandler } from '../../src/plugins/use-error-handler.js'; import { assertStreamExecutionValue, collectAsyncIteratorValues, createTestkit } from '@envelop/testing'; +import { Plugin } from '@envelop/types'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { Repeater } from '@repeaterjs/repeater'; +import { createGraphQLError } from '@graphql-tools/utils'; +import { schema } from '../common.js'; +import { useExtendContext } from '@envelop/core'; describe('useErrorHandler', () => { it('should invoke error handler when error happens during execution', async () => { @@ -26,16 +30,67 @@ describe('useErrorHandler', () => { const testInstance = createTestkit([useErrorHandler(mockHandler)], schema); await testInstance.execute(`query { foo }`, {}, { foo: 'bar' }); + expect(mockHandler).toHaveBeenCalledWith(expect.objectContaining({ phase: 'execution' })); + }); + + it('should invoke error handler when error happens during parse', async () => { + expect.assertions(2); + const mockHandler = jest.fn(); + const testInstance = createTestkit([useErrorHandler(mockHandler)], schema); + await testInstance.execute(`query { me `, {}); + expect(mockHandler).toHaveBeenCalledTimes(1); expect(mockHandler).toHaveBeenCalledWith( - [testError], expect.objectContaining({ - contextValue: expect.objectContaining({ - foo: 'bar', - }), + phase: 'parse', + }) + ); + }); + + it('should invoke error handler on validation error', async () => { + expect.assertions(2); + const useMyFailingValidator: Plugin = { + onValidate(payload) { + payload.setValidationFn(() => { + return [createGraphQLError('Failure!')]; + }); + }, + }; + const mockHandler = jest.fn(); + const testInstance = createTestkit([useMyFailingValidator, useErrorHandler(mockHandler)], schema); + await testInstance.execute(`query { iDoNotExistsMyGuy }`, {}); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'validate', }) ); }); + it('should invoke error handle for context errors', async () => { + expect.assertions(2); + const mockHandler = jest.fn(); + const testInstance = createTestkit( + [ + useExtendContext((): {} => { + throw new Error('No context for you!'); + }), + useErrorHandler(mockHandler), + ], + schema + ); + + try { + await testInstance.execute(`query { me { name } }`); + } catch { + expect(mockHandler).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'context', + }) + ); + expect(mockHandler).toHaveBeenCalledTimes(1); + } + }); + it('should invoke error handler when error happens during subscription resolver call', async () => { const testError = new Error('Foobar'); @@ -71,11 +126,9 @@ describe('useErrorHandler', () => { await collectAsyncIteratorValues(result); expect(mockHandler).toHaveBeenCalledWith( - [testError], expect.objectContaining({ - contextValue: expect.objectContaining({ - foo: 'bar', - }), + errors: expect.objectContaining([testError]), + phase: 'execution', }) ); }); diff --git a/packages/core/test/plugins/use-masked-errors.spec.ts b/packages/core/test/plugins/use-masked-errors.spec.ts index dd2e9774d..867bf201c 100644 --- a/packages/core/test/plugins/use-masked-errors.spec.ts +++ b/packages/core/test/plugins/use-masked-errors.spec.ts @@ -6,15 +6,15 @@ import { createTestkit, } from '@envelop/testing'; import { - EnvelopError, useMaskedErrors, DEFAULT_ERROR_MESSAGE, - formatError, - FormatErrorHandler, + MaskError, + createDefaultMaskError, } from '../../src/plugins/use-masked-errors.js'; -import { Plugin, useExtendContext } from '@envelop/core'; +import { useExtendContext } from '@envelop/core'; import { useAuth0 } from '../../../plugins/auth0/src/index.js'; import { GraphQLError } from 'graphql'; +import { createGraphQLError } from '@graphql-tools/utils'; describe('useMaskedErrors', () => { const schema = makeExecutableSchema({ @@ -28,9 +28,9 @@ describe('useMaskedErrors', () => { instantError: String streamError: String streamResolveError: String - instantEnvelopError: String - streamEnvelopError: String - streamResolveEnvelopError: String + instantGraphQLError: String + streamGraphQLError: String + streamResolveGraphQLError: String } `, resolvers: { @@ -39,12 +39,14 @@ describe('useMaskedErrors', () => { throw new Error('Secret sauce that should not leak.'); }, secretEnvelop: () => { - throw new EnvelopError('This message goes to all the clients out there!', { foo: 1 }); + throw createGraphQLError('This message goes to all the clients out there!', { extensions: { foo: 1 } }); }, secretWithExtensions: () => { - throw new EnvelopError('This message goes to all the clients out there!', { - code: 'Foo', - message: 'Bar', + throw createGraphQLError('This message goes to all the clients out there!', { + extensions: { + code: 'Foo', + message: 'Bar', + }, }); }, }, @@ -69,31 +71,31 @@ describe('useMaskedErrors', () => { throw new Error('Noop'); }, }, - instantEnvelopError: { + instantGraphQLError: { subscribe: async function () { - throw new EnvelopError('Noop'); + throw createGraphQLError('Noop'); }, resolve: _ => _, }, - streamEnvelopError: { + streamGraphQLError: { subscribe: async function* () { - throw new EnvelopError('Noop'); + throw createGraphQLError('Noop'); }, resolve: _ => _, }, - streamResolveEnvelopError: { + streamResolveGraphQLError: { subscribe: async function* () { yield '1'; }, resolve: _ => { - throw new EnvelopError('Noop'); + throw createGraphQLError('Noop'); }, }, }, }, }); - it('Should mask non EnvelopErrors', async () => { + it('Should mask non GraphQLErrors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); const result = await testInstance.execute(`query { secret }`); assertSingleExecutionValue(result); @@ -114,31 +116,6 @@ describe('useMaskedErrors', () => { expect(error.extensions).toEqual({ foo: 1 }); }); - it('Should include the original error within the error extensions when `isDev` is set to `true`', async () => { - const testInstance = createTestkit([useMaskedErrors({ isDev: true })], schema); - const result = await testInstance.execute(`query { secret }`); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - expect(result.errors).toHaveLength(1); - const [error] = result.errors!; - expect(error.extensions).toEqual({ - originalError: { - message: 'Secret sauce that should not leak.', - stack: expect.stringContaining('Error: Secret sauce that should not leak.'), - }, - }); - }); - - it('Should not include the original error within the error extensions when `isDev` is set to `false`', async () => { - const testInstance = createTestkit([useMaskedErrors({ isDev: false })], schema); - const result = await testInstance.execute(`query { secret }`); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - expect(result.errors).toHaveLength(1); - const [error] = result.errors!; - expect(error.extensions).toEqual({}); - }); - it('Should not mask GraphQL operation syntax errors (of course it does not since we are only hooking in after execute, but just to be sure)', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); const result = await testInstance.execute(`query { idonotexist }`); @@ -149,7 +126,7 @@ describe('useMaskedErrors', () => { expect(error.message).toEqual('Cannot query field "idonotexist" on type "Query".'); }); - it('Should forward extensions from EnvelopError to final GraphQLError in errors array', async () => { + it('Should forward extensions from GraphQLError to final GraphQLError in errors array', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); const result = await testInstance.execute(`query { secretWithExtensions }`); assertSingleExecutionValue(result); @@ -160,6 +137,9 @@ describe('useMaskedErrors', () => { code: 'Foo', message: 'Bar', }); + expect(JSON.stringify(result)).toMatchInlineSnapshot( + `"{\\"errors\\":[{\\"message\\":\\"This message goes to all the clients out there!\\",\\"locations\\":[{\\"line\\":1,\\"column\\":9}],\\"path\\":[\\"secretWithExtensions\\"],\\"extensions\\":{\\"code\\":\\"Foo\\",\\"message\\":\\"Bar\\"}}],\\"data\\":null}"` + ); }); it('Should properly mask context creation errors with a custom error message', async () => { @@ -202,7 +182,7 @@ describe('useMaskedErrors', () => { const testInstance = createTestkit( [ useExtendContext((): {} => { - throw new EnvelopError('No context for you!', { foo: 1 }); + throw createGraphQLError('No context for you!', { extensions: { foo: 1 } }); }), useMaskedErrors(), ], @@ -211,7 +191,7 @@ describe('useMaskedErrors', () => { try { await testInstance.execute(`query { secretWithExtensions }`); } catch (err) { - if (err instanceof EnvelopError) { + if (err instanceof GraphQLError) { expect(err.message).toEqual(`No context for you!`); expect(err.extensions).toEqual({ foo: 1 }); } else { @@ -219,30 +199,7 @@ describe('useMaskedErrors', () => { } } }); - it('Should include the original context error in extensions in dev mode for error thrown during context creation.', async () => { - expect.assertions(3); - const testInstance = createTestkit( - [ - useExtendContext((): {} => { - throw new Error('No context for you!'); - }), - useMaskedErrors({ isDev: true }), - ], - schema - ); - try { - await testInstance.execute(`query { secretWithExtensions }`); - } catch (err: any) { - expect(err).toBeInstanceOf(GraphQLError); - expect(err.message).toEqual('Unexpected error.'); - expect(err.extensions).toEqual({ - originalError: { - message: 'No context for you!', - stack: expect.stringContaining('Error: No context for you!'), - }, - }); - } - }); + it('Should mask subscribe (sync/promise) subscription errors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); const result = await testInstance.execute(`subscription { instantError }`); @@ -268,9 +225,9 @@ describe('useMaskedErrors', () => { `); }); - it('Should not mask subscribe (sync/promise) subscription envelop errors', async () => { + it('Should not mask subscribe (sync/promise) subscription GraphQL errors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); - const result = await testInstance.execute(`subscription { instantEnvelopError }`); + const result = await testInstance.execute(`subscription { instantGraphQLError }`); assertSingleExecutionValue(result); expect(result.errors).toBeDefined(); expect(result.errors).toMatchInlineSnapshot(` @@ -309,7 +266,7 @@ describe('useMaskedErrors', () => { it('Should not mask subscribe (AsyncIterable) subscription envelop errors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); - const resultStream = await testInstance.execute(`subscription { streamEnvelopError }`); + const resultStream = await testInstance.execute(`subscription { streamGraphQLError }`); assertStreamExecutionValue(resultStream); try { await collectAsyncIteratorValues(resultStream); @@ -330,6 +287,7 @@ describe('useMaskedErrors', () => { const [error] = result.errors!; expect(error.message).toEqual(DEFAULT_ERROR_MESSAGE); }); + it('Should mask resolve subscription errors with a custom error message', async () => { const testInstance = createTestkit( [useMaskedErrors({ errorMessage: 'Custom resolve subscription errors.' })], @@ -340,17 +298,14 @@ describe('useMaskedErrors', () => { const allResults = await collectAsyncIteratorValues(resultStream); expect(allResults).toHaveLength(1); const [result] = allResults; - expect(result.errors).toBeDefined(); - expect(result.errors).toMatchInlineSnapshot(` - Array [ - [GraphQLError: Custom resolve subscription errors.], - ] - `); + expect(JSON.stringify(result)).toMatchInlineSnapshot( + `"{\\"errors\\":[{\\"message\\":\\"Custom resolve subscription errors.\\"}],\\"data\\":{\\"streamResolveError\\":null}}"` + ); }); it('Should not mask resolve subscription envelop errors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); - const resultStream = await testInstance.execute(`subscription { streamResolveEnvelopError }`); + const resultStream = await testInstance.execute(`subscription { streamResolveGraphQLError }`); assertStreamExecutionValue(resultStream); const allResults = await collectAsyncIteratorValues(resultStream); expect(allResults).toHaveLength(1); @@ -377,13 +332,13 @@ describe('useMaskedErrors', () => { try { await testInstance.execute(`query { secret }`, {}, { request: { headers: { authorization: 'Something' } } }); } catch (err) { - expect(err).toMatchInlineSnapshot(`[GraphQLError: Invalid value provided for header "authorization"!]`); + expect(err).toMatchInlineSnapshot(`[GraphQLError: Unexpected error.]`); } try { await testInstance.execute(`query { secret }`, {}, { request: { headers: { authorization: 'Something else' } } }); } catch (err) { - expect(err).toMatchInlineSnapshot(`[GraphQLError: Unsupported token type provided: "Something"!]`); + expect(err).toMatchInlineSnapshot(`[GraphQLError: Unexpected error.]`); } }); @@ -399,14 +354,7 @@ describe('useMaskedErrors', () => { } `); }); - it('should mask parse errors with handleParseErrors option', async () => { - const testInstance = createTestkit([useMaskedErrors({ handleParseErrors: true })], schema); - const result = await testInstance.execute(`query { a `, {}); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - const [error] = result.errors!; - expect(error.message).toEqual(DEFAULT_ERROR_MESSAGE); - }); + it('should not mask validation errors', async () => { const testInstance = createTestkit([useMaskedErrors()], schema); const result = await testInstance.execute(`query { iDoNotExistsMyGuy }`, {}); @@ -419,21 +367,13 @@ describe('useMaskedErrors', () => { } `); }); - it('should mask validation errors with handleValidationErrors option', async () => { - const testInstance = createTestkit([useMaskedErrors({ handleValidationErrors: true })], schema); - const result = await testInstance.execute(`query { iDoNotExistsMyGuy }`, {}); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - const [error] = result.errors!; - expect(error.message).toEqual(DEFAULT_ERROR_MESSAGE); - }); - it('should use custom error formatter for execution errors', async () => { - const customErrorFormatter: FormatErrorHandler = e => + it('should use custom error mask function for execution errors', async () => { + const customErrorMaskFn: MaskError = e => new GraphQLError('Custom error message for ' + e, null, null, null, null, null, { custom: true, }); - const testInstance = createTestkit([useMaskedErrors({ formatError: customErrorFormatter })], schema); + const testInstance = createTestkit([useMaskedErrors({ maskError: customErrorMaskFn })], schema); const result = await testInstance.execute(`query { secret }`); assertSingleExecutionValue(result); expect(result).toMatchInlineSnapshot(` @@ -448,15 +388,18 @@ describe('useMaskedErrors', () => { ], } `); + expect(JSON.stringify(result)).toMatchInlineSnapshot( + `"{\\"errors\\":[{\\"message\\":\\"Custom error message for Secret sauce that should not leak.\\\\n\\\\nGraphQL request:1:9\\\\n1 | query { secret }\\\\n | ^\\",\\"extensions\\":{\\"custom\\":true}}],\\"data\\":null}"` + ); }); - it('should use custom error formatter for subscribe (AsyncIterable) subscription errors', async () => { - const customErrorFormatter: FormatErrorHandler = e => + it('should use custom error mask function for subscribe (AsyncIterable) subscription errors', async () => { + const customErrorMaskFn: MaskError = e => new GraphQLError('Custom error message for ' + e, null, null, null, null, null, { custom: true, }); expect.assertions(2); - const testInstance = createTestkit([useMaskedErrors({ formatError: customErrorFormatter })], schema); + const testInstance = createTestkit([useMaskedErrors({ maskError: customErrorMaskFn })], schema); const resultStream = await testInstance.execute(`subscription { streamError }`); assertStreamExecutionValue(resultStream); try { @@ -467,62 +410,16 @@ describe('useMaskedErrors', () => { } }); - it('should use custom error formatter for parsing errors with handleParseErrors options', async () => { - const customErrorFormatter: FormatErrorHandler = e => - new GraphQLError('Custom error message for ' + e, null, null, null, null, null, { - custom: true, - }); - const useMyFailingParser: Plugin = { - onParse(payload) { - payload.setParseFn(() => { - throw new GraphQLError('My custom error'); - }); - }, - }; - const testInstance = createTestkit( - [useMaskedErrors({ formatError: customErrorFormatter, handleParseErrors: true }), useMyFailingParser], - schema - ); - const result = await testInstance.execute(`query { a `, {}); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - const [error] = result.errors!; - expect(error.message).toEqual('Custom error message for My custom error'); - expect(error.extensions).toEqual({ custom: true }); - }); - it('should use custom error formatter for validation errors with handleValidationErrors option', async () => { - const customErrorFormatter: FormatErrorHandler = e => - new GraphQLError('Custom error message for ' + e, null, null, null, null, null, { - custom: true, - }); - const useMyFailingValidator: Plugin = { - onValidate(payload) { - payload.setValidationFn(() => { - return [new GraphQLError('My custom error')]; - }); - }, - }; - const testInstance = createTestkit( - [useMaskedErrors({ formatError: customErrorFormatter, handleValidationErrors: true }), useMyFailingValidator], - schema - ); - const result = await testInstance.execute(`query { iDoNotExistsMyGuy }`, {}); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - const [error] = result.errors!; - expect(error.message).toEqual('Custom error message for My custom error'); - expect(error.extensions).toEqual({ custom: true }); - }); - it('should use custom error formatter for errors while building the context', async () => { - const customErrorFormatter: FormatErrorHandler = e => + it('should use custom error mask function for errors while building the context', async () => { + const customErrorMaskFn: MaskError = e => new GraphQLError('Custom error message for ' + e, null, null, null, null, null, { custom: true, }); const testInstance = createTestkit( [ - useMaskedErrors({ formatError: customErrorFormatter }), + useMaskedErrors({ maskError: customErrorMaskFn }), useExtendContext(() => { - throw new GraphQLError('Custom error'); + throw createGraphQLError('Custom error'); return {}; }), ], @@ -535,4 +432,52 @@ describe('useMaskedErrors', () => { } expect.assertions(1); }); + + it('should include the original error message stack in the extensions in development mode', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => { + throw new Error("I'm a teapot"); + }, + }, + }, + }); + const testInstance = createTestkit([useMaskedErrors({ maskError: createDefaultMaskError(true) })], schema); + const result = await testInstance.execute(`query { foo }`, {}, {}); + assertSingleExecutionValue(result); + expect(result.errors?.[0].extensions).toEqual({ + message: "I'm a teapot", + stack: expect.stringMatching(/^Error: I'm a teapot/), + }); + }); + + it('should include the original thrown thing in the extensions in development mode', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => { + throw "I'm a teapot"; + }, + }, + }, + }); + const testInstance = createTestkit([useMaskedErrors({ maskError: createDefaultMaskError(true) })], schema); + const result = await testInstance.execute(`query { foo }`, {}, {}); + assertSingleExecutionValue(result); + expect(result.errors?.[0].extensions).toEqual({ + message: 'Unexpected error value: "I\'m a teapot"', + stack: expect.stringMatching(/Unexpected error value: \"I'm a teapot/), + }); + }); }); diff --git a/packages/core/test/utils.spec.ts b/packages/core/test/utils.spec.ts deleted file mode 100644 index 01a393f34..000000000 --- a/packages/core/test/utils.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useLogger, enableIf } from '@envelop/core'; -import { createTestkit, createSpiedPlugin } from '@envelop/testing'; -import { getIntrospectionQuery, parse } from 'graphql'; -import { isIntrospectionDocument } from '../src/utils.js'; -import { query, schema } from './common.js'; - -describe('Utils', () => { - describe('isIntrospectionDocument', () => { - it('Should return false on non-introspection', () => { - const doc = `query test { f }`; - - expect(isIntrospectionDocument(parse(doc))).toBeFalsy(); - }); - const introspectionFields = ['__schema', '__type']; - introspectionFields.forEach(introspectionFieldName => { - it(`Should detect ${introspectionFieldName} original introspection query`, () => { - const doc = getIntrospectionQuery(); - - expect(isIntrospectionDocument(parse(doc))).toBeTruthy(); - }); - - it('Should detect minimal introspection', () => { - const doc = `query { ${introspectionFieldName} { test }}`; - - expect(isIntrospectionDocument(parse(doc))).toBeTruthy(); - }); - - it('Should detect alias tricks', () => { - const doc = `query { test: ${introspectionFieldName} { test }}`; - - expect(isIntrospectionDocument(parse(doc))).toBeTruthy(); - }); - - it('Should detect inline fragment tricks', () => { - const doc = `query { ... on Query { ${introspectionFieldName} { test } } }`; - - expect(isIntrospectionDocument(parse(doc))).toBeTruthy(); - }); - - it('should detect fragment spread tricks', () => { - const doc = `fragment Fragment on Query { ${introspectionFieldName} } query { ...Fragment }`; - - expect(isIntrospectionDocument(parse(doc))).toBeTruthy(); - }); - }); - }); - - describe('enableIf', () => { - it('Should return a plugin', () => { - const plugin = enableIf(true, useLogger()); - expect(plugin).toBeTruthy(); - }); - - it('Should return null', () => { - const plugin = enableIf(false, useLogger()); - expect(plugin).toBeFalsy(); - }); - - it('Should not init plugin', async () => { - const spiedPlugin = createSpiedPlugin(); - const testkit = createTestkit([enableIf(false, spiedPlugin.plugin)], schema); - await testkit.execute(query); - expect(spiedPlugin.spies.beforeExecute).not.toHaveBeenCalled(); - }); - - it('Should init plugin', async () => { - const spiedPlugin = createSpiedPlugin(); - const testkit = createTestkit([enableIf(true, spiedPlugin.plugin)], schema); - await testkit.execute(query); - expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/core/test/validate.spec.ts b/packages/core/test/validate.spec.ts index 6931df84d..b5f0a9907 100644 --- a/packages/core/test/validate.spec.ts +++ b/packages/core/test/validate.spec.ts @@ -108,7 +108,7 @@ describe('validate', () => { [ { onValidate: ({ addValidationRule }) => { - addValidationRule(context => { + addValidationRule((context: any) => { context.reportError(new GraphQLError('Invalid!')); return {}; }); diff --git a/packages/plugins/apollo-datasources/README.md b/packages/plugins/apollo-datasources/README.md index 76ffc8135..936c91b9a 100644 --- a/packages/plugins/apollo-datasources/README.md +++ b/packages/plugins/apollo-datasources/README.md @@ -11,7 +11,8 @@ yarn add @envelop/apollo-datasources ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useApolloDataSources } from '@envelop/apollo-datasources' import { RESTDataSource } from 'apollo-datasource-rest' @@ -36,6 +37,7 @@ class MoviesAPI extends RESTDataSource { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useApolloDataSources({ dataSources() { diff --git a/packages/plugins/apollo-federation/README.md b/packages/plugins/apollo-federation/README.md index a5efe100b..85b9dbd06 100644 --- a/packages/plugins/apollo-federation/README.md +++ b/packages/plugins/apollo-federation/README.md @@ -11,7 +11,8 @@ yarn add @envelop/apollo-federation ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { envelop, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' import { ApolloGateway } from '@apollo/gateway' import { useApolloFederation } from '@envelop/apollo-federation' @@ -30,6 +31,7 @@ await gateway.load() // Then pass it to the plugin configuration const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useApolloFederation({ gateway }) ] diff --git a/packages/plugins/apollo-server-errors/README.md b/packages/plugins/apollo-server-errors/README.md index 51625fd3e..05a3ec159 100644 --- a/packages/plugins/apollo-server-errors/README.md +++ b/packages/plugins/apollo-server-errors/README.md @@ -11,11 +11,13 @@ yarn add @envelop/apollo-server-errors ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useApolloServerErrors } from '@envelop/apollo-server-errors' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useApolloServerErrors({ // All fields are optional, and should match what you pass today to ApolloServer diff --git a/packages/plugins/apollo-server-errors/test/apollo-server-errors.spec.ts b/packages/plugins/apollo-server-errors/test/apollo-server-errors.spec.ts index 5e3c02b6e..a35be5cec 100644 --- a/packages/plugins/apollo-server-errors/test/apollo-server-errors.spec.ts +++ b/packages/plugins/apollo-server-errors/test/apollo-server-errors.spec.ts @@ -3,7 +3,7 @@ import { ApolloServerBase } from 'apollo-server-core'; import { GraphQLSchema } from 'graphql'; import { envelop, useSchema } from '@envelop/core'; import { useApolloServerErrors } from '../src/index.js'; -import { assertSingleExecutionValue } from '@envelop/testing'; +import { assertSingleExecutionValue, useGraphQLJSEngine } from '@envelop/testing'; // Fix compat by mocking broken function // we can remove this once apollo fixed legacy usages of execute(schema, ...args) @@ -15,7 +15,9 @@ jest.mock('../../../../node_modules/apollo-server-core/dist/utils/schemaHash', ( describe('useApolloServerErrors', () => { const executeBoth = async (schema: GraphQLSchema, query: string, debug: boolean) => { const apolloServer = new ApolloServerBase({ schema, debug }); - const envelopRuntime = envelop({ plugins: [useSchema(schema), useApolloServerErrors({ debug })] })({}); + const envelopRuntime = envelop({ + plugins: [useGraphQLJSEngine(), useSchema(schema), useApolloServerErrors({ debug })], + })({}); return { apollo: await apolloServer.executeOperation({ query }), diff --git a/packages/plugins/apollo-tracing/README.md b/packages/plugins/apollo-tracing/README.md index eec2b253e..ef5adba0e 100644 --- a/packages/plugins/apollo-tracing/README.md +++ b/packages/plugins/apollo-tracing/README.md @@ -17,11 +17,13 @@ yarn add @envelop/apollo-tracing ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useApolloTracing } from '@envelop/apollo-tracing' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useApolloTracing() ] diff --git a/packages/plugins/apollo-tracing/package.json b/packages/plugins/apollo-tracing/package.json index a0409ca46..c844e9280 100644 --- a/packages/plugins/apollo-tracing/package.json +++ b/packages/plugins/apollo-tracing/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "apollo-tracing": "^0.15.0", "tslib": "^2.4.0" }, @@ -56,6 +57,7 @@ "typescript": "4.7.4" }, "peerDependencies": { + "@envelop/types": "^2.4.0", "@envelop/core": "^2.6.0", "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, diff --git a/packages/plugins/apollo-tracing/src/index.ts b/packages/plugins/apollo-tracing/src/index.ts index f6d7a3c0a..fd6fcb74b 100644 --- a/packages/plugins/apollo-tracing/src/index.ts +++ b/packages/plugins/apollo-tracing/src/index.ts @@ -1,4 +1,5 @@ import { Plugin, handleStreamOrSingleExecutionResult } from '@envelop/core'; +import { useOnResolve } from '@envelop/on-resolve'; import { TracingFormat } from 'apollo-tracing'; import { GraphQLType, ResponsePath, responsePathAsArray } from 'graphql'; @@ -41,21 +42,24 @@ type TracingContextObject = { export const useApolloTracing = (): Plugin => { return { - onResolverCalled: ({ info, context }) => { - const ctx = context[apolloTracingSymbol] as TracingContextObject; - // Taken from https://github.com/apollographql/apollo-server/blob/main/packages/apollo-tracing/src/index.ts - const resolverCall: ResolverCall = { - path: info.path, - fieldName: info.fieldName, - parentType: info.parentType, - returnType: info.returnType, - startOffset: process.hrtime(ctx.hrtime), - }; - - return () => { - resolverCall.endOffset = process.hrtime(ctx.hrtime); - ctx.resolversTiming.push(resolverCall); - }; + onPluginInit({ addPlugin }) { + addPlugin( + useOnResolve(({ info, context }) => { + const ctx = context[apolloTracingSymbol] as TracingContextObject; + // Taken from https://github.com/apollographql/apollo-server/blob/main/packages/apollo-tracing/src/index.ts + const resolverCall: ResolverCall = { + path: info.path, + fieldName: info.fieldName, + parentType: info.parentType, + returnType: info.returnType, + startOffset: process.hrtime(ctx.hrtime), + }; + return () => { + resolverCall.endOffset = process.hrtime(ctx.hrtime); + ctx.resolversTiming.push(resolverCall); + }; + }) + ); }, onExecute(onExecuteContext) { const ctx: TracingContextObject = { diff --git a/packages/plugins/auth0/README.md b/packages/plugins/auth0/README.md index de98028bb..2af1b6a5c 100644 --- a/packages/plugins/auth0/README.md +++ b/packages/plugins/auth0/README.md @@ -14,11 +14,13 @@ We recommend using the [Adding Authentication with Auth0 guide](https://www.enve 4. Setup Envelop with that plugin: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useAuth0 } from '@envelop/auth0' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useAuth0({ onError: e => {}, // In case of an error, you can override it and customize the error your client will get. diff --git a/packages/plugins/auth0/package.json b/packages/plugins/auth0/package.json index 2e48a3d3d..71a73cce0 100644 --- a/packages/plugins/auth0/package.json +++ b/packages/plugins/auth0/package.json @@ -53,12 +53,10 @@ }, "devDependencies": { "@types/jsonwebtoken": "8.5.8", - "graphql": "16.3.0", "typescript": "4.7.4" }, "peerDependencies": { - "@envelop/core": "^2.6.0", - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + "@envelop/core": "^2.6.0" }, "buildOptions": { "input": "./src/index.ts" diff --git a/packages/plugins/auth0/src/index.ts b/packages/plugins/auth0/src/index.ts index 7c22ec448..d7a1a6e7f 100644 --- a/packages/plugins/auth0/src/index.ts +++ b/packages/plugins/auth0/src/index.ts @@ -1,9 +1,8 @@ /* eslint-disable no-console */ /* eslint-disable dot-notation */ -import { EnvelopError, Plugin } from '@envelop/core'; +import { Plugin } from '@envelop/core'; import * as JwksRsa from 'jwks-rsa'; import jwtPkg, { VerifyOptions, DecodeOptions } from 'jsonwebtoken'; -import { GraphQLError } from 'graphql'; const { decode, verify } = jwtPkg; @@ -23,7 +22,7 @@ export type Auth0PluginOptions = { headerName?: string; }; -export class UnauthenticatedError extends GraphQLError {} +export class UnauthenticatedError extends Error {} export type UserPayload = { sub: string; @@ -71,12 +70,12 @@ export const useAuth0 = (options: TOptions) const split = authHeader.split(' '); if (split.length !== 2) { - throw new EnvelopError(`Invalid value provided for header "${headerName}"!`); + throw new Error(`Invalid value provided for header "${headerName}"!`); } else { const [type, value] = split; if (type !== tokenType) { - throw new EnvelopError(`Unsupported token type provided: "${type}"!`); + throw new Error(`Unsupported token type provided: "${type}"!`); } else { return value; } diff --git a/packages/plugins/dataloader/README.md b/packages/plugins/dataloader/README.md index 5658f9aed..4bf3dc01d 100644 --- a/packages/plugins/dataloader/README.md +++ b/packages/plugins/dataloader/README.md @@ -11,12 +11,14 @@ yarn add dataloader @envelop/dataloader ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import DataLoader from 'dataloader' import { useDataLoader } from '@envelop/dataloader' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useDataLoader('users', context => new DataLoader(keys => myBatchGetUsers(keys))) ] diff --git a/packages/plugins/dataloader/package.json b/packages/plugins/dataloader/package.json index 60d6f614c..b598ee86c 100644 --- a/packages/plugins/dataloader/package.json +++ b/packages/plugins/dataloader/package.json @@ -56,9 +56,8 @@ "typescript": "4.7.4" }, "peerDependencies": { - "@envelop/core": "^2.6.0", "dataloader": "^2.0.0", - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + "@envelop/core": "^2.6.0" }, "buildOptions": { "input": "./src/index.ts" diff --git a/packages/plugins/depth-limit/README.md b/packages/plugins/depth-limit/README.md index 90efe640e..ca846df4d 100644 --- a/packages/plugins/depth-limit/README.md +++ b/packages/plugins/depth-limit/README.md @@ -11,11 +11,13 @@ yarn add @envelop/depth-limit ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useDepthLimit } from '@envelop/depth-limit' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useDepthLimit({ maxDepth: 10 diff --git a/packages/plugins/disable-introspection/README.md b/packages/plugins/disable-introspection/README.md index 0e16f4e4f..dccc4e6b6 100644 --- a/packages/plugins/disable-introspection/README.md +++ b/packages/plugins/disable-introspection/README.md @@ -11,11 +11,12 @@ yarn add @envelop/disable-introspection ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useDisableIntrospection } from '@envelop/disable-introspection' const getEnveloped = envelop({ - plugins: [useDisableIntrospection()] + plugins: [useEngine({ parse, validate, execute, subscribe }), useDisableIntrospection()] }) ``` diff --git a/packages/plugins/execute-subscription-event/README.md b/packages/plugins/execute-subscription-event/README.md index b4849c3e4..ec1192315 100644 --- a/packages/plugins/execute-subscription-event/README.md +++ b/packages/plugins/execute-subscription-event/README.md @@ -7,12 +7,14 @@ Utilities for hooking into the [ExecuteSubscriptionEvent]( createContext()), useContextValuePerExecuteSubscriptionEvent(() => ({ // Existing context is merged with this context partial @@ -29,12 +31,14 @@ const getEnveloped = envelop({ Alternatively, you can also provide a callback that is invoked after each [`ExecuteSubscriptionEvent`]() phase. ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event' import { createContext, createDataLoaders } from './context' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useContext(() => createContext()), useContextValuePerExecuteSubscriptionEvent(({ args }) => ({ onEnd: () => { diff --git a/packages/plugins/extended-validation/README.md b/packages/plugins/extended-validation/README.md index d1b90ca30..3630bdcd1 100644 --- a/packages/plugins/extended-validation/README.md +++ b/packages/plugins/extended-validation/README.md @@ -17,10 +17,13 @@ yarn add @envelop/extended-validation Then, use the plugin with your validation rules: ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useExtendedValidation } from '@envelop/extended-validation' -const getEnveloped = evelop({ +const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useExtendedValidation({ rules: [ /* ... your rules here */ @@ -59,10 +62,13 @@ You can use union inputs either via a the SDL flow, by annotating types and fiel First, make sure to add that rule to your plugin usage: ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useExtendedValidation, OneOfInputObjectsRule } from '@envelop/extended-validation' -const getEnveloped = evelop({ +const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useExtendedValidation({ rules: [OneOfInputObjectsRule] }) diff --git a/packages/plugins/extended-validation/src/plugin.ts b/packages/plugins/extended-validation/src/plugin.ts index a21b6efaa..1dfecb772 100644 --- a/packages/plugins/extended-validation/src/plugin.ts +++ b/packages/plugins/extended-validation/src/plugin.ts @@ -24,13 +24,13 @@ type OnValidationFailedCallback = (params: { setResult: (result: ExecutionResult) => void; }) => void; -export const useExtendedValidation = (options: { +export const useExtendedValidation = = {}>(options: { rules: Array; /** * Callback that is invoked if the extended validation yields any errors. */ onValidationFailed?: OnValidationFailedCallback; -}): Plugin => { +}): Plugin => { let schemaTypeInfo: TypeInfo; function getTypeInfo(): TypeInfo | undefined { @@ -50,6 +50,7 @@ export const useExtendedValidation = (options: { didRun: false, }; extendContext({ + ...context, [symbolExtendedValidationRules]: validationRulesContext, }); } diff --git a/packages/plugins/filter-operation-type/README.md b/packages/plugins/filter-operation-type/README.md index 86b080ac0..3fb5b03ec 100644 --- a/packages/plugins/filter-operation-type/README.md +++ b/packages/plugins/filter-operation-type/README.md @@ -11,10 +11,12 @@ yarn add @envelop/filter-operation-type ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useFilterAllowedOperations } from '@envelop/filter-operation-type' + const getEnveloped = envelop({ // only allow execution of subscription operations - plugins: [useFilterAllowedOperations(['subscription'])] + plugins: [useEngine({ parse, validate, execute, subscribe }), useFilterAllowedOperations(['subscription'])] }) ``` diff --git a/packages/plugins/fragment-arguments/README.md b/packages/plugins/fragment-arguments/README.md index fac953a4b..16af0bb60 100644 --- a/packages/plugins/fragment-arguments/README.md +++ b/packages/plugins/fragment-arguments/README.md @@ -15,11 +15,13 @@ yarn add @envelop/fragment-arguments ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useFragmentArguments } from '@envelop/fragment-arguments' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useFragmentArguments() ] diff --git a/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts b/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts index ab76dece1..f1065ec6b 100644 --- a/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts +++ b/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts @@ -3,6 +3,7 @@ import { oneLine, stripIndent } from 'common-tags'; import { diff } from 'jest-diff'; import { envelop, useSchema } from '@envelop/core'; import { useFragmentArguments } from '../src/index.js'; +import { useGraphQLJSEngine } from '@envelop/testing'; function compareStrings(a: string, b: string): boolean { return a.includes(b); @@ -66,7 +67,9 @@ describe('useFragmentArguments', () => { } `); test('can inline fragment with argument', () => { - const { parse } = envelop({ plugins: [useFragmentArguments(), useSchema(schema)] })({}); + const { parse } = envelop({ + plugins: [useGraphQLJSEngine(), useFragmentArguments(), useSchema(schema)], + })({}); const result = parse(/* GraphQL */ ` fragment TestFragment($c: String) on Query { a(b: $c) diff --git a/packages/plugins/generic-auth/README.md b/packages/plugins/generic-auth/README.md index 52b24e46d..ba0855672 100644 --- a/packages/plugins/generic-auth/README.md +++ b/packages/plugins/generic-auth/README.md @@ -78,7 +78,8 @@ This mode offers complete protection for the entire API. It protects your entire To setup this mode, use the following config: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGenericAuth, ResolveUserFn, ValidateUserFn } from '@envelop/generic-auth' type UserType = { @@ -93,6 +94,7 @@ const validateUser: ValidateUserFn = params => { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGenericAuth({ resolveUserFn, @@ -147,7 +149,8 @@ const GraphQLQueryType = new GraphQLObjectType({ This mode uses the plugin to inject the authenticated user into the `context`, and later you can verify it in your resolvers. ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGenericAuth, ResolveUserFn, ValidateUserFn } from '@envelop/generic-auth' type UserType = { @@ -162,6 +165,7 @@ const validateUser: ValidateUserFn = async params => { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGenericAuth({ resolveUserFn, @@ -192,7 +196,8 @@ const resolvers = { This mode is similar to option #2, but it uses the `@auth` SDL directive or `auth` field extension for protecting specific GraphQL fields. ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGenericAuth, ResolveUserFn, ValidateUserFn } from '@envelop/generic-auth' type UserType = { @@ -207,6 +212,7 @@ const validateUser: ValidateUserFn = params => { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGenericAuth({ resolveUserFn, diff --git a/packages/plugins/graphql-jit/README.md b/packages/plugins/graphql-jit/README.md index 9997b13bd..e6eef37fe 100644 --- a/packages/plugins/graphql-jit/README.md +++ b/packages/plugins/graphql-jit/README.md @@ -11,11 +11,13 @@ yarn add @envelop/graphql-jit ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGraphQlJit } from '@envelop/graphql-jit' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGraphQlJit( { @@ -34,11 +36,13 @@ const getEnveloped = envelop({ If you wish to conditionally use the JIT executor based on the incoming request, you can use `enableIf` config flag and return a `boolean` based on the `ExecutionArgs`: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGraphQlJit } from '@envelop/graphql-jit' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGraphQlJit( { @@ -57,11 +61,13 @@ const getEnveloped = envelop({ You can configure the JIT cache with the following options: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGraphQlJit } from '@envelop/graphql-jit' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGraphQlJit( { diff --git a/packages/plugins/graphql-middleware/README.md b/packages/plugins/graphql-middleware/README.md index 6b285e41f..76a3c44ec 100644 --- a/packages/plugins/graphql-middleware/README.md +++ b/packages/plugins/graphql-middleware/README.md @@ -15,7 +15,8 @@ yarn add graphql-middleware @envelop/graphql-middleware You can use any type of middleware defined for `graphql-middleware`, here's an example for doing that with [`graphql-shield`](https://github.com/maticzav/graphql-shield): ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useGraphQLMiddleware } from '@envelop/graphql-middleware' import { rule, shield, and, or, not } from 'graphql-shield' @@ -36,6 +37,7 @@ const permissions = shield({ const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useSchema(mySchema), useGraphQLMiddleware([permissions]) diff --git a/packages/plugins/graphql-middleware/src/index.ts b/packages/plugins/graphql-middleware/src/index.ts index ece38586b..a2cc564a2 100644 --- a/packages/plugins/graphql-middleware/src/index.ts +++ b/packages/plugins/graphql-middleware/src/index.ts @@ -8,7 +8,6 @@ export const useGraphQLMiddleware = ): Plugin => { return { onSchemaChange({ schema, replaceSchema }) { - // @ts-expect-error See https://github.com/graphql/graphql-js/pull/3511 - remove this comments once merged if (schema.extensions?.[graphqlMiddlewareAppliedTransformSymbol]) { return; } diff --git a/packages/plugins/graphql-modules/README.md b/packages/plugins/graphql-modules/README.md index 9b1218cdd..584a12a90 100644 --- a/packages/plugins/graphql-modules/README.md +++ b/packages/plugins/graphql-modules/README.md @@ -13,7 +13,8 @@ yarn add @envelop/graphql-modules ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { createApplication } from 'graphql-modules' import { useGraphQLModules } from '@envelop/graphql-modules' @@ -25,6 +26,7 @@ const myApp = createApplication({ const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useGraphQLModules(myApp) ] diff --git a/packages/plugins/immediate-introspection/.npmignore b/packages/plugins/immediate-introspection/.npmignore new file mode 100644 index 000000000..3684decc0 --- /dev/null +++ b/packages/plugins/immediate-introspection/.npmignore @@ -0,0 +1,2 @@ +test +*.png diff --git a/packages/core/docs/use-immediate-introspection.md b/packages/plugins/immediate-introspection/README.md similarity index 65% rename from packages/core/docs/use-immediate-introspection.md rename to packages/plugins/immediate-introspection/README.md index e22252102..38b0223e2 100644 --- a/packages/core/docs/use-immediate-introspection.md +++ b/packages/plugins/immediate-introspection/README.md @@ -1,4 +1,12 @@ -#### `useImmediateIntrospection` +## `@envelop/immediate-introspection` + +## Getting Started + +``` +yarn add @envelop/immediate-introspection +``` + +## Usage Example Context building can be costly and require calling remote services. For simple GraphQL operations that only select introspection fields building a context is not necessary. @@ -6,11 +14,13 @@ For simple GraphQL operations that only select introspection fields building a c The `useImmediateIntrospection` can be used to short circuit any further context building if a GraphQL operation selection set only includes introspection fields within the selection set. ```ts -import { envelop, useImmediateIntrospection } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useImmediateIntrospection, useEngine } from '@envelop/core' import { schema } from './schema' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useSchema(schema), useImmediateIntrospection() // additional plugins @@ -21,12 +31,14 @@ const getEnveloped = envelop({ In case you want to authorize that an user is authenticated before allowing introspection the plugin must be placed in front of the `useImmediateIntrospection()` call. ```ts -import { envelop, useImmediateIntrospection } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useImmediateIntrospection, useEngine } from '@envelop/core' import { schema } from './schema' import { useAuthorization } from './useAuthorization' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useSchema(schema), useAuthorization(), // place this before useImmediateIntrospection() diff --git a/packages/plugins/immediate-introspection/package.json b/packages/plugins/immediate-introspection/package.json new file mode 100644 index 000000000..5feedb43c --- /dev/null +++ b/packages/plugins/immediate-introspection/package.json @@ -0,0 +1,67 @@ +{ + "name": "@envelop/immediate-introspection", + "version": "0.0.0", + "author": "Saihajpreet Singh ", + "license": "MIT", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/n1ru4l/envelop.git", + "directory": "packages/plugins/immediate-introspection" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./*": { + "require": { + "types": "./dist/typings/*.d.cts", + "default": "./dist/cjs/*.js" + }, + "import": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + }, + "default": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "typescript": { + "definition": "dist/typings/index.d.ts" + }, + "dependencies": {}, + "devDependencies": { + "graphql": "16.3.0", + "typescript": "4.7.4" + }, + "peerDependencies": { + "@envelop/core": "^2.5.0", + "@sentry/node": "^6 || ^7", + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "buildOptions": { + "input": "./src/index.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "type": "module" +} diff --git a/packages/core/src/plugins/use-immediate-introspection.ts b/packages/plugins/immediate-introspection/src/index.ts similarity index 100% rename from packages/core/src/plugins/use-immediate-introspection.ts rename to packages/plugins/immediate-introspection/src/index.ts diff --git a/packages/core/test/plugins/use-immediate-introspection.spec.ts b/packages/plugins/immediate-introspection/test/use-immediate-introspection.spec.ts similarity index 94% rename from packages/core/test/plugins/use-immediate-introspection.spec.ts rename to packages/plugins/immediate-introspection/test/use-immediate-introspection.spec.ts index 9c563768c..99ede68cb 100644 --- a/packages/core/test/plugins/use-immediate-introspection.spec.ts +++ b/packages/plugins/immediate-introspection/test/use-immediate-introspection.spec.ts @@ -1,7 +1,7 @@ import { createTestkit } from '@envelop/testing'; -import { useImmediateIntrospection } from '../../src/plugins/use-immediate-introspection.js'; -import { useExtendContext } from '../../src/plugins/use-extend-context.js'; -import { schema } from '../common.js'; +import { useImmediateIntrospection } from '../src/index.js'; +import { useExtendContext } from '@envelop/core'; +import { schema } from '../../../core/test/common.js'; import { getIntrospectionQuery } from 'graphql'; describe('useImmediateIntrospection', () => { diff --git a/packages/plugins/live-query/README.md b/packages/plugins/live-query/README.md index 990cf3975..5cb239f71 100644 --- a/packages/plugins/live-query/README.md +++ b/packages/plugins/live-query/README.md @@ -27,7 +27,8 @@ yarn add @envelop/live-query @n1ru4l/in-memory-live-query-store ### `makeExecutableSchema` from `graphql-tools` ```ts -import { envelop, useSchema, useExtendContext } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useSchema, useExtendContext, useEngine } from '@envelop/core' import { useLiveQuery } from '@envelop/live-query' import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store' import { makeExecutableSchema } from '@graphql-tools/schema' @@ -60,6 +61,7 @@ setInterval(() => { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useSchema(schema), useLiveQuery({ liveQueryStore }), useExtendContext(() => ({ greetings })) diff --git a/packages/plugins/live-query/src/index.ts b/packages/plugins/live-query/src/index.ts index fcafa743d..24bf4617d 100644 --- a/packages/plugins/live-query/src/index.ts +++ b/packages/plugins/live-query/src/index.ts @@ -18,15 +18,11 @@ export const GraphQLLiveDirectiveSDL = print(GraphQLLiveDirectiveAST); export const useLiveQuery = (opts: UseLiveQueryOptions): Plugin => { return { onExecute: ({ executeFn, setExecuteFn }) => { - const execute = opts.liveQueryStore.makeExecute(executeFn); + const execute = opts.liveQueryStore.makeExecute(executeFn as any); if (opts.applyLiveQueryPatchGenerator) { const { applyLiveQueryPatchGenerator } = opts; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore execute typings do not include AsyncIterable return right now setExecuteFn((...args) => applyLiveQueryPatchGenerator(execute(...args))); } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore execute typings do not include AsyncIterable return right now setExecuteFn(execute); } }, diff --git a/packages/plugins/newrelic/README.md b/packages/plugins/newrelic/README.md index d98218ea1..a5ba910ef 100644 --- a/packages/plugins/newrelic/README.md +++ b/packages/plugins/newrelic/README.md @@ -31,11 +31,13 @@ yarn add newrelic @envelop/newrelic ## Basic usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useNewRelic } from '@envelop/newrelic' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useNewRelic({ includeOperationDocument: true, // default `false`. When set to `true`, includes the GraphQL document defining the operations and fragments diff --git a/packages/plugins/newrelic/package.json b/packages/plugins/newrelic/package.json index 541df1537..013a0b0d6 100644 --- a/packages/plugins/newrelic/package.json +++ b/packages/plugins/newrelic/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "tslib": "^2.4.0" }, "devDependencies": { diff --git a/packages/plugins/newrelic/src/index.ts b/packages/plugins/newrelic/src/index.ts index e3ceeb4e1..840512bc4 100644 --- a/packages/plugins/newrelic/src/index.ts +++ b/packages/plugins/newrelic/src/index.ts @@ -1,4 +1,5 @@ -import { Plugin, OnResolverCalledHook, Path, isAsyncIterable, EnvelopError, DefaultContext } from '@envelop/core'; +import { Plugin, Path, isAsyncIterable, DefaultContext } from '@envelop/core'; +import { useOnResolve } from '@envelop/on-resolve'; import { print, FieldNode, Kind, OperationDefinitionNode, ExecutionResult, GraphQLError } from 'graphql'; enum AttributeName { @@ -29,7 +30,7 @@ export type UseNewRelicOptions = { extractOperationName?: (context: DefaultContext) => string | undefined; /** * Indicates whether or not to skip reporting a given error to NewRelic. - * By default, this plugin skips all `EnvelopError` errors and does not report them to NewRelic. + * By default, this plugin skips all `Error` errors and does not report them to NewRelic. */ skipError?: (error: GraphQLError) => boolean; }; @@ -46,13 +47,9 @@ const DEFAULT_OPTIONS: UseNewRelicOptions = { trackResolvers: false, includeResolverArgs: false, rootFieldsNaming: false, - skipError: defaultSkipError, + skipError: () => false, }; -export function defaultSkipError(error: GraphQLError): boolean { - return error.originalError instanceof EnvelopError; -} - export const useNewRelic = (rawOptions?: UseNewRelicOptions): Plugin => { const options: InternalOptions = { ...DEFAULT_OPTIONS, @@ -81,12 +78,60 @@ export const useNewRelic = (rawOptions?: UseNewRelicOptions): Plugin => { }); return { + onPluginInit({ addPlugin }) { + if (options.trackResolvers) { + addPlugin( + useOnResolve(async ({ args: resolversArgs, info }) => { + const instrumentationApi = await instrumentationApi$; + const transactionNameState = instrumentationApi.agent.tracer.getTransaction().nameState; + const delimiter = transactionNameState.delimiter; + + const logger = await logger$; + const { returnType, path, parentType } = info; + const formattedPath = flattenPath(path, delimiter); + const currentSegment = instrumentationApi.getActiveSegment(); + if (!currentSegment) { + logger.trace('No active segment found at resolver call. Not recording resolver (%s).', formattedPath); + return () => {}; + } + + const resolverSegment = instrumentationApi.createSegment( + `resolver${delimiter}${formattedPath}`, + null, + currentSegment + ); + if (!resolverSegment) { + logger.trace('Resolver segment was not created (%s).', formattedPath); + return () => {}; + } + resolverSegment.start(); + resolverSegment.addAttribute(AttributeName.RESOLVER_FIELD_PATH, formattedPath); + resolverSegment.addAttribute(AttributeName.RESOLVER_TYPE_NAME, parentType.toString()); + resolverSegment.addAttribute(AttributeName.RESOLVER_RESULT_TYPE, returnType.toString()); + if (options.includeResolverArgs) { + const rawArgs = resolversArgs || {}; + const resolverArgsToTrack = options.isResolverArgsRegex + ? filterPropertiesByRegex(rawArgs, options.includeResolverArgs as RegExp) + : rawArgs; + resolverSegment.addAttribute(AttributeName.RESOLVER_ARGS, JSON.stringify(resolverArgsToTrack)); + } + return ({ result }) => { + if (options.includeRawResult) { + resolverSegment.addAttribute(AttributeName.RESOLVER_RESULT, JSON.stringify(result)); + } + resolverSegment.end(); + }; + }) + ); + } + }, async onExecute({ args }) { const instrumentationApi = await instrumentationApi$; const transactionNameState = instrumentationApi.agent.tracer.getTransaction().nameState; const spanContext = instrumentationApi.agent.tracer.getSpanContext(); const delimiter = transactionNameState.delimiter; const rootOperation = args.document.definitions.find( + // @ts-expect-error TODO: not sure how we will make it dev friendly definitionNode => definitionNode.kind === Kind.OPERATION_DEFINITION ) as OperationDefinitionNode; const operationType = rootOperation.operation; @@ -128,56 +173,7 @@ export const useNewRelic = (rawOptions?: UseNewRelicOptions): Plugin => { const operationSegment = instrumentationApi.getActiveSegment(); - const onResolverCalled: OnResolverCalledHook | undefined = options.trackResolvers - ? async ({ args: resolversArgs, info }) => { - const logger = await logger$; - const { returnType, path, parentType } = info; - const formattedPath = flattenPath(path, delimiter); - const currentSegment = instrumentationApi.getActiveSegment(); - - if (!currentSegment) { - logger.trace('No active segment found at resolver call. Not recording resolver (%s).', formattedPath); - return () => {}; - } - - const resolverSegment = instrumentationApi.createSegment( - `resolver${delimiter}${formattedPath}`, - null, - operationSegment - ); - - if (!resolverSegment) { - logger.trace('Resolver segment was not created (%s).', formattedPath); - return () => {}; - } - - resolverSegment.start(); - - resolverSegment.addAttribute(AttributeName.RESOLVER_FIELD_PATH, formattedPath); - resolverSegment.addAttribute(AttributeName.RESOLVER_TYPE_NAME, parentType.toString()); - resolverSegment.addAttribute(AttributeName.RESOLVER_RESULT_TYPE, returnType.toString()); - - if (options.includeResolverArgs) { - const rawArgs = resolversArgs || {}; - const resolverArgsToTrack = options.isResolverArgsRegex - ? filterPropertiesByRegex(rawArgs, options.includeResolverArgs as RegExp) - : rawArgs; - - resolverSegment.addAttribute(AttributeName.RESOLVER_ARGS, JSON.stringify(resolverArgsToTrack)); - } - - return ({ result }) => { - if (options.includeRawResult) { - resolverSegment.addAttribute(AttributeName.RESOLVER_RESULT, JSON.stringify(result)); - } - - resolverSegment.end(); - }; - } - : undefined; - return { - onResolverCalled, onExecuteDone({ result }) { const sendResult = (singularResult: ExecutionResult) => { if (singularResult.data && options.includeRawResult) { diff --git a/packages/plugins/on-resolve/README.md b/packages/plugins/on-resolve/README.md new file mode 100644 index 000000000..dd271e1c0 --- /dev/null +++ b/packages/plugins/on-resolve/README.md @@ -0,0 +1,102 @@ +## `@envelop/on-resolve` + +This plugin allows you to hook into resolves of every field in the GraphQL schema. + +Useful for tracing or augmenting resolvers (and their results) with custom logic. + +## Getting Started + +``` +yarn add @envelop/on-resolve +``` + +## Usage Example + +### Custom field resolutions + +```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' +import { useOnResolve } from '@envelop/on-resolve' +import { specialResolver } from './my-resolvers' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + // ... other plugins ... + useOnResolve(async function onResolve({ context, root, args, info, replaceResolver }) { + // replace special field's resolver + if (info.fieldName === 'special') { + replaceResolver(specialResolver) + } + + // replace field's result + if (info.fieldName === 'alwaysHello') { + return ({ setResult }) => { + setResult('hello') + } + } + }) + ] +}) +``` + +### Tracing + +```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine, Plugin } from '@envelop/core' +import { useOnResolve } from '@envelop/on-resolve' + +interface FieldTracingPluginContext { + tracerUrl: string +} + +function useFieldTracing() { + return { + onPluginInit({ addPlugin }) { + addPlugin( + useOnResolve(async function onResolve({ context, root, args, info }) { + await fetch(context.tracerUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + startedResolving: { + ...info, + parent: root, + args + } + }) + }) + + return async () => { + await fetch(context.tracerUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + endedResolving: { + ...info, + parent: root, + args + } + }) + }) + } + }) + ) + } + } +} + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + // ... other plugins ... + useSpecialResolve() + ] +}) +``` diff --git a/packages/plugins/on-resolve/package.json b/packages/plugins/on-resolve/package.json new file mode 100644 index 000000000..1e931c13d --- /dev/null +++ b/packages/plugins/on-resolve/package.json @@ -0,0 +1,66 @@ +{ + "name": "@envelop/on-resolve", + "version": "1.0.0", + "author": "Denis Badurina ", + "license": "MIT", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/n1ru4l/envelop.git", + "directory": "packages/plugins/on-resolve" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./*": { + "require": { + "types": "./dist/typings/*.d.cts", + "default": "./dist/cjs/*.js" + }, + "import": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + }, + "default": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "typescript": { + "definition": "dist/typings/index.d.ts" + }, + "dependencies": {}, + "devDependencies": { + "graphql": "16.3.0", + "typescript": "4.7.4" + }, + "peerDependencies": { + "@envelop/core": "^2.5.0", + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "buildOptions": { + "input": "./src/index.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "type": "module" +} diff --git a/packages/plugins/on-resolve/src/index.ts b/packages/plugins/on-resolve/src/index.ts new file mode 100644 index 000000000..73b2ed997 --- /dev/null +++ b/packages/plugins/on-resolve/src/index.ts @@ -0,0 +1,85 @@ +import { defaultFieldResolver, GraphQLResolveInfo, GraphQLSchema, isIntrospectionType, isObjectType } from 'graphql'; +import { Plugin, PromiseOrValue } from '@envelop/core'; + +export type Resolver = ( + root: unknown, + args: Record, + context: Context, + info: GraphQLResolveInfo +) => PromiseOrValue; + +export type AfterResolver = (options: { + result: unknown; + setResult: (newResult: unknown) => void; +}) => PromiseOrValue; + +export interface OnResolveOptions = {}> { + context: PluginContext; + root: unknown; + args: Record; + info: GraphQLResolveInfo; + resolver: Resolver; + replaceResolver: (newResolver: Resolver) => void; +} + +export type OnResolve = {}> = ( + options: OnResolveOptions +) => PromiseOrValue; + +/** + * Wraps the provided schema by hooking into the resolvers of every field. + * + * Use the `onResolve` argument to manipulate the resolver and its results/errors. + */ +export function useOnResolve = {}>( + onResolve: OnResolve +): Plugin { + return { + onSchemaChange({ schema: _schema }) { + const schema = _schema as GraphQLSchema; + if (!schema) return; // nothing to do if schema is missing + + for (const type of Object.values(schema.getTypeMap())) { + if (!isIntrospectionType(type) && isObjectType(type)) { + for (const field of Object.values(type.getFields())) { + let resolver = (field.resolve || defaultFieldResolver) as Resolver; + + field.resolve = async (root, args, context, info) => { + const afterResolve = await onResolve({ + root, + args, + context, + info, + resolver, + replaceResolver: newResolver => { + resolver = newResolver; + }, + }); + + let result; + try { + result = await resolver(root, args, context, info); + } catch (err) { + result = err as Error; + } + + if (typeof afterResolve === 'function') { + await afterResolve({ + result, + setResult: newResult => { + result = newResult; + }, + }); + } + + if (result instanceof Error) { + throw result; + } + return result; + }; + } + } + } + }, + }; +} diff --git a/packages/plugins/on-resolve/test/use-on-resolve.spec.ts b/packages/plugins/on-resolve/test/use-on-resolve.spec.ts new file mode 100644 index 000000000..7b6954f8f --- /dev/null +++ b/packages/plugins/on-resolve/test/use-on-resolve.spec.ts @@ -0,0 +1,63 @@ +import { OnResolveOptions, useOnResolve } from '@envelop/on-resolve'; +import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; +import { makeExecutableSchema } from '@graphql-tools/schema'; + +describe('useOnResolve', () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + value1: String! + value2: String! + } + `, + resolvers: { + Query: { + value1: () => 'value1', + value2: () => 'value2', + }, + }, + }); + + it('should invoke the callback for each resolver', async () => { + const onResolveDoneFn = jest.fn(); + const onResolveFn = jest.fn((_opts: OnResolveOptions) => onResolveDoneFn); + const testkit = createTestkit([useOnResolve(onResolveFn)], schema); + + await testkit.execute('{ value1, value2 }'); + + expect(onResolveFn).toBeCalledTimes(2); + expect(onResolveDoneFn).toBeCalledTimes(2); + + let i = 0; + for (const field of ['value1', 'value2']) { + expect(onResolveFn.mock.calls[i][0].context).toBeDefined(); + expect(onResolveFn.mock.calls[i][0].root).toBeDefined(); + expect(onResolveFn.mock.calls[i][0].args).toBeDefined(); + expect(onResolveFn.mock.calls[i][0].info).toBeDefined(); + expect(onResolveFn.mock.calls[i][0].info.fieldName).toBe(field); + expect(onResolveFn.mock.calls[i][0].resolver).toBeInstanceOf(Function); + expect(onResolveFn.mock.calls[i][0].replaceResolver).toBeInstanceOf(Function); + + expect(onResolveDoneFn.mock.calls[i][0].result).toBe(field); + expect(onResolveDoneFn.mock.calls[i][0].setResult).toBeInstanceOf(Function); + + i++; + } + }); + + it('should replace the result using the after hook', async () => { + const testkit = createTestkit( + [ + useOnResolve(() => ({ setResult }) => { + setResult('value2'); + }), + ], + schema + ); + + const result = await testkit.execute('{ value1 }'); + assertSingleExecutionValue(result); + + expect(result.data?.value1).toBe('value2'); + }); +}); diff --git a/packages/plugins/opentelemetry/README.md b/packages/plugins/opentelemetry/README.md index 7b94d8dc5..d4f1ebd47 100644 --- a/packages/plugins/opentelemetry/README.md +++ b/packages/plugins/opentelemetry/README.md @@ -15,11 +15,13 @@ yarn add @envelop/opentelemetry By default, this plugin prints the collected telemetry to the console: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useOpenTelemetry } from '@envelop/opentelemetry' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useOpenTelemetry({ resolvers: true, // Tracks resolvers calls, and tracks resolvers thrown errors @@ -33,7 +35,8 @@ const getEnveloped = envelop({ If you wish to use custom tracer/exporter, create it and pass it. This example integrates Jaeger tracer: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useOpenTelemetry } from '@envelop/opentelemetry' import { JaegerExporter } from '@opentelemetry/exporter-jaeger' import { SimpleSpanProcessor, BasicTracerProvider } from '@opentelemetry/tracing' @@ -48,6 +51,7 @@ provider.register() const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useOpenTelemetry( { diff --git a/packages/plugins/opentelemetry/package.json b/packages/plugins/opentelemetry/package.json index 3028d79f0..2d784dd4f 100644 --- a/packages/plugins/opentelemetry/package.json +++ b/packages/plugins/opentelemetry/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "@opentelemetry/api": "^1.0.0", "@opentelemetry/tracing": "^0.24.0", "tslib": "^2.4.0" diff --git a/packages/plugins/opentelemetry/src/index.ts b/packages/plugins/opentelemetry/src/index.ts index 2e2a6b56f..362d34c47 100644 --- a/packages/plugins/opentelemetry/src/index.ts +++ b/packages/plugins/opentelemetry/src/index.ts @@ -1,4 +1,5 @@ import { Plugin, OnExecuteHookResult, isAsyncIterable } from '@envelop/core'; +import { useOnResolve } from '@envelop/on-resolve'; import { SpanAttributes, SpanKind } from '@opentelemetry/api'; import * as opentelemetry from '@opentelemetry/api'; import { BasicTracerProvider, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; @@ -45,41 +46,45 @@ export const useOpenTelemetry = ( const tracer = tracingProvider.getTracer(serviceName); return { - onResolverCalled: options.resolvers - ? ({ info, context, args }) => { - if (context && typeof context === 'object' && context[tracingSpanSymbol]) { - tracer.getActiveSpanProcessor(); - const ctx = opentelemetry.trace.setSpan(opentelemetry.context.active(), context[tracingSpanSymbol]); - const { fieldName, returnType, parentType } = info; - - const resolverSpan = tracer.startSpan( - `${parentType.name}.${fieldName}`, - { - attributes: { - [AttributeName.RESOLVER_FIELD_NAME]: fieldName, - [AttributeName.RESOLVER_TYPE_NAME]: parentType.toString(), - [AttributeName.RESOLVER_RESULT_TYPE]: returnType.toString(), - [AttributeName.RESOLVER_ARGS]: JSON.stringify(args || {}), + onPluginInit({ addPlugin }) { + if (options.resolvers) { + addPlugin( + useOnResolve(({ info, context, args }) => { + if (context && typeof context === 'object' && context[tracingSpanSymbol]) { + tracer.getActiveSpanProcessor(); + const ctx = opentelemetry.trace.setSpan(opentelemetry.context.active(), context[tracingSpanSymbol]); + const { fieldName, returnType, parentType } = info; + + const resolverSpan = tracer.startSpan( + `${parentType.name}.${fieldName}`, + { + attributes: { + [AttributeName.RESOLVER_FIELD_NAME]: fieldName, + [AttributeName.RESOLVER_TYPE_NAME]: parentType.toString(), + [AttributeName.RESOLVER_RESULT_TYPE]: returnType.toString(), + [AttributeName.RESOLVER_ARGS]: JSON.stringify(args || {}), + }, }, - }, - ctx - ); - - return ({ result }) => { - if (result instanceof Error) { - resolverSpan.recordException({ - name: AttributeName.RESOLVER_EXCEPTION, - message: JSON.stringify(result), - }); - } else { - resolverSpan.end(); - } - }; - } - - return () => {}; - } - : undefined, + ctx + ); + + return ({ result }) => { + if (result instanceof Error) { + resolverSpan.recordException({ + name: AttributeName.RESOLVER_EXCEPTION, + message: JSON.stringify(result), + }); + } else { + resolverSpan.end(); + } + }; + } + + return () => {}; + }) + ); + } + }, onExecute({ args, extendContext }) { const executionSpan = tracer.startSpan(`${args.operationName || 'Anonymous Operation'}`, { kind: spanKind, diff --git a/packages/plugins/operation-field-permissions/README.md b/packages/plugins/operation-field-permissions/README.md index 6bd673f81..81d21ec1d 100644 --- a/packages/plugins/operation-field-permissions/README.md +++ b/packages/plugins/operation-field-permissions/README.md @@ -13,11 +13,13 @@ yarn add @envelop/operation-field-permissions ## Usage Example ```ts -import { envelop, useSchema } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useSchema, useEngine } from '@envelop/core' import { useOperationFieldPermissions } from '@envelop/operation-field-permissions' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), useSchema(schema), useOperationFieldPermissions({ // we can access graphql context here diff --git a/packages/plugins/operation-field-permissions/src/index.ts b/packages/plugins/operation-field-permissions/src/index.ts index 3d6d4d3df..dc2473c2b 100644 --- a/packages/plugins/operation-field-permissions/src/index.ts +++ b/packages/plugins/operation-field-permissions/src/index.ts @@ -1,4 +1,4 @@ -import { EnvelopError, Plugin, useExtendContext } from '@envelop/core'; +import { Plugin, useExtendContext } from '@envelop/core'; import { ExtendedValidationRule, useExtendedValidation } from '@envelop/extended-validation'; import { isUnionType, @@ -8,6 +8,7 @@ import { isInterfaceType, isIntrospectionType, getNamedType, + GraphQLError, } from 'graphql'; type PromiseOrValue = T | Promise; @@ -63,10 +64,9 @@ const OperationScopeRule = !permissionContext.wildcardTypes.has(objectType.name) && !permissionContext.schemaCoordinates.has(schemaCoordinate) ) { - // TODO: EnvelopError was a bad idea ;) // We should use GraphQLError once the object constructor lands in stable GraphQL.js // and useMaskedErrors supports it. - const error = new EnvelopError(options.formatError(schemaCoordinate)); + const error = new GraphQLError(options.formatError(schemaCoordinate)); (error as any).nodes = [node]; context.reportError(error); } @@ -124,7 +124,9 @@ type OperationScopeOptions = { const defaultFormatError = (schemaCoordinate: string) => `Insufficient permissions for selecting '${schemaCoordinate}'.`; -export const useOperationFieldPermissions = (opts: OperationScopeOptions): Plugin => { +export const useOperationFieldPermissions = ( + opts: OperationScopeOptions +): Plugin<{ [OPERATION_PERMISSIONS_SYMBOL]: ScopeContext }> => { return { onPluginInit({ addPlugin }) { addPlugin( diff --git a/packages/plugins/operation-field-permissions/test/use-operation-permissions.spec.ts b/packages/plugins/operation-field-permissions/test/use-operation-permissions.spec.ts index 93655b8ae..4811e6768 100644 --- a/packages/plugins/operation-field-permissions/test/use-operation-permissions.spec.ts +++ b/packages/plugins/operation-field-permissions/test/use-operation-permissions.spec.ts @@ -2,7 +2,6 @@ import { useOperationFieldPermissions } from '../src/index.js'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; import { getIntrospectionQuery } from 'graphql'; -import { useMaskedErrors } from '@envelop/core'; const schema = makeExecutableSchema({ typeDefs: [ @@ -225,24 +224,4 @@ describe('useOperationPermissions', () => { const [error] = result.errors!; expect(error.nodes).toBeDefined(); }); - - it('is not masked by the masked errors plugin', async () => { - const kit = createTestkit( - [ - useOperationFieldPermissions({ - getPermissions: () => new Set([]), - }), - useMaskedErrors(), - ], - schema - ); - const result = await kit.execute(/* GraphQL */ ` - query { - __typename - } - `); - assertSingleExecutionValue(result); - expect(result.errors).toBeDefined(); - expect(result.errors![0].message).toEqual("Insufficient permissions for selecting 'Query.__typename'."); - }); }); diff --git a/packages/plugins/parser-cache/README.md b/packages/plugins/parser-cache/README.md index db0666321..624121de9 100644 --- a/packages/plugins/parser-cache/README.md +++ b/packages/plugins/parser-cache/README.md @@ -13,11 +13,13 @@ yarn add @envelop/parser-cache ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useParserCache } from '@envelop/parser-cache' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useParserCache({ // options goes here diff --git a/packages/plugins/persisted-operations/README.md b/packages/plugins/persisted-operations/README.md index 5f25519e5..042f7d861 100644 --- a/packages/plugins/persisted-operations/README.md +++ b/packages/plugins/persisted-operations/README.md @@ -15,7 +15,8 @@ yarn add @envelop/persisted-operations The most basic implementation can use an in-memory JS `Map` wrapper with a `Store` object: ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { usePersistedOperations, InMemoryStore } from '@envelop/persisted-operations' // You can retrieve the store in any way (e.g. from a remote source) and implement it with a simple Map / Key->Value @@ -29,6 +30,7 @@ const store = new InMemoryStore({ const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePersistedOperations({ store: myStore @@ -58,6 +60,8 @@ usePersistedOperations({ ## Usage Example with built-in JsonFileStore ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { usePersistedOperations, JsonFileStore } from '@envelop/persisted-operations' const persistedOperationsStore = new JsonFilesStore() @@ -71,6 +75,7 @@ await persistedOperationsStore.loadFromFile(filePath) // load and parse persiste const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePersistedOperations({ store: persistedOperationsStore @@ -84,8 +89,12 @@ const getEnveloped = envelop({ The `store` parameter accepts both a `Store` instance, or a function. If you need to support multiple stores (based on incoming GraphQL operation/HTTP request), you can provide a function to toggle between the stores, based on your needs: ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' + const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePersistedOperations({ store: context => { diff --git a/packages/plugins/preload-assets/README.md b/packages/plugins/preload-assets/README.md index b3d46d0e3..43bfed6a9 100644 --- a/packages/plugins/preload-assets/README.md +++ b/packages/plugins/preload-assets/README.md @@ -12,7 +12,8 @@ yarn add @envelop/preload-assets ``` ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { usePreloadAssets } from '@envelop/preload-asset' import { makeExecutableSchema } from 'graphql' @@ -34,7 +35,7 @@ const schema = makeExecutableSchema({ }) const getEnveloped = envelop({ - plugins: [usePreloadAssets()] + plugins: [useEngine({ parse, validate, execute, subscribe }), usePreloadAssets()] }) ``` diff --git a/packages/plugins/preload-assets/package.json b/packages/plugins/preload-assets/package.json index 86c62dba8..ec6e19e8a 100644 --- a/packages/plugins/preload-assets/package.json +++ b/packages/plugins/preload-assets/package.json @@ -54,8 +54,7 @@ "typescript": "4.7.4" }, "peerDependencies": { - "@envelop/core": "^2.6.0", - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + "@envelop/core": "^2.6.0" }, "buildOptions": { "input": "./src/index.ts" diff --git a/packages/plugins/prometheus/README.md b/packages/plugins/prometheus/README.md index c182ca076..e2f01c526 100644 --- a/packages/plugins/prometheus/README.md +++ b/packages/plugins/prometheus/README.md @@ -26,11 +26,13 @@ yarn add prom-client @envelop/prometheus ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { usePrometheus } from '@envelop/prometheus' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePrometheus({ // all optional, and by default, all set to false, please opt-in to the metrics you wish to get @@ -57,12 +59,15 @@ const getEnveloped = envelop({ You can customize the `prom-client` `Registry` object if you are using a custom one, by passing it along with the configuration object: ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { Registry } from 'prom-client' const myRegistry = new Registry() const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePrometheus({ // ... config ... @@ -83,12 +88,14 @@ If you wish to disable introspection logging, you can use `skipIntrospection: tr Each tracing field supports custom `prom-client` objects, and custom `labels` a metadata, you can create a custom extraction function for every `Histogram` / `Summary` / `Counter`: ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { Histogram } from 'prom-client' -import { envelop } from '@envelop/core' +import { envelop, useEngine } from '@envelop/core' import { createHistogram, usePrometheus } from '@envelop/prometheus' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... usePrometheus({ // all optional, and by default, all set to false, please opt-in to the metrics you wish to get diff --git a/packages/plugins/prometheus/package.json b/packages/plugins/prometheus/package.json index af0c9644f..83809478c 100644 --- a/packages/plugins/prometheus/package.json +++ b/packages/plugins/prometheus/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "tslib": "^2.4.0" }, "devDependencies": { diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 97c7faee0..73c72857c 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -9,6 +9,7 @@ import { isIntrospectionOperationString, isAsyncIterable, } from '@envelop/core'; +import { useOnResolve } from '@envelop/on-resolve'; import { TypeInfo } from 'graphql'; import { Summary, Counter, Histogram, register as defaultRegistry } from 'prom-client'; import { @@ -324,31 +325,35 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig = {}): Plugi : undefined; return { - onResolverCalled: resolversHistogram - ? ({ info, context }) => { - const shouldTrace = shouldTraceFieldResolver(info, config.resolversWhitelist); - - if (!shouldTrace) { - return undefined; - } - - const startTime = Date.now(); - - return () => { - const totalTime = (Date.now() - startTime) / 1000; - const paramsCtx = { - ...context[promPluginContext], - info, - }; - resolversHistogram.histogram.observe(resolversHistogram.fillLabelsFn(paramsCtx, context), totalTime); - }; - } - : undefined, onEnveloped({ extendContext }) { extendContext({ [promPluginExecutionStartTimeSymbol]: Date.now(), }); }, + onPluginInit({ addPlugin }) { + if (resolversHistogram) { + addPlugin( + useOnResolve(({ info, context }) => { + const shouldTrace = shouldTraceFieldResolver(info, config.resolversWhitelist); + + if (!shouldTrace) { + return undefined; + } + + const startTime = Date.now(); + + return () => { + const totalTime = (Date.now() - startTime) / 1000; + const paramsCtx = { + ...context[promPluginContext], + info, + }; + resolversHistogram.histogram.observe(resolversHistogram.fillLabelsFn(paramsCtx, context), totalTime); + }; + }) + ); + } + }, onSchemaChange({ schema }) { typeInfo = new TypeInfo(schema); }, diff --git a/packages/plugins/rate-limiter/README.md b/packages/plugins/rate-limiter/README.md index 8aeeed98b..bcd66b293 100644 --- a/packages/plugins/rate-limiter/README.md +++ b/packages/plugins/rate-limiter/README.md @@ -11,7 +11,8 @@ yarn add @envelop/rate-limiter ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useRateLimiter, IdentifyFn } from '@envelop/rate-limiter' const identifyFn: IdentifyFn = async context => { @@ -20,6 +21,7 @@ const identifyFn: IdentifyFn = async context => { const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useRateLimiter({ identifyFn diff --git a/packages/plugins/rate-limiter/package.json b/packages/plugins/rate-limiter/package.json index 72a572bb8..e90d106e0 100644 --- a/packages/plugins/rate-limiter/package.json +++ b/packages/plugins/rate-limiter/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "graphql-rate-limit": "3.3.0", "tslib": "^2.4.0" }, diff --git a/packages/plugins/rate-limiter/src/index.ts b/packages/plugins/rate-limiter/src/index.ts index 25dac08c4..3ee6023d5 100644 --- a/packages/plugins/rate-limiter/src/index.ts +++ b/packages/plugins/rate-limiter/src/index.ts @@ -1,4 +1,5 @@ import { Plugin } from '@envelop/core'; +import { useOnResolve } from '@envelop/on-resolve'; import { IntValueNode, StringValueNode, GraphQLResolveInfo } from 'graphql'; import { getDirective } from './utils.js'; import { getGraphQLRateLimiter } from 'graphql-rate-limit'; @@ -19,61 +20,66 @@ export type RateLimiterPluginOptions = { onRateLimitError?: (event: { error: string; identifier: string; context: unknown; info: GraphQLResolveInfo }) => void; }; -export const useRateLimiter = ( - options: RateLimiterPluginOptions -): Plugin<{ +interface RateLimiterContext { rateLimiterFn: ReturnType; -}> => { +} + +export const useRateLimiter = (options: RateLimiterPluginOptions): Plugin => { const rateLimiterFn = getGraphQLRateLimiter({ identifyContext: options.identifyFn }); return { - async onContextBuilding({ extendContext }) { - extendContext({ - rateLimiterFn, - }); - }, - async onResolverCalled({ args, root, context, info }) { - const rateLimitDirectiveNode = getDirective(info, options.rateLimitDirectiveName || 'rateLimit'); + onPluginInit({ addPlugin }) { + addPlugin( + useOnResolve(async ({ args, root, context, info }) => { + const rateLimitDirectiveNode = getDirective(info, options.rateLimitDirectiveName || 'rateLimit'); - if (rateLimitDirectiveNode && rateLimitDirectiveNode.arguments) { - const maxNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'max')?.value as IntValueNode; - const windowNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'window') - ?.value as StringValueNode; - const messageNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'message') - ?.value as IntValueNode; + if (rateLimitDirectiveNode && rateLimitDirectiveNode.arguments) { + const maxNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'max') + ?.value as IntValueNode; + const windowNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'window') + ?.value as StringValueNode; + const messageNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'message') + ?.value as IntValueNode; - const message = messageNode.value; - const max = parseInt(maxNode.value); - const window = windowNode.value; - const id = options.identifyFn(context); + const message = messageNode.value; + const max = parseInt(maxNode.value); + const window = windowNode.value; + const id = options.identifyFn(context); - const errorMessage = await context.rateLimiterFn( - { parent: root, args, context, info }, - { - max, - window, - message: interpolate(message, { - id, - }), - } - ); - if (errorMessage) { - if (options.onRateLimitError) { - options.onRateLimitError({ - error: errorMessage, - identifier: id, - context, - info, - }); - } + const errorMessage = await context.rateLimiterFn( + { parent: root, args, context, info }, + { + max, + window, + message: interpolate(message, { + id, + }), + } + ); + if (errorMessage) { + if (options.onRateLimitError) { + options.onRateLimitError({ + error: errorMessage, + identifier: id, + context, + info, + }); + } - if (options.transformError) { - throw options.transformError(errorMessage); - } + if (options.transformError) { + throw options.transformError(errorMessage); + } - throw new Error(errorMessage); - } - } + throw new Error(errorMessage); + } + } + }) + ); + }, + async onContextBuilding({ extendContext }) { + extendContext({ + rateLimiterFn, + }); }, }; }; diff --git a/packages/plugins/resource-limitations/README.md b/packages/plugins/resource-limitations/README.md index d047f895d..c1c418c9c 100644 --- a/packages/plugins/resource-limitations/README.md +++ b/packages/plugins/resource-limitations/README.md @@ -11,11 +11,13 @@ yarn add @envelop/resource-limitations ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResourceLimitations } from '@envelop/resource-limitations' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResourceLimitations({ nodeCostLimit: 500000, // optional, default to 500000 diff --git a/packages/plugins/response-cache-redis/README.md b/packages/plugins/response-cache-redis/README.md index 98b8f5fb8..af284623f 100644 --- a/packages/plugins/response-cache-redis/README.md +++ b/packages/plugins/response-cache-redis/README.md @@ -22,7 +22,8 @@ In order to use the Redis cache, you need to: - Create an instance of the Redis Cache and set to the `useResponseCache` plugin options ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' import { createRedisCache } from '@envelop/response-cache-redis' import Redis from 'ioredis' @@ -45,6 +46,7 @@ const cache = createRedisCache({ redis }) const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResponseCache({ cache }) ] @@ -54,7 +56,8 @@ const getEnveloped = envelop({ ### Invalidate Cache based on custom logic ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' import { createRedisCache } from '@envelop/response-cache-redis' @@ -67,6 +70,7 @@ const cache = createRedisCache({ redis }) const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResponseCache({ ttl: 2000, diff --git a/packages/plugins/response-cache/README.md b/packages/plugins/response-cache/README.md index c6cc036e7..d738adbc3 100644 --- a/packages/plugins/response-cache/README.md +++ b/packages/plugins/response-cache/README.md @@ -38,11 +38,13 @@ When configuring the `useResponseCache`, you can choose the type of cache: The in-memory LRU cache is used by default. ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResponseCache({ // use global cache for all operations @@ -55,13 +57,15 @@ const getEnveloped = envelop({ Or, you may create the in-memory LRU cache explicitly. ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResponseCache, createInMemoryCache } from '@envelop/response-cache' const cache = createInMemoryCache() const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResponseCache({ cache, @@ -76,11 +80,13 @@ const getEnveloped = envelop({ ### Cache based on session/user ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useResponseCache({ ttl: 2000, @@ -105,6 +111,7 @@ In order to use the Redis cache, you need to: - Create an instance of the Redis Cache and set to the `useResponseCache` plugin options ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' import { createRedisCache } from '@envelop/response-cache-redis' @@ -122,6 +129,10 @@ const redis = new Redis('rediss://:1234567890@my-redis-db.example.com:30652') const cache = createRedisCache({ redis }) const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -139,10 +150,15 @@ const getEnveloped = envelop({ ### Cache with maximum TTL ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -158,10 +174,15 @@ const getEnveloped = envelop({ ### Cache with custom TTL per object type ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -178,10 +199,15 @@ const getEnveloped = envelop({ ### Cache with custom TTL per schema coordinate ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -198,10 +224,15 @@ const getEnveloped = envelop({ ### Disable cache based on session/user ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -228,6 +259,7 @@ cache results with certain error types. By default, the `defaultShouldCacheResult` function is used which never caches any query operation execution results that includes any errors (unexpected, EnvelopError, or GraphQLError). ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache, ShouldCacheResultFunction } from '@envelop/response-cache' @@ -238,6 +270,10 @@ export const defaultShouldCacheResult: ShouldCacheResultFunction = (params): Boo } const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -255,10 +291,15 @@ By default introspection query operations are not cached. In case you want to ca **Infinite caching** ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -274,10 +315,15 @@ const getEnveloped = envelop({ **TTL caching** ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -293,10 +339,15 @@ const getEnveloped = envelop({ ### Cache with maximum TTL ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -310,10 +361,15 @@ const getEnveloped = envelop({ ### Customize the fields that are used for building the cache ID ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -329,10 +385,15 @@ const getEnveloped = envelop({ ### Disable automatic cache invalidation via mutations ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache } from '@envelop/response-cache' const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -348,6 +409,7 @@ const getEnveloped = envelop({ ### Invalidate Cache based on custom logic ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache, createInMemoryCache } from '@envelop/response-cache' import { emitter } from './eventEmitter' @@ -356,6 +418,10 @@ import { emitter } from './eventEmitter' const cache = createInMemoryCache() const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -380,6 +446,7 @@ emitter.on('invalidate', resource => { ### Customize how cache ids are built ```ts +import { parse, validate, execute, subscribe } from 'graphql' import { envelop } from '@envelop/core' import { useResponseCache, createInMemoryCache } from '@envelop/response-cache' import { emitter } from './eventEmitter' @@ -391,6 +458,10 @@ const cache = createInMemoryCache({ }) const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ @@ -408,7 +479,14 @@ const getEnveloped = envelop({ For debugging or monitoring it might be useful to know whether a response got served from the cache or not. ```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop } from '@envelop/core' + const getEnveloped = envelop({ + parse, + validate, + execute, + subscribe, plugins: [ // ... other plugins ... useResponseCache({ diff --git a/packages/plugins/sentry/README.md b/packages/plugins/sentry/README.md index 9f95bce9f..cb6f8c3e0 100644 --- a/packages/plugins/sentry/README.md +++ b/packages/plugins/sentry/README.md @@ -30,13 +30,15 @@ yarn add @sentry/node @sentry/tracing @envelop/sentry ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useSentry } from '@envelop/sentry' // do this only once in you entry file. import '@sentry/tracing' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useSentry({ includeRawResult: false, // set to `true` in order to include the execution result in the metadata collected diff --git a/packages/plugins/sentry/package.json b/packages/plugins/sentry/package.json index ab53de763..b12a5e2fd 100644 --- a/packages/plugins/sentry/package.json +++ b/packages/plugins/sentry/package.json @@ -47,6 +47,7 @@ "definition": "dist/typings/index.d.ts" }, "dependencies": { + "@envelop/on-resolve": "^1.0.0", "tslib": "^2.4.0" }, "devDependencies": { diff --git a/packages/plugins/sentry/src/index.ts b/packages/plugins/sentry/src/index.ts index b985e3be8..71f0b6235 100644 --- a/packages/plugins/sentry/src/index.ts +++ b/packages/plugins/sentry/src/index.ts @@ -1,10 +1,10 @@ import { Plugin, - OnResolverCalledHook, - EnvelopError, handleStreamOrSingleExecutionResult, OnExecuteDoneHookResultOnNextHook, + isGraphQLError, } from '@envelop/core'; +import { OnResolve, useOnResolve } from '@envelop/on-resolve'; import * as Sentry from '@sentry/node'; import type { Span, TraceparentData } from '@sentry/types'; import { ExecutionArgs, GraphQLError, Kind, OperationDefinitionNode, print, responsePathAsArray } from 'graphql'; @@ -80,13 +80,16 @@ export type SentryPluginOptions = { skip?: (args: ExecutionArgs) => boolean; /** * Indicates whether or not to skip Sentry exception reporting for a given error. - * By default, this plugin skips all `EnvelopError` errors and does not report it to Sentry. + * By default, this plugin skips all `GraphQLError` errors and does not report it to Sentry. */ skipError?: (args: Error) => boolean; }; export function defaultSkipError(error: Error): boolean { - return error instanceof EnvelopError; + if (isGraphQLError(error)) { + return isGraphQLError(error.originalError); + } + return false; } const sentryTracingSymbol = Symbol('sentryTracing'); @@ -126,7 +129,7 @@ export const useSentry = (options: SentryPluginOptions = {}): Plugin => { }); } - const onResolverCalled: OnResolverCalledHook | undefined = trackResolvers + const onResolve: OnResolve | undefined = trackResolvers ? ({ args: resolversArgs, info, context }) => { const { rootSpan, opName, operationType } = context[sentryTracingSymbol] as SentryTracingContext; if (rootSpan) { @@ -172,13 +175,18 @@ export const useSentry = (options: SentryPluginOptions = {}): Plugin => { : undefined; return { - onResolverCalled, + onPluginInit({ addPlugin }) { + if (onResolve) { + addPlugin(useOnResolve(onResolve)); + } + }, onExecute({ args, extendContext }) { if (skipOperation(args)) { return; } const rootOperation = args.document.definitions.find( + // @ts-expect-error TODO: not sure how we will make it dev friendly o => o.kind === Kind.OPERATION_DEFINITION ) as OperationDefinitionNode; const operationType = rootOperation.operation; @@ -247,7 +255,7 @@ export const useSentry = (options: SentryPluginOptions = {}): Plugin => { Sentry.configureScope(scope => options.configureScope!(args, scope)); } - if (onResolverCalled) { + if (onResolve) { const sentryContext: SentryTracingContext = { rootSpan, opName, @@ -292,7 +300,7 @@ export const useSentry = (options: SentryPluginOptions = {}): Plugin => { // Map index values in list to $index for better grouping of events. const errorPathWithIndex = (err.path ?? []) - .map(v => (typeof v === 'number' ? '$index' : v)) + .map((v: any) => (typeof v === 'number' ? '$index' : v)) .join(' > '); const eventId = diff --git a/packages/plugins/statsd/README.md b/packages/plugins/statsd/README.md index 107a20358..056b2f40e 100644 --- a/packages/plugins/statsd/README.md +++ b/packages/plugins/statsd/README.md @@ -25,7 +25,8 @@ yarn add hot-shots @envelop/stats ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useStatsD } from '@envelop/statsd' import StatsD from 'hot-shots' @@ -36,6 +37,7 @@ const client = new StatsD({ const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useStatsD({ client, diff --git a/packages/plugins/statsd/package.json b/packages/plugins/statsd/package.json index f5de2c438..ceb57e6ba 100644 --- a/packages/plugins/statsd/package.json +++ b/packages/plugins/statsd/package.json @@ -63,7 +63,6 @@ }, "peerDependencies": { "@envelop/core": "^2.6.0", - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", "hot-shots": "^8.0.0 || ^9.0.0" }, "buildOptions": { diff --git a/packages/plugins/statsd/src/index.ts b/packages/plugins/statsd/src/index.ts index 8a6316800..d9f3fe3d1 100644 --- a/packages/plugins/statsd/src/index.ts +++ b/packages/plugins/statsd/src/index.ts @@ -1,5 +1,4 @@ import { Plugin, AfterParseEventPayload, isIntrospectionOperationString, isAsyncIterable } from '@envelop/core'; -import { DocumentNode, Kind, OperationDefinitionNode } from 'graphql'; import type { StatsD } from 'hot-shots'; export interface StatsDPluginOptions { @@ -31,8 +30,8 @@ interface PluginInternalContext { [statsDPluginExecutionStartTimeSymbol]: number; } -function getOperation(document: DocumentNode) { - return document.definitions.find(def => def.kind === Kind.OPERATION_DEFINITION) as OperationDefinitionNode; +function getOperation(document: any) { + return document.definitions.find((def: any) => def.kind === 'OperationDefinition'); } function isParseFailure(parseResult: AfterParseEventPayload['result']): parseResult is Error | null { diff --git a/packages/plugins/validation-cache/README.md b/packages/plugins/validation-cache/README.md index 9f2fec750..42d37dd20 100644 --- a/packages/plugins/validation-cache/README.md +++ b/packages/plugins/validation-cache/README.md @@ -13,11 +13,13 @@ yarn add @envelop/validation-cache ## Usage Example ```ts -import { envelop } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEngine } from '@envelop/core' import { useValidationCache } from '@envelop/validation-cache' const getEnveloped = envelop({ plugins: [ + useEngine({ parse, validate, execute, subscribe }), // ... other plugins ... useValidationCache({ // options goes here diff --git a/packages/plugins/validation-cache/src/index.ts b/packages/plugins/validation-cache/src/index.ts index acd655b96..d0bec4d43 100644 --- a/packages/plugins/validation-cache/src/index.ts +++ b/packages/plugins/validation-cache/src/index.ts @@ -59,6 +59,7 @@ export const useValidationCache = (pluginOptions: ValidationCacheOptions = {}): } return ({ result }) => { + // @ts-expect-error TODO: not sure how we will make it dev friendly resultCache.set(key, result); }; }, diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 1bfad5498..ee1d9b3d8 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -1,8 +1,23 @@ -import { DocumentNode, ExecutionResult, getOperationAST, GraphQLError, GraphQLSchema, print } from 'graphql'; -import { useSchema, envelop, PluginOrDisabledPlugin, isAsyncIterable } from '@envelop/core'; +import { + DocumentNode, + ExecutionResult, + getOperationAST, + GraphQLError, + GraphQLSchema, + print, + execute, + parse, + subscribe, + validate, +} from 'graphql'; +import { useSchema, envelop, isAsyncIterable, useEngine } from '@envelop/core'; import { GetEnvelopedFn, Plugin } from '@envelop/types'; import { mapSchema as cloneSchema, isDocumentNode } from '@graphql-tools/utils'; +export const useGraphQLJSEngine = () => { + return useEngine({ parse, validate, execute, subscribe }); +}; + export type ModifyPluginsFn = (plugins: Plugin[]) => Plugin[]; export type PhaseReplacementParams = | { @@ -48,7 +63,6 @@ export function createSpiedPlugin() { beforeExecute: jest.fn(() => ({ onExecuteDone: baseSpies.afterExecute, })), - onResolverCalled: baseSpies.beforeResolver, }; return { @@ -64,7 +78,6 @@ export function createSpiedPlugin() { onValidate: spies.beforeValidate, onExecute: spies.beforeExecute, onContextBuilding: spies.beforeContextBuilding, - onResolverCalled: spies.beforeResolver, }, }; } @@ -86,7 +99,7 @@ export type TestkitInstance = { }; export function createTestkit( - pluginsOrEnvelop: GetEnvelopedFn | Array, + pluginsOrEnvelop: GetEnvelopedFn | Parameters['0']['plugins'], schema?: GraphQLSchema ): TestkitInstance { const toGraphQLErrorOrThrow = (thrownThing: unknown): GraphQLError => { @@ -100,14 +113,20 @@ export function createTestkit( const phasesReplacements: PhaseReplacementParams[] = []; let getEnveloped = Array.isArray(pluginsOrEnvelop) ? envelop({ - plugins: [...(schema ? [useSchema(cloneSchema(schema))] : []), ...pluginsOrEnvelop], + plugins: [ + ...(schema ? [useGraphQLJSEngine(), useSchema(cloneSchema(schema))] : [useGraphQLJSEngine()]), + ...pluginsOrEnvelop, + ], }) : pluginsOrEnvelop; return { modifyPlugins(modifyPluginsFn: ModifyPluginsFn) { getEnveloped = envelop({ - plugins: [...(schema ? [useSchema(cloneSchema(schema))] : []), ...modifyPluginsFn(getEnveloped._plugins)], + plugins: [ + ...(schema ? [useGraphQLJSEngine(), useSchema(cloneSchema(schema))] : [useGraphQLJSEngine()]), + ...modifyPluginsFn(getEnveloped._plugins), + ], }); }, mockPhase(phaseReplacement: PhaseReplacementParams) { diff --git a/packages/testing/test/test.spec.ts b/packages/testing/test/test.spec.ts index 42d3de89a..2b11ceddb 100644 --- a/packages/testing/test/test.spec.ts +++ b/packages/testing/test/test.spec.ts @@ -1,4 +1,3 @@ -import { enableIf } from '@envelop/core'; import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; import { Plugin } from '@envelop/types'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -98,7 +97,7 @@ describe('Test the testkit', () => { onValidate: jest.fn().mockReturnValue(undefined), }; - const testkit = createTestkit([plugin1, enableIf(false, plugin2)], createSchema()); + const testkit = createTestkit([plugin1, false && plugin2], createSchema()); const result = await testkit.execute('query test { foo }'); assertSingleExecutionValue(result); expect(plugin1.onParse).toBeCalled(); diff --git a/packages/types/package.json b/packages/types/package.json index 83d31e4ce..d6485bb9c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -50,12 +50,8 @@ "tslib": "^2.4.0" }, "devDependencies": { - "graphql": "16.3.0", "typescript": "4.7.4" }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" - }, "buildOptions": { "input": "./src/index.ts" }, diff --git a/packages/types/src/get-enveloped.ts b/packages/types/src/get-enveloped.ts index 800f0a5ca..b1505b855 100644 --- a/packages/types/src/get-enveloped.ts +++ b/packages/types/src/get-enveloped.ts @@ -1,5 +1,4 @@ import { Plugin } from './plugin.js'; -import { GraphQLSchema } from 'graphql'; import { ExecuteFunction, ParseFunction, SubscribeFunction, ValidateFunction } from './graphql.js'; import { ArbitraryObject, Spread, PromiseOrValue } from './utils.js'; export { ArbitraryObject } from './utils.js'; @@ -17,7 +16,7 @@ export type GetEnvelopedFn = { contextFactory: ( contextExtension?: ContextExtension ) => PromiseOrValue>; - schema: GraphQLSchema; + schema: any; }; _plugins: Plugin[]; }; diff --git a/packages/types/src/graphql.ts b/packages/types/src/graphql.ts index 9a8ac5e6e..ef83bf5e4 100644 --- a/packages/types/src/graphql.ts +++ b/packages/types/src/graphql.ts @@ -1,48 +1,22 @@ -import type { - DocumentNode, - GraphQLFieldResolver, - GraphQLSchema, - SubscriptionArgs, - ExecutionArgs, - GraphQLTypeResolver, - subscribe, - execute, - parse, - validate, - GraphQLResolveInfo, -} from 'graphql'; -import type { Maybe } from './utils.js'; - -/** @private */ -export type PolymorphicExecuteArguments = - | [ExecutionArgs] - | [ - GraphQLSchema, - DocumentNode, - any, - any, - Maybe<{ [key: string]: any }>, - Maybe, - Maybe>, - Maybe> - ]; +import { ObjMap } from './utils.js'; +export interface ExecutionArgs { + schema: any; + document: any; + rootValue?: any; + contextValue?: any; + variableValues?: any; + operationName?: any; + fieldResolver?: any; + typeResolver?: any; + subscribeFieldResolver?: any; +} +declare function parse(source: any, options?: any): any; +declare function execute(args: ExecutionArgs): any; +declare function subscribe(args: ExecutionArgs): any; +declare function validate(schema: any, documentAST: any, rules?: any, options?: any, typeInfo?: any): any; export type ExecuteFunction = typeof execute; -/** @private */ -export type PolymorphicSubscribeArguments = - | [SubscriptionArgs] - | [ - GraphQLSchema, - DocumentNode, - any?, - any?, - Maybe<{ [key: string]: any }>?, - Maybe?, - Maybe>?, - Maybe>? - ]; - export type SubscribeFunction = typeof subscribe; export type ParseFunction = typeof parse; @@ -70,4 +44,31 @@ export type ValidateFunctionParameter = { options?: Parameters[4]; }; -export type Path = GraphQLResolveInfo['path']; +/** @private */ +export type PolymorphicExecuteArguments = + | [ExecutionArgs] + | [ + ExecutionArgs['schema'], + ExecutionArgs['document'], + ExecutionArgs['rootValue'], + ExecutionArgs['contextValue'], + ExecutionArgs['variableValues'], + ExecutionArgs['operationName'], + ExecutionArgs['fieldResolver'], + ExecutionArgs['typeResolver'] + ]; + +/** @private */ +export type PolymorphicSubscribeArguments = PolymorphicExecuteArguments; + +export type Path = { + readonly prev: Path | undefined; + readonly key: string | number; + readonly typename: string | undefined; +}; + +export interface ExecutionResult, TExtensions = ObjMap> { + errors?: ReadonlyArray; + data?: TData | null; + extensions?: TExtensions; +} diff --git a/packages/types/src/hooks.ts b/packages/types/src/hooks.ts index 5b47c1a09..1172893b5 100644 --- a/packages/types/src/hooks.ts +++ b/packages/types/src/hooks.ts @@ -1,34 +1,23 @@ -import type { - DocumentNode, - ExecutionArgs, - ExecutionResult, - GraphQLError, - GraphQLResolveInfo, - GraphQLSchema, - ParseOptions, - Source, - SubscriptionArgs, - ValidationRule, -} from 'graphql'; import { Maybe, PromiseOrValue, AsyncIterableIteratorOrValue } from './utils.js'; -import { DefaultContext } from './context-types.js'; import { ExecuteFunction, ParseFunction, ValidateFunction, ValidateFunctionParameter, SubscribeFunction, + ExecutionResult, + ExecutionArgs, } from './graphql.js'; import { Plugin } from './plugin.js'; export type DefaultArgs = Record; -export type SetSchemaFn = (newSchema: GraphQLSchema) => void; +export type SetSchemaFn = (newSchema: any) => void; /** * The payload forwarded to the onSchemaChange hook. */ -export type OnSchemaChangeEventPayload = { schema: GraphQLSchema; replaceSchema: SetSchemaFn }; +export type OnSchemaChangeEventPayload = { schema: any; replaceSchema: SetSchemaFn }; /** * Invoked each time the schema is changed via a setSchema call. @@ -53,15 +42,15 @@ export type RegisterContextErrorHandler = (handler: OnContextErrorHandler) => vo /** * Payload forwarded to the onPluginInit hook. */ -export type OnPluginInitEventPayload = { +export type OnPluginInitEventPayload> = { /** * Register a new plugin. */ - addPlugin: (newPlugin: Plugin) => void; + addPlugin: (newPlugin: Plugin) => void; /** * A list of all currently active plugins. */ - plugins: Plugin[]; + plugins: Plugin[]; /** * Set the GraphQL schema. */ @@ -75,7 +64,9 @@ export type OnPluginInitEventPayload = { /** * Invoked when a plugin is initialized. */ -export type OnPluginInitHook = (options: OnPluginInitEventPayload) => void; +export type OnPluginInitHook> = ( + options: OnPluginInitEventPayload +) => void; /** onPluginInit */ export type OnEnvelopedHookEventPayload = { @@ -107,7 +98,7 @@ export type OnParseEventPayload = { /** * The parameters that are passed to the parse call. */ - params: { source: string | Source; options?: ParseOptions }; + params: { source: string | any; options?: any }; /** * The current parse function */ @@ -120,7 +111,7 @@ export type OnParseEventPayload = { * Set/overwrite the parsed document. * If a parsed document is set the call to the parseFn will be skipped. */ - setParsedDocument: (doc: DocumentNode) => void; + setParsedDocument: (doc: any) => void; }; export type AfterParseEventPayload = { @@ -135,11 +126,11 @@ export type AfterParseEventPayload = { /** * The result of the parse phase. */ - result: DocumentNode | Error | null; + result: any | Error | null; /** * Replace the parse result with a new result. */ - replaceParseResult: (newResult: DocumentNode | Error) => void; + replaceParseResult: (newResult: any | Error) => void; }; /** @@ -171,7 +162,7 @@ export type OnValidateEventPayload = { /** * Register a validation rule that will be used for the validate invocation. */ - addValidationRule: (rule: ValidationRule) => void; + addValidationRule: (rule: any) => void; /** * The current validate function that will be invoked. */ @@ -183,7 +174,7 @@ export type OnValidateEventPayload = { /** * Set a validation error result and skip the validate invocation. */ - setResult: (errors: readonly GraphQLError[]) => void; + setResult: (errors: readonly any[]) => void; }; /** @@ -206,11 +197,11 @@ export type AfterValidateEventPayload = { * An array of errors that were raised during the validation phase. * The array is empty if no errors were raised. */ - result: readonly GraphQLError[]; + result: readonly Error[] | any[]; /** * Replace the current error result with a new one. */ - setResult: (errors: GraphQLError[]) => void; + setResult: (errors: Error[] | any[]) => void; }; /** @@ -272,40 +263,6 @@ export type OnContextBuildingHook = ( options: OnContextBuildingEventPayload ) => PromiseOrValue>; -export type ResolverFn = ( - root: ParentType, - args: ArgsType, - context: ContextType, - info: GraphQLResolveInfo -) => PromiseOrValue; - -export type OnBeforeResolverCalledEventPayload< - ParentType = unknown, - ArgsType = DefaultArgs, - ContextType = unknown, - ResultType = unknown -> = { - root: ParentType; - args: ArgsType; - context: ContextType; - info: GraphQLResolveInfo; - resolverFn: ResolverFn; - replaceResolverFn: (newResolver: ResolverFn) => void; -}; - -export type AfterResolverEventPayload = { result: unknown | Error; setResult: (newResult: unknown) => void }; - -export type AfterResolverHook = (options: AfterResolverEventPayload) => void; - -export type OnResolverCalledHook< - ParentType = unknown, - ArgsType = DefaultArgs, - ContextType = DefaultContext, - ResultType = unknown -> = ( - options: OnBeforeResolverCalledEventPayload -) => PromiseOrValue; - /** * Execution arguments with inferred context value type. */ @@ -428,7 +385,7 @@ export type OnExecuteHook = ( /** * Subscription arguments with inferred context value type. */ -export type TypedSubscriptionArgs = Omit & { contextValue: ContextType }; +export type TypedSubscriptionArgs = Omit & { contextValue: ContextType }; /** * Payload with which the onSubscribe hook is invoked. diff --git a/packages/types/src/plugin.ts b/packages/types/src/plugin.ts index 37941b362..7deb4d5df 100644 --- a/packages/types/src/plugin.ts +++ b/packages/types/src/plugin.ts @@ -7,8 +7,6 @@ import { OnSchemaChangeHook, OnSubscribeHook, OnValidateHook, - OnResolverCalledHook, - DefaultArgs, } from './hooks.js'; export interface Plugin = {}> { @@ -23,7 +21,7 @@ export interface Plugin = {}> { /** * Invoked when a plugin is initialized. */ - onPluginInit?: OnPluginInitHook; + onPluginInit?: OnPluginInitHook; /** * Invoked for each execute call. */ @@ -44,8 +42,4 @@ export interface Plugin = {}> { * Invoked for each time the context is builded. */ onContextBuilding?: OnContextBuildingHook; - /** - * Invoked before each resolver has been invoked during the execution phase. - */ - onResolverCalled?: OnResolverCalledHook; } diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index 57920b749..0faa5ef95 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -29,3 +29,20 @@ export type ArbitraryObject = Record; export type PromiseOrValue = T | Promise; export type AsyncIterableIteratorOrValue = T | AsyncIterableIterator; export type Maybe = T | null | undefined; +export type Optional = T | Maybe | false; +export interface ObjMap { + [key: string]: T; +} +export type ObjMapLike = + | ObjMap + | { + [key: string]: T; + }; +export interface ReadOnlyObjMap { + readonly [key: string]: T; +} +export type ReadOnlyObjMapLike = + | ReadOnlyObjMap + | { + readonly [key: string]: T; + }; diff --git a/patches/nextra-theme-docs+2.0.0-beta.29.patch b/patches/nextra-theme-docs+2.0.0-beta.29.patch new file mode 100644 index 000000000..f556c4501 --- /dev/null +++ b/patches/nextra-theme-docs+2.0.0-beta.29.patch @@ -0,0 +1,1885 @@ +diff --git a/node_modules/nextra-theme-docs/dist/index.js b/node_modules/nextra-theme-docs/dist/index.js +index 55d4d18..b637a04 100644 +--- a/node_modules/nextra-theme-docs/dist/index.js ++++ b/node_modules/nextra-theme-docs/dist/index.js +@@ -51,8 +51,8 @@ var __async = (__this, __arguments, generator) => { + }; + + // src/index.tsx +-import React41, { useEffect as useEffect8, useMemo as useMemo5, useRef as useRef7 } from "react"; +-import { useRouter as useRouter9 } from "next/router"; ++import React44, { useEffect as useEffect9, useMemo as useMemo5, useRef as useRef7 } from "react"; ++import { useRouter as useRouter10 } from "next/router"; + import "focus-visible"; + import scrollIntoView3 from "scroll-into-view-if-needed"; + import { SkipNavContent } from "@reach/skip-nav"; +@@ -60,8 +60,8 @@ import cn17 from "clsx"; + import { MDXProvider } from "@mdx-js/react"; + + // src/constants.tsx +-import React38, { isValidElement } from "react"; +-import { useRouter as useRouter8 } from "next/router"; ++import React39, { isValidElement } from "react"; ++import { useRouter as useRouter9 } from "next/router"; + + // src/components/anchor.tsx + import React3, { forwardRef } from "react"; +@@ -123,30 +123,44 @@ var ConfigProvider = ({ + newNextLinkBehavior: pageOpts.newNextLinkBehavior, + title: pageOpts.title, + frontMatter: pageOpts.frontMatter +- }), Object.fromEntries(DEEP_OBJECT_KEYS.map((key) => typeof themeConfig[key] === "object" ? [ +- key, +- __spreadValues(__spreadValues({}, DEFAULT_THEME[key]), themeConfig[key]) +- ] : []))); ++ }), Object.fromEntries( ++ DEEP_OBJECT_KEYS.map( ++ (key) => typeof themeConfig[key] === "object" ? [ ++ key, ++ __spreadValues(__spreadValues({}, DEFAULT_THEME[key]), themeConfig[key]) ++ ] : [] ++ ) ++ )); + const { nextThemes } = extendedConfig; + if (process.env.NODE_ENV === "development") { + const notice = "[nextra-theme-docs] \u26A0\uFE0F You are using a legacy theme config"; +- for (const [legacyOption, newPath] of Object.entries(LEGACY_CONFIG_OPTIONS)) { ++ for (const [legacyOption, newPath] of Object.entries( ++ LEGACY_CONFIG_OPTIONS ++ )) { + if (legacyOption in themeConfig) { + const [obj, key] = newPath.split("."); + const renameTo = key ? `${obj}: { ${key}: ... }` : obj; +- console.warn(`${notice} \`${legacyOption}\`. Rename it to \`${renameTo}\` for future compatibility.`); ++ console.warn( ++ `${notice} \`${legacyOption}\`. Rename it to \`${renameTo}\` for future compatibility.` ++ ); + } + } + for (const key of ["search", "footer"]) { + if (key in themeConfig) { + const option = themeConfig[key]; + if (typeof option === "boolean" || option == null) { +- console.warn(`${notice} \`${key}\`.`, option ? "Remove it" : `Rename it to \`${key}: { component: null }\` for future compatibility.`); ++ console.warn( ++ `${notice} \`${key}\`.`, ++ option ? "Remove it" : `Rename it to \`${key}: { component: null }\` for future compatibility.` ++ ); + } + } + } + if (typeof themeConfig.banner === "string") { +- console.warn(notice, "`banner`. Rename it to `banner: { content: ... }` for future compatibility."); ++ console.warn( ++ notice, ++ "`banner`. Rename it to `banner: { content: ... }` for future compatibility." ++ ); + } + } + return /* @__PURE__ */ React2.createElement(ThemeProvider, { +@@ -253,7 +267,7 @@ var __async2 = (__this, __arguments, generator) => { + }); + }; + +-// ../nextra/dist/chunk-I473SOMZ.mjs ++// ../nextra/dist/chunk-P3CRSSFM.mjs + import React4 from "react"; + import React22 from "react"; + import React32 from "react"; +@@ -567,7 +581,9 @@ function normalizePages({ + const fallbackMeta = meta["*"] || {}; + delete fallbackMeta.title; + delete fallbackMeta.href; +- const items = list.filter((a) => a.kind !== "Meta" && !a.name.startsWith("_") && (a.locale === locale || a.locale === defaultLocale || !a.locale)).sort((a, b) => { ++ const items = list.filter( ++ (a) => a.kind !== "Meta" && !a.name.startsWith("_") && (a.locale === locale || a.locale === defaultLocale || !a.locale) ++ ).sort((a, b) => { + const indexA = metaKeys.indexOf(a.name); + const indexB = metaKeys.indexOf(b.name); + if (indexA === -1 && indexB === -1) +@@ -678,7 +694,9 @@ function normalizePages({ + pageItem.children.push(...normalizedChildren.directories); + docsDirectories.push(...normalizedChildren.docsDirectories); + if (normalizedChildren.flatDirectories.length) { +- pageItem.firstChildRoute = findFirstRoute(normalizedChildren.flatDirectories); ++ pageItem.firstChildRoute = findFirstRoute( ++ normalizedChildren.flatDirectories ++ ); + topLevelNavbarItems.push(pageItem); + } else if (pageItem.withIndexPage) { + topLevelNavbarItems.push(pageItem); +@@ -769,18 +787,25 @@ function usePopper(options) { + return; + if (cleanupCallback.current) + cleanupCallback.current(); +- cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy; ++ cleanupCallback.current = createPopper( ++ reference.current, ++ popper.current, ++ options ++ ).destroy; + }, [reference, popper, cleanupCallback, options]); +- return useMemo(() => [ +- (referenceDomNode) => { +- reference.current = referenceDomNode; +- instantiatePopper(); +- }, +- (popperDomNode) => { +- popper.current = popperDomNode; +- instantiatePopper(); +- } +- ], [reference, popper, instantiatePopper]); ++ return useMemo( ++ () => [ ++ (referenceDomNode) => { ++ reference.current = referenceDomNode; ++ instantiatePopper(); ++ }, ++ (popperDomNode) => { ++ popper.current = popperDomNode; ++ instantiatePopper(); ++ } ++ ], ++ [reference, popper, instantiatePopper] ++ ); + } + + // src/components/banner.tsx +@@ -789,11 +814,19 @@ function Banner() { + if (!banner.text) { + return null; + } +- const hideBannerScript = `try{if(localStorage.getItem(${JSON.stringify(banner.key)})==='0'){document.body.classList.add('nextra-banner-hidden')}}catch(e){}`; ++ const hideBannerScript = `try{if(localStorage.getItem(${JSON.stringify( ++ banner.key ++ )})==='0'){document.body.classList.add('nextra-banner-hidden')}}catch(e){}`; + return /* @__PURE__ */ React14.createElement(React14.Fragment, null, /* @__PURE__ */ React14.createElement("script", { + dangerouslySetInnerHTML: { __html: hideBannerScript } + }), /* @__PURE__ */ React14.createElement("div", { +- className: cn("relative z-20 flex justify-center items-center", "bg-neutral-900 text-sm text-slate-50 font-medium", "h-[var(--nextra-banner-height)] [body.nextra-banner-hidden_&]:hidden", "dark:bg-[linear-gradient(1deg,#383838,#212121)] dark:text-white", "py-1 pl-[max(env(safe-area-inset-left),2.5rem)] pr-[max(env(safe-area-inset-right),2.5rem)]") ++ className: cn( ++ "relative z-20 flex justify-center items-center", ++ "bg-neutral-900 text-sm text-slate-50 font-medium", ++ "h-[var(--nextra-banner-height)] [body.nextra-banner-hidden_&]:hidden", ++ "dark:bg-[linear-gradient(1deg,#383838,#212121)] dark:text-white", ++ "py-1 pl-[max(env(safe-area-inset-left),2.5rem)] pr-[max(env(safe-area-inset-right),2.5rem)]" ++ ) + }, /* @__PURE__ */ React14.createElement("div", { + className: "max-w-[90rem] truncate" + }, renderComponent(banner.text)), /* @__PURE__ */ React14.createElement("button", { +@@ -818,11 +851,14 @@ function Bleed({ + children + }) { + return /* @__PURE__ */ React15.createElement("div", { +- className: cn2("bleed relative -mx-6 mt-6 md:-mx-8 2xl:-mx-24", full && [ +- "md:mx:[calc(-50vw+50%+8rem)", +- "ltr:xl:ml-[calc(50%-50vw+16rem)] ltr:xl:mr-[calc(50%-50vw)]", +- "rtl:xl:ml-[calc(50%-50vw)] rtl:xl:mr-[calc(50%-50vw+16rem)]" +- ]) ++ className: cn2( ++ "bleed relative -mx-6 mt-6 md:-mx-8 2xl:-mx-24", ++ full && [ ++ "md:mx:[calc(-50vw+50%+8rem)", ++ "ltr:xl:ml-[calc(50%-50vw+16rem)] ltr:xl:mr-[calc(50%-50vw)]", ++ "rtl:xl:ml-[calc(50%-50vw)] rtl:xl:mr-[calc(50%-50vw+16rem)]" ++ ] ++ ) + }, children); + } + +@@ -842,10 +878,13 @@ function Breadcrumb({ + }, index > 0 && /* @__PURE__ */ React16.createElement(ArrowRightIcon, { + className: "w-3.5 shrink-0" + }), /* @__PURE__ */ React16.createElement("li", { +- className: cn3("transition-colors whitespace-nowrap", isActive ? "text-gray-700 dark:text-gray-400 font-medium contrast-more:font-bold contrast-more:text-current contrast-more:dark:text-current" : [ +- "min-w-[24px] overflow-hidden text-ellipsis", +- isLink && "hover:text-gray-900 dark:hover:text-gray-200" +- ]), ++ className: cn3( ++ "transition-colors whitespace-nowrap", ++ isActive ? "text-gray-700 dark:text-gray-400 font-medium contrast-more:font-bold contrast-more:text-current contrast-more:dark:text-current" : [ ++ "min-w-[24px] overflow-hidden text-ellipsis", ++ isLink && "hover:text-gray-900 dark:hover:text-gray-200" ++ ] ++ ), + title: item.title + }, isLink && !isActive ? /* @__PURE__ */ React16.createElement(Anchor, { + href: item.route +@@ -868,7 +907,11 @@ function Callout({ + emoji = "\u{1F4A1}" + }) { + return /* @__PURE__ */ React17.createElement("div", { +- className: cn4("nextra-callout border mt-6 flex rounded-lg py-2 ltr:pr-4 rtl:pl-4", "contrast-more:border-current contrast-more:dark:border-current", themes[type]) ++ className: cn4( ++ "nextra-callout border mt-6 flex rounded-lg py-2 ltr:pr-4 rtl:pl-4", ++ "contrast-more:border-current contrast-more:dark:border-current", ++ themes[type] ++ ) + }, /* @__PURE__ */ React17.createElement("div", { + className: "select-none text-xl ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2", + style: { +@@ -943,7 +986,10 @@ function Collapse({ + } + }, /* @__PURE__ */ React18.createElement("div", { + ref: innerRef, +- className: cn5("p-2 transform-gpu overflow-hidden transition-opacity duration-500 ease-in-out motion-reduce:transition-none", className), ++ className: cn5( ++ "p-2 transform-gpu overflow-hidden transition-opacity duration-500 ease-in-out motion-reduce:transition-none", ++ className ++ ), + style: { + opacity: initialState.current ? 1 : 0 + } +@@ -980,16 +1026,26 @@ function useMounted() { + // src/components/input.tsx + import React19, { forwardRef as forwardRef2 } from "react"; + import cn6 from "clsx"; +-var Input = forwardRef2((_a, forwardedRef) => { +- var _b = _a, { className, suffix } = _b, props = __objRest(_b, ["className", "suffix"]); +- return /* @__PURE__ */ React19.createElement("div", { +- className: "relative flex items-center text-gray-900 dark:text-gray-300 contrast-more:text-gray-800 contrast-more:dark:text-gray-300" +- }, /* @__PURE__ */ React19.createElement("input", __spreadValues({ +- ref: forwardedRef, +- spellCheck: false, +- className: cn6(className, "block w-full appearance-none rounded-lg px-3 py-2 transition-colors", "md:text-sm text-base leading-tight", "bg-black/[.03] dark:bg-gray-50/10", "focus:bg-white dark:focus:bg-dark", "placeholder:text-gray-400 dark:placeholder:text-gray-500", "contrast-more:border contrast-more:border-current") +- }, props)), suffix); +-}); ++var Input = forwardRef2( ++ (_a, forwardedRef) => { ++ var _b = _a, { className, suffix } = _b, props = __objRest(_b, ["className", "suffix"]); ++ return /* @__PURE__ */ React19.createElement("div", { ++ className: "relative flex items-center text-gray-900 dark:text-gray-300 contrast-more:text-gray-800 contrast-more:dark:text-gray-300" ++ }, /* @__PURE__ */ React19.createElement("input", __spreadValues({ ++ ref: forwardedRef, ++ spellCheck: false, ++ className: cn6( ++ className, ++ "block w-full appearance-none rounded-lg px-3 py-2 transition-colors", ++ "md:text-sm text-base leading-tight", ++ "bg-black/[.03] dark:bg-gray-50/10", ++ "focus:bg-white dark:focus:bg-dark", ++ "placeholder:text-gray-400 dark:placeholder:text-gray-500", ++ "contrast-more:border contrast-more:border-current" ++ ) ++ }, props)), suffix); ++ } ++); + + // src/components/search.tsx + import { useRouter } from "next/router"; +@@ -1035,46 +1091,53 @@ function Search({ + window.removeEventListener("keydown", down); + }; + }, []); +- const handleKeyDown = useCallback2(function(e) { +- var _a, _b, _c; +- switch (e.key) { +- case "ArrowDown": { +- if (active + 1 < results.length) { +- const el = (_a = ulRef.current) == null ? void 0 : _a.querySelector(`li:nth-of-type(${active + 2}) > a`); +- if (el) { +- e.preventDefault(); +- handleActive({ currentTarget: el }); +- el.focus(); ++ const handleKeyDown = useCallback2( ++ function(e) { ++ var _a, _b, _c; ++ switch (e.key) { ++ case "ArrowDown": { ++ if (active + 1 < results.length) { ++ const el = (_a = ulRef.current) == null ? void 0 : _a.querySelector( ++ `li:nth-of-type(${active + 2}) > a` ++ ); ++ if (el) { ++ e.preventDefault(); ++ handleActive({ currentTarget: el }); ++ el.focus(); ++ } + } ++ break; + } +- break; +- } +- case "ArrowUp": { +- if (active - 1 >= 0) { +- const el = (_b = ulRef.current) == null ? void 0 : _b.querySelector(`li:nth-of-type(${active}) > a`); +- if (el) { +- e.preventDefault(); +- handleActive({ currentTarget: el }); +- el.focus(); ++ case "ArrowUp": { ++ if (active - 1 >= 0) { ++ const el = (_b = ulRef.current) == null ? void 0 : _b.querySelector( ++ `li:nth-of-type(${active}) > a` ++ ); ++ if (el) { ++ e.preventDefault(); ++ handleActive({ currentTarget: el }); ++ el.focus(); ++ } + } ++ break; + } +- break; +- } +- case "Enter": { +- const result = results[active]; +- if (result) { +- router.push(result.route); +- finishSearch(); ++ case "Enter": { ++ const result = results[active]; ++ if (result) { ++ router.push(result.route); ++ finishSearch(); ++ } ++ break; ++ } ++ case "Escape": { ++ setShow(false); ++ (_c = input.current) == null ? void 0 : _c.blur(); ++ break; + } +- break; +- } +- case "Escape": { +- setShow(false); +- (_c = input.current) == null ? void 0 : _c.blur(); +- break; + } +- } +- }, [active, results, router]); ++ }, ++ [active, results, router] ++ ); + const finishSearch = () => { + var _a; + (_a = input.current) == null ? void 0 : _a.blur(); +@@ -1094,7 +1157,14 @@ function Search({ + leaveFrom: "opacity-100", + leaveTo: "opacity-0" + }, /* @__PURE__ */ React20.createElement("kbd", { +- className: cn7("absolute ltr:right-1.5 rtl:left-1.5 my-1.5 select-none", "rounded bg-white px-1.5 h-5 font-mono font-medium text-gray-500 text-[10px]", "border dark:bg-dark/50 dark:border-gray-100/20", "contrast-more:border-current contrast-more:text-current contrast-more:dark:border-current", "items-center gap-1 transition-opacity", value ? "cursor-pointer hover:opacity-70 z-20 flex" : "hidden sm:flex pointer-events-none"), ++ className: cn7( ++ "absolute ltr:right-1.5 rtl:left-1.5 my-1.5 select-none", ++ "rounded bg-white px-1.5 h-5 font-mono font-medium text-gray-500 text-[10px]", ++ "border dark:bg-dark/50 dark:border-gray-100/20", ++ "contrast-more:border-current contrast-more:text-current contrast-more:dark:border-current", ++ "items-center gap-1 transition-opacity", ++ value ? "cursor-pointer hover:opacity-70 z-20 flex" : "hidden sm:flex pointer-events-none" ++ ), + title: value ? "Clear" : void 0, + onClick: () => { + onChange(""); +@@ -1102,10 +1172,13 @@ function Search({ + }, value ? "ESC" : mounted && (navigator.userAgent.includes("Macintosh") ? /* @__PURE__ */ React20.createElement(React20.Fragment, null, /* @__PURE__ */ React20.createElement("span", { + className: "text-xs" + }, "\u2318"), "K") : "CTRL K"))); +- const handleActive = useCallback2((e) => { +- const { index } = e.currentTarget.dataset; +- setActive(Number(index)); +- }, []); ++ const handleActive = useCallback2( ++ (e) => { ++ const { index } = e.currentTarget.dataset; ++ setActive(Number(index)); ++ }, ++ [] ++ ); + return /* @__PURE__ */ React20.createElement("div", { + className: cn7("nextra-search relative md:w-64", className) + }, renderList && /* @__PURE__ */ React20.createElement("div", { +@@ -1130,7 +1203,16 @@ function Search({ + leaveFrom: "opacity-100", + leaveTo: "opacity-0" + }, /* @__PURE__ */ React20.createElement("ul", { +- className: cn7("nextra-scrollbar", "bg-white text-gray-100 dark:bg-neutral-900", "absolute top-full z-20 mt-2 overscroll-contain rounded-xl py-2.5 shadow-xl overflow-auto", "max-h-[min(calc(50vh-11rem-env(safe-area-inset-bottom)),400px)]", "md:max-h-[min(calc(100vh-5rem-env(safe-area-inset-bottom)),400px)]", "right-0 left-0 ltr:md:left-auto rtl:md:right-auto", "contrast-more:border contrast-more:border-gray-900 contrast-more:dark:border-gray-50", overlayClassName), ++ className: cn7( ++ "nextra-scrollbar", ++ "bg-white text-gray-100 dark:bg-neutral-900", ++ "absolute top-full z-20 mt-2 overscroll-contain rounded-xl py-2.5 shadow-xl overflow-auto", ++ "max-h-[min(calc(50vh-11rem-env(safe-area-inset-bottom)),400px)]", ++ "md:max-h-[min(calc(100vh-5rem-env(safe-area-inset-bottom)),400px)]", ++ "right-0 left-0 ltr:md:left-auto rtl:md:right-auto", ++ "contrast-more:border contrast-more:border-gray-900 contrast-more:dark:border-gray-50", ++ overlayClassName ++ ), + ref: ulRef, + style: { + transition: "max-height .2s ease" +@@ -1142,7 +1224,11 @@ function Search({ + }), "Loading...") : results.length > 0 ? results.map(({ route, prefix, children, id }, i) => /* @__PURE__ */ React20.createElement(Fragment, { + key: id + }, prefix, /* @__PURE__ */ React20.createElement("li", { +- className: cn7("mx-2.5 rounded-md break-words", "contrast-more:border", i === active ? "bg-primary-500/10 text-primary-500 contrast-more:border-primary-500" : "text-gray-800 dark:text-gray-300 contrast-more:border-transparent") ++ className: cn7( ++ "mx-2.5 rounded-md break-words", ++ "contrast-more:border", ++ i === active ? "bg-primary-500/10 text-primary-500 contrast-more:border-primary-500" : "text-gray-800 dark:text-gray-300 contrast-more:border-transparent" ++ ) + }, /* @__PURE__ */ React20.createElement(Anchor, { + className: "block px-2.5 py-2 scroll-m-12", + href: route, +@@ -1166,11 +1252,13 @@ var HighlightMatches = memo(function HighlightMatches2({ value, match }) { + const res = []; + if (value) { + while ((result = regexp.exec(value)) !== null) { +- res.push(/* @__PURE__ */ React21.createElement(Fragment2, { +- key: id++ +- }, splitText.splice(0, result.index - index).join(""), /* @__PURE__ */ React21.createElement("span", { +- className: "text-primary-500" +- }, splitText.splice(0, regexp.lastIndex - result.index).join("")))); ++ res.push( ++ /* @__PURE__ */ React21.createElement(Fragment2, { ++ key: id++ ++ }, splitText.splice(0, result.index - index).join(""), /* @__PURE__ */ React21.createElement("span", { ++ className: "text-primary-500" ++ }, splitText.splice(0, regexp.lastIndex - result.index).join(""))) ++ ); + index = regexp.lastIndex; + } + } +@@ -1190,7 +1278,9 @@ var loadIndexes = (basePath, locale) => { + return promise; + }; + var loadIndexesImpl = (basePath, locale) => __async(void 0, null, function* () { +- const response = yield fetch(`${basePath}/_next/static/chunks/nextra-data-${locale}.json`); ++ const response = yield fetch( ++ `${basePath}/_next/static/chunks/nextra-data-${locale}.json` ++ ); + const data = yield response.json(); + const pageIndex = new FlexSearch.Document({ + cache: 100, +@@ -1301,7 +1391,10 @@ function Flexsearch({ + _section_rk: j, + route: url, + prefix: isFirstItemOfPage && /* @__PURE__ */ React23.createElement("div", { +- className: cn8("border-b border-black/10 dark:border-white/20 mx-2.5 mb-2 mt-6 select-none px-2.5 pb-1.5 text-xs font-semibold uppercase text-gray-500 first:mt-0 dark:text-gray-300", "contrast-more:border-gray-600 contrast-more:text-gray-900 contrast-more:dark:border-gray-50 contrast-more:dark:text-gray-50") ++ className: cn8( ++ "border-b border-black/10 dark:border-white/20 mx-2.5 mb-2 mt-6 select-none px-2.5 pb-1.5 text-xs font-semibold uppercase text-gray-500 first:mt-0 dark:text-gray-300", ++ "contrast-more:border-gray-600 contrast-more:text-gray-900 contrast-more:dark:border-gray-50 contrast-more:dark:text-gray-50" ++ ) + }, result.doc.title), + children: /* @__PURE__ */ React23.createElement(React23.Fragment, null, /* @__PURE__ */ React23.createElement("div", { + className: "font-semibold leading-5 text-base" +@@ -1318,28 +1411,33 @@ function Flexsearch({ + isFirstItemOfPage = false; + } + } +- setResults(results2.sort((a, b) => { +- if (a._page_rk === b._page_rk) { +- return a._section_rk - b._section_rk; +- } +- if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) { +- return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]; +- } +- return a._page_rk - b._page_rk; +- }).map((res) => ({ +- id: `${res._page_rk}_${res._section_rk}`, +- route: res.route, +- prefix: res.prefix, +- children: res.children +- }))); ++ setResults( ++ results2.sort((a, b) => { ++ if (a._page_rk === b._page_rk) { ++ return a._section_rk - b._section_rk; ++ } ++ if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) { ++ return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]; ++ } ++ return a._page_rk - b._page_rk; ++ }).map((res) => ({ ++ id: `${res._page_rk}_${res._section_rk}`, ++ route: res.route, ++ prefix: res.prefix, ++ children: res.children ++ })) ++ ); + }; +- const preload = useCallback3((active) => __async(this, null, function* () { +- if (active && !indexes[locale]) { +- setLoading(true); +- yield loadIndexes(basePath, locale); +- setLoading(false); +- } +- }), [locale]); ++ const preload = useCallback3( ++ (active) => __async(this, null, function* () { ++ if (active && !indexes[locale]) { ++ setLoading(true); ++ yield loadIndexes(basePath, locale); ++ setLoading(false); ++ } ++ }), ++ [locale] ++ ); + const handleChange = (value) => __async(this, null, function* () { + setSearch(value); + if (loading) { +@@ -1405,7 +1503,11 @@ function Select({ + }, ({ open }) => /* @__PURE__ */ React24.createElement(Listbox.Button, { + ref: trigger, + title, +- className: cn9("h-7 rounded-md px-2 text-left text-xs font-medium text-gray-600 transition-colors dark:text-gray-400", open ? "bg-gray-200 text-gray-900 dark:bg-primary-100/10 dark:text-gray-50" : "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-primary-100/5 dark:hover:text-gray-50", className) ++ className: cn9( ++ "h-7 rounded-md px-2 text-left text-xs font-medium text-gray-600 transition-colors dark:text-gray-400", ++ open ? "bg-gray-200 text-gray-900 dark:bg-primary-100/10 dark:text-gray-50" : "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-primary-100/5 dark:hover:text-gray-50", ++ className ++ ) + }, selected.name, /* @__PURE__ */ React24.createElement(Portal, null, /* @__PURE__ */ React24.createElement(Transition2, { + ref: container, + show: open, +@@ -1417,7 +1519,11 @@ function Select({ + }, options.map((option) => /* @__PURE__ */ React24.createElement(Listbox.Option, { + key: option.key, + value: option, +- className: ({ active }) => cn9(active ? "bg-primary-50 text-primary-500 dark:bg-primary-500/10" : "text-gray-800 dark:text-gray-100", "relative cursor-pointer whitespace-nowrap py-1.5", "ltr:pl-3 ltr:pr-9 rtl:pr-3 rtl:pl-9") ++ className: ({ active }) => cn9( ++ active ? "bg-primary-50 text-primary-500 dark:bg-primary-500/10" : "text-gray-800 dark:text-gray-100", ++ "relative cursor-pointer whitespace-nowrap py-1.5", ++ "ltr:pl-3 ltr:pr-9 rtl:pr-3 rtl:pl-9" ++ ) + }, option.name, option.key === selected.key && /* @__PURE__ */ React24.createElement("span", { + className: "absolute inset-y-0 ltr:right-3 rtl:left-3 flex items-center" + }, /* @__PURE__ */ React24.createElement(CheckIcon, null)))))))); +@@ -1497,13 +1603,19 @@ function Footer({ menu }) { + return /* @__PURE__ */ React27.createElement("footer", { + className: "bg-gray-100 pb-[env(safe-area-inset-bottom)] dark:bg-neutral-900" + }, /* @__PURE__ */ React27.createElement("div", { +- className: cn10("mx-auto max-w-[90rem] py-2 px-4 flex gap-2", menu ? "flex" : "hidden") ++ className: cn10( ++ "mx-auto max-w-[90rem] py-2 px-4 flex gap-2", ++ menu ? "flex" : "hidden" ++ ) + }, config.i18n.length > 0 && /* @__PURE__ */ React27.createElement(LocaleSwitch, { + options: config.i18n + }), config.darkMode && /* @__PURE__ */ React27.createElement(ThemeSwitch, null)), /* @__PURE__ */ React27.createElement("hr", { + className: "dark:border-neutral-800" + }), /* @__PURE__ */ React27.createElement("div", { +- className: cn10("mx-auto max-w-[90rem] py-12 flex justify-center md:justify-start text-gray-600 dark:text-gray-400", "pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)]") ++ className: cn10( ++ "mx-auto max-w-[90rem] py-12 flex justify-center md:justify-start text-gray-600 dark:text-gray-400", ++ "pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)]" ++ ) + }, renderComponent(config.footer.text))); + } + +@@ -1540,7 +1652,6 @@ function Head() { + :root { + --nextra-primary-hue: ${lightHue}deg; + --nextra-navbar-height: 4rem; +- --nextra-menu-height: 3.75rem; + --nextra-banner-height: 2.5rem; + } + +@@ -1569,7 +1680,10 @@ var NavLinks = ({ + if (!prev && !next) + return null; + return /* @__PURE__ */ React29.createElement("div", { +- className: cn11("mb-8 flex items-center border-t pt-8 dark:border-neutral-800", "contrast-more:border-neutral-400 dark:contrast-more:border-neutral-400") ++ className: cn11( ++ "mb-8 flex items-center border-t pt-8 dark:border-neutral-800", ++ "contrast-more:border-neutral-400 dark:contrast-more:border-neutral-400" ++ ) + }, prev && /* @__PURE__ */ React29.createElement(Anchor, { + href: prev.route, + title: prev.title, +@@ -1579,17 +1693,56 @@ var NavLinks = ({ + }), prev.title), next && /* @__PURE__ */ React29.createElement(Anchor, { + href: next.route, + title: next.title, +- className: cn11(classes.link, "ltr:pl-4 rtl:pr-4 ltr:text-right rtl:text-left ltr:ml-auto rtl:mr-auto") ++ className: cn11( ++ classes.link, ++ "ltr:pl-4 rtl:pr-4 ltr:text-right rtl:text-left ltr:ml-auto rtl:mr-auto" ++ ) + }, next.title, /* @__PURE__ */ React29.createElement(ArrowRightIcon, { + className: cn11(classes.icon, "rtl:rotate-180") + }))); + }; + + // src/components/navbar.tsx +-import React30 from "react"; ++import React31 from "react"; + import cn12 from "clsx"; +-import { useRouter as useRouter4 } from "next/router"; ++import { useRouter as useRouter5 } from "next/router"; + import { Menu, Transition as Transition3 } from "@headlessui/react"; ++ ++// src/components/version-switch.tsx ++import React30, { useEffect as useEffect4, useState as useState6 } from "react"; ++import { useRouter as useRouter4 } from "next/router"; ++function VersionSwitch({ ++ options ++}) { ++ const router = useRouter4(); ++ const [route, setRoute] = useState6(""); ++ useEffect4(() => { ++ var _a; ++ const newRoute = ((_a = options.find((opt) => router.route.startsWith(opt.route))) == null ? void 0 : _a.route) || ""; ++ setRoute(newRoute); ++ }, [router.route]); ++ const selected = route ? options.find((opt) => opt.route === route) : options[0]; ++ return /* @__PURE__ */ React30.createElement(Select, { ++ className: "flex gap-1 items-center", ++ onChange: (option) => { ++ setRoute(option.key); ++ router.push(option.key); ++ }, ++ selected: { ++ key: route, ++ name: /* @__PURE__ */ React30.createElement(React30.Fragment, null, route ? selected == null ? void 0 : selected.title : "Docs", /* @__PURE__ */ React30.createElement(ArrowRightIcon, { ++ className: "shrink-0 h-3.5 w-3.5", ++ pathClassName: "[[aria-expanded='true']>svg>&]:rotate-[270deg] origin-center transition-transform rotate-90" ++ })) ++ }, ++ options: options.map((o) => ({ ++ key: o.route, ++ name: o.title ++ })) ++ }); ++} ++ ++// src/components/navbar.tsx + var classes2 = { + link: "text-sm contrast-more:text-gray-700 contrast-more:dark:text-gray-100", + active: "subpixel-antialiased contrast-more:font-bold", +@@ -1601,55 +1754,86 @@ function NavbarMenu({ + children + }) { + const { items } = menu; +- const routes = Object.fromEntries((menu.children || []).map((route) => [route.name, route])); +- return /* @__PURE__ */ React30.createElement("div", { ++ const routes = Object.fromEntries( ++ (menu.children || []).map((route) => [route.name, route]) ++ ); ++ return /* @__PURE__ */ React31.createElement("div", { + className: "inline-block relative" +- }, /* @__PURE__ */ React30.createElement(Menu, null, /* @__PURE__ */ React30.createElement(Menu.Button, { +- className: cn12(className, "rounded items-center -ml-2 hidden whitespace-nowrap p-2 md:inline-flex", classes2.inactive) +- }, children), /* @__PURE__ */ React30.createElement(Transition3, { ++ }, /* @__PURE__ */ React31.createElement(Menu, null, /* @__PURE__ */ React31.createElement(Menu.Button, { ++ className: cn12( ++ className, ++ "rounded items-center -ml-2 hidden whitespace-nowrap p-2 md:inline-flex", ++ classes2.inactive ++ ) ++ }, children), /* @__PURE__ */ React31.createElement(Transition3, { + leave: "transition-opacity", + leaveFrom: "opacity-100", + leaveTo: "opacity-0" +- }, /* @__PURE__ */ React30.createElement(Menu.Items, { ++ }, /* @__PURE__ */ React31.createElement(Menu.Items, { + className: "absolute right-0 z-20 mt-1 max-h-64 min-w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg dark:bg-neutral-800" + }, Object.entries(items || {}).map(([key, item]) => { + var _a; +- return /* @__PURE__ */ React30.createElement(Menu.Item, { ++ return /* @__PURE__ */ React31.createElement(Menu.Item, { + key +- }, /* @__PURE__ */ React30.createElement(Anchor, { ++ }, /* @__PURE__ */ React31.createElement(Anchor, { + href: item.href || ((_a = routes[key]) == null ? void 0 : _a.route) || menu.route + "/" + key, +- className: cn12("hidden whitespace-nowrap md:inline-block text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 relative select-none w-full", "py-1.5 ltr:pl-3 ltr:pr-9 rtl:pr-3 rtl:pl-9"), ++ className: cn12( ++ "hidden whitespace-nowrap md:inline-block text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 relative select-none w-full", ++ "py-1.5 ltr:pl-3 ltr:pr-9 rtl:pr-3 rtl:pl-9" ++ ), + newWindow: item.newWindow + }, item.title || key)); + }))))); + } + function Navbar({ flatDirectories, items }) { + const config = useConfig(); +- const { locale = DEFAULT_LOCALE, asPath } = useRouter4(); ++ const { locale = DEFAULT_LOCALE, asPath } = useRouter5(); + const activeRoute = getFSRoute(asPath, locale); + const { menu, setMenu } = useMenu(); +- return /* @__PURE__ */ React30.createElement("div", { ++ const { versioned, filteredItems } = items.reduce( ++ (acc, curr) => { ++ if ("versioned" in curr && curr.versioned) { ++ acc.versioned.push(curr); ++ } else { ++ acc.filteredItems.push(curr); ++ } ++ return acc; ++ }, ++ { versioned: [], filteredItems: [] } ++ ); ++ return /* @__PURE__ */ React31.createElement("div", { + className: "nextra-nav-container sticky top-0 z-20 w-full bg-transparent" +- }, /* @__PURE__ */ React30.createElement("div", { +- className: cn12("nextra-nav-container-blur", "pointer-events-none absolute z-[-1] h-full w-full bg-white dark:bg-dark", "shadow-[0_2px_4px_rgba(0,0,0,.02),0_-1px_0_rgba(0,0,0,.06)_inset] dark:shadow-[0_-1px_0_rgba(255,255,255,.1)_inset]", "contrast-more:shadow-[0_0_0_1px_#000] contrast-more:dark:shadow-[0_0_0_1px_#fff]") +- }), /* @__PURE__ */ React30.createElement("nav", { +- className: "mx-auto flex h-[var(--nextra-navbar-height)] max-w-[90rem] items-center justify-end gap-2 pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)]" +- }, /* @__PURE__ */ React30.createElement(Anchor, { ++ }, /* @__PURE__ */ React31.createElement("div", { ++ className: cn12( ++ "nextra-nav-container-blur", ++ "pointer-events-none absolute z-[-1] h-full w-full bg-white dark:bg-dark", ++ "shadow-[0_2px_4px_rgba(0,0,0,.02),0_-1px_0_rgba(0,0,0,.06)_inset] dark:shadow-[0_-1px_0_rgba(255,255,255,.1)_inset]", ++ "contrast-more:shadow-[0_0_0_1px_#000] contrast-more:dark:shadow-[0_0_0_1px_#fff]" ++ ) ++ }), /* @__PURE__ */ React31.createElement("nav", { ++ className: "mx-auto flex h-[var(--nextra-navbar-height)] max-w-[90rem] items-center gap-2 pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)]" ++ }, /* @__PURE__ */ React31.createElement(Anchor, { + href: "/", + className: "flex ltr:mr-auto rtl:ml-auto items-center hover:opacity-75" +- }, renderComponent(config.logo)), items.map((pageOrMenu) => { ++ }, renderComponent(config.logo)), versioned.length > 0 && /* @__PURE__ */ React31.createElement(VersionSwitch, { ++ options: versioned ++ }), filteredItems.map((pageOrMenu) => { + if (pageOrMenu.hidden) + return null; + if (pageOrMenu.type === "menu") { + const menu2 = pageOrMenu; + const isActive2 = menu2.route === activeRoute || activeRoute.startsWith(menu2.route + "/"); +- return /* @__PURE__ */ React30.createElement(NavbarMenu, { ++ return /* @__PURE__ */ React31.createElement(NavbarMenu, { + key: menu2.title, +- className: cn12(classes2.link, "flex gap-1", isActive2 ? classes2.active : classes2.inactive), ++ className: cn12( ++ classes2.link, ++ "flex gap-1", ++ isActive2 ? classes2.active : classes2.inactive ++ ), + menu: menu2 +- }, menu2.title, /* @__PURE__ */ React30.createElement(ArrowRightIcon, { +- className: "h-[18px] min-w-[18px] rounded-sm p-0.5", +- pathClassName: "origin-center transition-transform rotate-90" ++ }, menu2.title, /* @__PURE__ */ React31.createElement(ArrowRightIcon, { ++ className: "h-3.5 w-3.5 rounded-sm", ++ pathClassName: "[[aria-expanded='true']>svg>&]:rotate-[270deg] origin-center transition-transform rotate-90" + })); + } + const page = pageOrMenu; +@@ -1658,46 +1842,50 @@ function Navbar({ flatDirectories, items }) { + href = (page.withIndexPage ? page.route : page.firstChildRoute) || href; + } + const isActive = page.route === activeRoute || activeRoute.startsWith(page.route + "/"); +- return /* @__PURE__ */ React30.createElement(Anchor, { ++ return /* @__PURE__ */ React31.createElement(Anchor, { + href, + key: page.route, +- className: cn12(classes2.link, "-ml-2 hidden whitespace-nowrap p-2 md:inline-block", !isActive || page.newWindow ? classes2.inactive : classes2.active), ++ className: cn12( ++ classes2.link, ++ "-ml-2 hidden whitespace-nowrap p-2 md:inline-block", ++ !isActive || page.newWindow ? classes2.inactive : classes2.active ++ ), + newWindow: page.newWindow, + "aria-selected": !page.newWindow && isActive + }, page.title); + }), renderComponent(config.search.component, { + directories: flatDirectories, + className: "hidden md:inline-block min-w-[200px]" +- }), config.project.link ? /* @__PURE__ */ React30.createElement(Anchor, { ++ }), config.project.link ? /* @__PURE__ */ React31.createElement(Anchor, { + className: "p-2 text-current", + href: config.project.link, + newWindow: true +- }, renderComponent(config.project.icon)) : null, config.chat.link ? /* @__PURE__ */ React30.createElement(Anchor, { ++ }, renderComponent(config.project.icon)) : null, config.chat.link ? /* @__PURE__ */ React31.createElement(Anchor, { + className: "p-2 text-current", + href: config.chat.link, + newWindow: true +- }, renderComponent(config.chat.icon)) : null, /* @__PURE__ */ React30.createElement("button", { ++ }, renderComponent(config.chat.icon)) : null, /* @__PURE__ */ React31.createElement("button", { + className: "nextra-hamburger rounded active:bg-gray-400/20 p-2 -mr-2 md:hidden", + onClick: () => setMenu(!menu) +- }, /* @__PURE__ */ React30.createElement(MenuIcon, { ++ }, /* @__PURE__ */ React31.createElement(MenuIcon, { + className: cn12({ open: menu }) + })))); + } + + // src/components/not-found.tsx +-import React31 from "react"; +-import { useRouter as useRouter5 } from "next/router"; ++import React33 from "react"; ++import { useRouter as useRouter6 } from "next/router"; + function NotFoundPage() { + const config = useConfig(); + const mounted = useMounted(); +- const { asPath } = useRouter5(); ++ const { asPath } = useRouter6(); + const { content, labels } = config.notFound; + if (!content) { + return null; + } +- return /* @__PURE__ */ React31.createElement("p", { ++ return /* @__PURE__ */ React33.createElement("p", { + className: "text-center" +- }, /* @__PURE__ */ React31.createElement(Anchor, { ++ }, /* @__PURE__ */ React33.createElement(Anchor, { + href: getGitIssueUrl({ + repository: config.docsRepositoryBase, + title: `Found broken \`${mounted ? asPath : ""}\` link. Please fix!`, +@@ -1709,19 +1897,19 @@ function NotFoundPage() { + } + + // src/components/server-side-error.tsx +-import React33 from "react"; +-import { useRouter as useRouter6 } from "next/router"; ++import React34 from "react"; ++import { useRouter as useRouter7 } from "next/router"; + function ServerSideErrorPage() { + const config = useConfig(); + const mounted = useMounted(); +- const { asPath } = useRouter6(); ++ const { asPath } = useRouter7(); + const { content, labels } = config.serverSideError; + if (!content) { + return null; + } +- return /* @__PURE__ */ React33.createElement("p", { ++ return /* @__PURE__ */ React34.createElement("p", { + className: "text-center" +- }, /* @__PURE__ */ React33.createElement(Anchor, { ++ }, /* @__PURE__ */ React34.createElement(Anchor, { + href: getGitIssueUrl({ + repository: config.docsRepositoryBase, + title: `Got server-side error in \`${mounted ? asPath : ""}\` url. Please fix!`, +@@ -1733,31 +1921,46 @@ function ServerSideErrorPage() { + } + + // src/components/sidebar.tsx +-import React34, { +- useState as useState6, +- useEffect as useEffect4, ++import React35, { ++ useState as useState7, ++ useEffect as useEffect5, + useMemo as useMemo2, + memo as memo2, + useRef as useRef4 + } from "react"; + import cn13 from "clsx"; + import Slugger from "github-slugger"; +-import { useRouter as useRouter7 } from "next/router"; ++import { useRouter as useRouter8 } from "next/router"; + import scrollIntoView from "scroll-into-view-if-needed"; + var TreeState = /* @__PURE__ */ Object.create(null); + var Folder = memo2(FolderImpl); + var classes3 = { +- link: cn13("flex rounded px-2 py-1.5 text-sm transition-colors [word-break:break-word]", "[-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] contrast-more:border"), +- inactive: cn13("hover:bg-gray-100 text-gray-500 hover:text-gray-900", "dark:hover:bg-primary-100/5 dark:text-neutral-500 dark:hover:text-gray-50", "contrast-more:text-gray-900 contrast-more:dark:text-gray-50", "contrast-more:border-transparent contrast-more:hover:border-gray-900 contrast-more:dark:hover:border-gray-50"), +- active: cn13("bg-primary-50 text-primary-500 dark:bg-primary-500/10 font-bold", "contrast-more:border-primary-500 contrast-more:dark:border-primary-500"), ++ link: cn13( ++ "flex rounded px-2 py-1.5 text-sm transition-colors [word-break:break-word]", ++ "[-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] contrast-more:border" ++ ), ++ inactive: cn13( ++ "hover:bg-gray-100 text-gray-500 hover:text-gray-900", ++ "dark:hover:bg-primary-100/5 dark:text-neutral-500 dark:hover:text-gray-50", ++ "contrast-more:text-gray-900 contrast-more:dark:text-gray-50", ++ "contrast-more:border-transparent contrast-more:hover:border-gray-900 contrast-more:dark:hover:border-gray-50" ++ ), ++ active: cn13( ++ "bg-primary-50 text-primary-500 dark:bg-primary-500/10 font-bold", ++ "contrast-more:border-primary-500 contrast-more:dark:border-primary-500" ++ ), + list: "flex gap-1 flex-col", +- border: cn13("relative before:absolute before:top-1.5 before:bottom-1.5", 'before:content-[""] before:w-px before:bg-gray-200 dark:before:bg-neutral-800', "ltr:pl-3 rtl:pr-3 ltr:before:left-0 rtl:before:right-0") ++ border: cn13( ++ "relative before:absolute before:top-1.5 before:bottom-1.5", ++ 'before:content-[""] before:w-px before:bg-gray-200 dark:before:bg-neutral-800', ++ "ltr:pl-3 rtl:pr-3 ltr:before:left-0 rtl:before:right-0" ++ ) + }; + function FolderImpl({ + item, + anchors + }) { +- const { asPath, locale = DEFAULT_LOCALE } = useRouter7(); ++ const { asPath, locale = DEFAULT_LOCALE } = useRouter8(); + const routeOriginal = getFSRoute(asPath, locale); + const [route] = routeOriginal.split("#"); + const active = [route, route + "/"].includes(item.route + "/"); +@@ -1765,15 +1968,17 @@ function FolderImpl({ + const { setMenu } = useMenu(); + const config = useConfig(); + const open = TreeState[item.route] !== void 0 ? TreeState[item.route] : active || activeRouteInside || !config.sidebar.defaultMenuCollapsed; +- const rerender = useState6({})[1]; +- useEffect4(() => { ++ const rerender = useState7({})[1]; ++ useEffect5(() => { + if (activeRouteInside) { + TreeState[item.route] = true; + } + }, [activeRouteInside]); + if (item.type === "menu") { + const menu = item; +- const routes = Object.fromEntries((menu.children || []).map((route2) => [route2.name, route2])); ++ const routes = Object.fromEntries( ++ (menu.children || []).map((route2) => [route2.name, route2]) ++ ); + item.children = Object.entries(menu.items || {}).map(([key, item2]) => { + const route2 = routes[key] || { + name: key, +@@ -1783,13 +1988,19 @@ function FolderImpl({ + return __spreadValues(__spreadValues({}, route2), item2); + }); + } +- return /* @__PURE__ */ React34.createElement("li", { ++ return /* @__PURE__ */ React35.createElement("li", { + className: cn13({ open, active }) +- }, /* @__PURE__ */ React34.createElement(Anchor, { ++ }, /* @__PURE__ */ React35.createElement(Anchor, { + href: item.withIndexPage ? item.route : "", +- className: cn13("gap-2 items-center justify-between", classes3.link, active ? classes3.active : classes3.inactive), ++ className: cn13( ++ "gap-2 items-center justify-between", ++ classes3.link, ++ active ? classes3.active : classes3.inactive ++ ), + onClick: (e) => { +- const clickedToggleIcon = ["svg", "path"].includes(e.target.tagName.toLowerCase()); ++ const clickedToggleIcon = ["svg", "path"].includes( ++ e.target.tagName.toLowerCase() ++ ); + if (clickedToggleIcon) { + e.preventDefault(); + } +@@ -1811,13 +2022,16 @@ function FolderImpl({ + }, renderComponent(config.sidebar.titleComponent, { + title: item.title, + type: item.type +- }), /* @__PURE__ */ React34.createElement(ArrowRightIcon, { ++ }), /* @__PURE__ */ React35.createElement(ArrowRightIcon, { + className: "h-[18px] min-w-[18px] rounded-sm p-0.5 hover:bg-gray-800/5 dark:hover:bg-gray-100/5", +- pathClassName: cn13("origin-center transition-transform rtl:-rotate-180", open && "ltr:rotate-90 rtl:rotate-[-270deg]") +- })), /* @__PURE__ */ React34.createElement(Collapse, { ++ pathClassName: cn13( ++ "origin-center transition-transform rtl:-rotate-180", ++ open && "ltr:rotate-90 rtl:rotate-[-270deg]" ++ ) ++ })), /* @__PURE__ */ React35.createElement(Collapse, { + className: "ltr:pr-0 rtl:pl-0", + open +- }, Array.isArray(item.children) ? /* @__PURE__ */ React34.createElement(Menu2, { ++ }, Array.isArray(item.children) ? /* @__PURE__ */ React35.createElement(Menu2, { + className: cn13(classes3.border, "ltr:ml-1 rtl:mr-1"), + directories: item.children, + base: item.route, +@@ -1826,12 +2040,15 @@ function FolderImpl({ + } + function Separator({ title }) { + const config = useConfig(); +- return /* @__PURE__ */ React34.createElement("li", { +- className: cn13("[word-break:break-word]", title ? "first:mt-0 mt-5 mb-2 px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-100" : "my-4") ++ return /* @__PURE__ */ React35.createElement("li", { ++ className: cn13( ++ "[word-break:break-word]", ++ title ? "first:mt-0 mt-5 mb-2 px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-100" : "my-4" ++ ) + }, title ? renderComponent(config.sidebar.titleComponent, { + title, + type: "separator" +- }) : /* @__PURE__ */ React34.createElement("hr", { ++ }) : /* @__PURE__ */ React35.createElement("hr", { + className: "mx-2 border-t border-gray-200 dark:border-primary-100/10" + })); + } +@@ -1839,7 +2056,7 @@ function File({ + item, + anchors + }) { +- const { asPath, locale = DEFAULT_LOCALE } = useRouter7(); ++ const { asPath, locale = DEFAULT_LOCALE } = useRouter8(); + const route = getFSRoute(asPath, locale); + const active = [route, route + "/"].includes(item.route + "/"); + const slugger = new Slugger(); +@@ -1847,13 +2064,13 @@ function File({ + const { setMenu } = useMenu(); + const config = useConfig(); + if (item.type === "separator") { +- return /* @__PURE__ */ React34.createElement(Separator, { ++ return /* @__PURE__ */ React35.createElement(Separator, { + title: item.title + }); + } +- return /* @__PURE__ */ React34.createElement("li", { ++ return /* @__PURE__ */ React35.createElement("li", { + className: cn13(classes3.list, { active }) +- }, /* @__PURE__ */ React34.createElement(Anchor, { ++ }, /* @__PURE__ */ React35.createElement(Anchor, { + href: item.href || item.route, + newWindow: item.newWindow, + className: cn13(classes3.link, active ? classes3.active : classes3.inactive), +@@ -1863,16 +2080,20 @@ function File({ + }, renderComponent(config.sidebar.titleComponent, { + title: item.title, + type: item.type +- })), active && anchors.length > 0 && /* @__PURE__ */ React34.createElement("ul", { ++ })), active && anchors.length > 0 && /* @__PURE__ */ React35.createElement("ul", { + className: cn13(classes3.list, classes3.border, "ltr:ml-3 rtl:mr-3") + }, anchors.map((text) => { + var _a; + const slug = slugger.slug(text); +- return /* @__PURE__ */ React34.createElement("li", { ++ return /* @__PURE__ */ React35.createElement("li", { + key: slug +- }, /* @__PURE__ */ React34.createElement("a", { ++ }, /* @__PURE__ */ React35.createElement("a", { + href: `#${slug}`, +- className: cn13(classes3.link, 'before:opacity-25 flex gap-2 before:content-["#"]', ((_a = activeAnchor[slug]) == null ? void 0 : _a.isActive) ? classes3.active : classes3.inactive), ++ className: cn13( ++ classes3.link, ++ 'before:opacity-25 flex gap-2 before:content-["#"]', ++ ((_a = activeAnchor[slug]) == null ? void 0 : _a.isActive) ? classes3.active : classes3.inactive ++ ), + onClick: () => { + setMenu(false); + } +@@ -1880,17 +2101,19 @@ function File({ + }))); + } + function Menu2({ directories, anchors, className }) { +- return /* @__PURE__ */ React34.createElement("ul", { ++ return /* @__PURE__ */ React35.createElement("ul", { + className: cn13(classes3.list, className) +- }, directories.map((item) => item.type === "menu" || item.children && (item.children.length || !item.withIndexPage) ? /* @__PURE__ */ React34.createElement(Folder, { +- key: item.name, +- item, +- anchors +- }) : /* @__PURE__ */ React34.createElement(File, { +- key: item.name, +- item, +- anchors +- }))); ++ }, directories.map( ++ (item) => item.type === "menu" || item.children && (item.children.length || !item.withIndexPage) ? /* @__PURE__ */ React35.createElement(Folder, { ++ key: item.name, ++ item, ++ anchors ++ }) : /* @__PURE__ */ React35.createElement(File, { ++ key: item.name, ++ item, ++ anchors ++ }) ++ )); + } + var emptyHeading = []; + function Sidebar({ +@@ -1903,17 +2126,20 @@ function Sidebar({ + }) { + const config = useConfig(); + const { menu, setMenu } = useMenu(); +- const anchors = useMemo2(() => headings.filter((v) => v.children && v.depth === 2 && v.type === "heading").map(getHeadingText).filter(Boolean), [headings]); ++ const anchors = useMemo2( ++ () => headings.filter((v) => v.children && v.depth === 2 && v.type === "heading").map(getHeadingText).filter(Boolean), ++ [headings] ++ ); + const sidebarRef = useRef4(null); + const containerRef = useRef4(null); +- useEffect4(() => { ++ useEffect5(() => { + if (menu) { + document.body.classList.add("overflow-hidden", "md:overflow-auto"); + } else { + document.body.classList.remove("overflow-hidden", "md:overflow-auto"); + } + }, [menu]); +- useEffect4(() => { ++ useEffect5(() => { + var _a; + const activeElement = (_a = sidebarRef.current) == null ? void 0 : _a.querySelector("li.active"); + if (activeElement && (window.innerWidth > 767 || menu)) { +@@ -1933,41 +2159,63 @@ function Sidebar({ + } + }, [menu]); + const hasMenu = config.i18n.length > 0 || config.darkMode; +- return /* @__PURE__ */ React34.createElement(React34.Fragment, null, includePlaceholder && asPopover ? /* @__PURE__ */ React34.createElement("div", { ++ return /* @__PURE__ */ React35.createElement(React35.Fragment, null, includePlaceholder && asPopover ? /* @__PURE__ */ React35.createElement("div", { + className: "hidden h-0 w-64 flex-shrink-0 xl:block" +- }) : null, /* @__PURE__ */ React34.createElement("div", { +- className: cn13("[transition:background-color_1.5s_ease] motion-reduce:transition-none", menu ? "fixed inset-0 z-10 bg-black/80 dark:bg-black/60" : "bg-transparent"), ++ }) : null, /* @__PURE__ */ React35.createElement("div", { ++ className: cn13( ++ "[transition:background-color_1.5s_ease] motion-reduce:transition-none", ++ menu ? "fixed inset-0 z-10 bg-black/80 dark:bg-black/60" : "bg-transparent" ++ ), + onClick: () => setMenu(false) +- }), /* @__PURE__ */ React34.createElement("aside", { +- className: cn13("nextra-sidebar-container flex flex-col", "md:top-16 md:flex-shrink-0 md:w-64 md:transform-none", asPopover ? "md:hidden" : "md:sticky md:self-start", menu ? "[transform:translate3d(0,0,0)]" : "[transform:translate3d(0,-100%,0)]"), ++ }), /* @__PURE__ */ React35.createElement("aside", { ++ className: cn13( ++ "nextra-sidebar-container flex flex-col", ++ "md:top-16 md:flex-shrink-0 md:w-64 md:transform-none", ++ asPopover ? "md:hidden" : "md:sticky md:self-start", ++ menu ? "[transform:translate3d(0,0,0)]" : "[transform:translate3d(0,-100%,0)]" ++ ), + ref: containerRef +- }, /* @__PURE__ */ React34.createElement("div", { +- className: cn13("z-[1]", "md:hidden p-4", "shadow-[0_2px_14px_6px_#fff] dark:shadow-[0_2px_14px_6px_#111]", "contrast-more:shadow-none dark:contrast-more:shadow-none") ++ }, /* @__PURE__ */ React35.createElement("div", { ++ className: cn13( ++ "z-[1]", ++ "md:hidden p-4", ++ "shadow-[0_2px_14px_6px_#fff] dark:shadow-[0_2px_14px_6px_#111]", ++ "contrast-more:shadow-none dark:contrast-more:shadow-none" ++ ) + }, renderComponent(config.search.component, { + directories: flatDirectories +- })), /* @__PURE__ */ React34.createElement("div", { +- className: cn13("px-4 pb-4 md:pt-4 overflow-y-auto nextra-scrollbar", "grow md:h-[calc(100vh-var(--nextra-navbar-height)-3.75rem)]"), ++ })), /* @__PURE__ */ React35.createElement("div", { ++ className: cn13( ++ "px-4 pb-4 md:pt-4 overflow-y-auto nextra-scrollbar", ++ "grow md:h-[calc(100vh-var(--nextra-navbar-height)-3.75rem)]" ++ ), + ref: sidebarRef +- }, /* @__PURE__ */ React34.createElement(Menu2, { ++ }, /* @__PURE__ */ React35.createElement(Menu2, { + className: "hidden md:flex", + directories: docsDirectories, + anchors: config.toc.float ? [] : anchors +- }), /* @__PURE__ */ React34.createElement(Menu2, { ++ }), /* @__PURE__ */ React35.createElement(Menu2, { + className: "md:hidden", + directories: fullDirectories, + anchors +- })), hasMenu && /* @__PURE__ */ React34.createElement("div", { +- className: cn13("z-[1] relative", "mx-4 py-4 border-t shadow-[0_-12px_16px_#fff]", "flex gap-2 items-center gap-2", "dark:border-neutral-800 dark:shadow-[0_-12px_16px_#111]", "contrast-more:shadow-none contrast-more:dark:shadow-none contrast-more:border-neutral-400") +- }, config.i18n.length > 0 && /* @__PURE__ */ React34.createElement(LocaleSwitch, { ++ })), hasMenu && /* @__PURE__ */ React35.createElement("div", { ++ className: cn13( ++ "z-[1] relative", ++ "mx-4 py-4 border-t shadow-[0_-12px_16px_#fff]", ++ "flex gap-2 items-center", ++ "dark:border-neutral-800 dark:shadow-[0_-12px_16px_#111]", ++ "contrast-more:shadow-none contrast-more:dark:shadow-none contrast-more:border-neutral-400" ++ ) ++ }, config.i18n.length > 0 && /* @__PURE__ */ React35.createElement(LocaleSwitch, { + options: config.i18n, + className: "grow" +- }), config.darkMode && /* @__PURE__ */ React34.createElement(ThemeSwitch, { ++ }), config.darkMode && /* @__PURE__ */ React35.createElement(ThemeSwitch, { + lite: config.i18n.length > 0 + })))); + } + + // src/components/tabs.tsx +-import React35 from "react"; ++import React36 from "react"; + import cn14 from "clsx"; + import { Tab as HeadlessTab } from "@headlessui/react"; + function isTabItem(item) { +@@ -1988,22 +2236,28 @@ function Tabs({ + onChange, + children + }) { +- return /* @__PURE__ */ React35.createElement(HeadlessTab.Group, { ++ return /* @__PURE__ */ React36.createElement(HeadlessTab.Group, { + selectedIndex, + defaultIndex, + onChange +- }, /* @__PURE__ */ React35.createElement("div", { ++ }, /* @__PURE__ */ React36.createElement("div", { + className: "no-scrollbar -m-2 overflow-x-auto overflow-y-hidden overscroll-x-contain p-2" +- }, /* @__PURE__ */ React35.createElement(HeadlessTab.List, { ++ }, /* @__PURE__ */ React36.createElement(HeadlessTab.List, { + className: "mt-4 flex w-max min-w-full border-b border-gray-200 pb-px dark:border-neutral-800" + }, items.map((item, index) => { + const disabled = !!(item && typeof item === "object" && "disabled" in item && item.disabled); +- return /* @__PURE__ */ React35.createElement(HeadlessTab, { ++ return /* @__PURE__ */ React36.createElement(HeadlessTab, { + key: index, + disabled, +- className: ({ selected }) => cn14("rounded-t", "text-md mr-2 p-2 font-medium leading-5 transition-colors", "-mb-0.5 select-none border-b-2", selected ? "border-primary-500 text-primary-500" : "border-transparent text-gray-600 hover:border-gray-200 hover:text-black dark:text-gray-200 dark:hover:border-neutral-800 dark:hover:text-white", disabled && "pointer-events-none text-gray-400 dark:text-neutral-600") ++ className: ({ selected }) => cn14( ++ "rounded-t", ++ "text-md mr-2 p-2 font-medium leading-5 transition-colors", ++ "-mb-0.5 select-none border-b-2", ++ selected ? "border-primary-500 text-primary-500" : "border-transparent text-gray-600 hover:border-gray-200 hover:text-black dark:text-gray-200 dark:hover:border-neutral-800 dark:hover:text-white", ++ disabled && "pointer-events-none text-gray-400 dark:text-neutral-600" ++ ) + }, renderTab(item)); +- }))), /* @__PURE__ */ React35.createElement(HeadlessTab.Panels, null, children)); ++ }))), /* @__PURE__ */ React36.createElement(HeadlessTab.Panels, null, children)); + } + function Tab(_a) { + var _b = _a, { +@@ -2011,13 +2265,13 @@ function Tab(_a) { + } = _b, props = __objRest(_b, [ + "children" + ]); +- return /* @__PURE__ */ React35.createElement(HeadlessTab.Panel, __spreadProps(__spreadValues({}, props), { ++ return /* @__PURE__ */ React36.createElement(HeadlessTab.Panel, __spreadProps(__spreadValues({}, props), { + className: "rounded" + }), children); + } + + // src/components/toc.tsx +-import React36, { useEffect as useEffect5, useRef as useRef5, useMemo as useMemo3 } from "react"; ++import React37, { useEffect as useEffect6, useRef as useRef5, useMemo as useMemo3 } from "react"; + import cn15 from "clsx"; + import Slugger2 from "github-slugger"; + import scrollIntoView2 from "scroll-into-view-if-needed"; +@@ -2027,18 +2281,25 @@ function TOC({ headings, filePath }) { + const activeAnchor = useActiveAnchor(); + const config = useConfig(); + const tocRef = useRef5(null); +- const items = useMemo3(() => headings.filter((heading) => heading.type === "heading" && heading.depth > 1).map((heading) => { +- const text = getHeadingText(heading); +- return { +- text, +- slug: slugger.slug(text), +- depth: heading.depth +- }; +- }), [headings]); ++ const items = useMemo3( ++ () => headings.filter((heading) => heading.type === "heading" && heading.depth > 1).map((heading) => { ++ const text = getHeadingText(heading); ++ return { ++ text, ++ slug: slugger.slug(text), ++ depth: heading.depth ++ }; ++ }), ++ [headings] ++ ); + const hasHeadings = items.length > 0; +- const hasMetaInfo = Boolean(config.feedback.content || config.editLink.component || config.toc.extraContent); +- const activeSlug = (_a = Object.entries(activeAnchor).find(([, { isActive }]) => isActive)) == null ? void 0 : _a[0]; +- useEffect5(() => { ++ const hasMetaInfo = Boolean( ++ config.feedback.content || config.editLink.component || config.toc.extraContent ++ ); ++ const activeSlug = (_a = Object.entries(activeAnchor).find( ++ ([, { isActive }]) => isActive ++ )) == null ? void 0 : _a[0]; ++ useEffect6(() => { + var _a2; + if (!activeSlug) + return; +@@ -2053,30 +2314,44 @@ function TOC({ headings, filePath }) { + }); + } + }, [activeSlug]); +- const linkClassName = cn15("text-xs font-medium text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100", "contrast-more:text-gray-800 contrast-more:dark:text-gray-50"); +- return /* @__PURE__ */ React36.createElement("div", { ++ const linkClassName = cn15( ++ "text-xs font-medium text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100", ++ "contrast-more:text-gray-800 contrast-more:dark:text-gray-50" ++ ); ++ return /* @__PURE__ */ React37.createElement("div", { + ref: tocRef, +- className: cn15("nextra-scrollbar sticky top-16 overflow-y-auto pr-4 pt-8 text-sm [hyphens:auto]", "ltr:-mr-4 rtl:-ml-4 max-h-[calc(100vh-var(--nextra-navbar-height)-env(safe-area-inset-bottom))]") +- }, hasHeadings && /* @__PURE__ */ React36.createElement(React36.Fragment, null, /* @__PURE__ */ React36.createElement("p", { ++ className: cn15( ++ "nextra-scrollbar sticky top-16 overflow-y-auto pr-4 pt-8 text-sm [hyphens:auto]", ++ "ltr:-mr-4 rtl:-ml-4 max-h-[calc(100vh-var(--nextra-navbar-height)-env(safe-area-inset-bottom))]" ++ ) ++ }, hasHeadings && /* @__PURE__ */ React37.createElement(React37.Fragment, null, /* @__PURE__ */ React37.createElement("p", { + className: "mb-4 font-semibold tracking-tight" +- }, renderComponent(config.toc.title)), /* @__PURE__ */ React36.createElement("ul", null, items.map(({ slug, text, depth }) => { ++ }, renderComponent(config.toc.title)), /* @__PURE__ */ React37.createElement("ul", null, items.map(({ slug, text, depth }) => { + var _a2; +- return /* @__PURE__ */ React36.createElement("li", { ++ return /* @__PURE__ */ React37.createElement("li", { + className: "my-2 scroll-my-6 scroll-py-6", + key: slug +- }, /* @__PURE__ */ React36.createElement("a", { ++ }, /* @__PURE__ */ React37.createElement("a", { + href: `#${slug}`, +- className: cn15({ +- 2: "font-semibold", +- 3: "ltr:ml-4 rtl:mr-4", +- 4: "ltr:ml-8 rtl:mr-8", +- 5: "ltr:ml-12 rtl:mr-12", +- 6: "ltr:ml-16 rtl:mr-16" +- }[depth], ((_a2 = activeAnchor[slug]) == null ? void 0 : _a2.isActive) ? "text-primary-500 subpixel-antialiased contrast-more:!text-primary-500" : "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300", "contrast-more:text-gray-900 contrast-more:underline contrast-more:dark:text-gray-50") ++ className: cn15( ++ { ++ 2: "font-semibold", ++ 3: "ltr:ml-4 rtl:mr-4", ++ 4: "ltr:ml-8 rtl:mr-8", ++ 5: "ltr:ml-12 rtl:mr-12", ++ 6: "ltr:ml-16 rtl:mr-16" ++ }[depth], ++ ((_a2 = activeAnchor[slug]) == null ? void 0 : _a2.isActive) ? "text-primary-500 subpixel-antialiased contrast-more:!text-primary-500" : "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300", ++ "contrast-more:text-gray-900 contrast-more:underline contrast-more:dark:text-gray-50" ++ ) + }, text)); +- }))), hasMetaInfo && /* @__PURE__ */ React36.createElement("div", { +- className: cn15(hasHeadings && "mt-8 border-t bg-white pt-8 shadow-[0_-12px_16px_white] dark:bg-dark dark:shadow-[0_-12px_16px_#111]", "sticky bottom-0 pb-8 dark:border-neutral-800 flex flex-col items-start gap-2", "contrast-more:shadow-none contrast-more:border-t contrast-more:border-neutral-400 contrast-more:dark:border-neutral-400") +- }, config.feedback.content ? /* @__PURE__ */ React36.createElement(Anchor, { ++ }))), hasMetaInfo && /* @__PURE__ */ React37.createElement("div", { ++ className: cn15( ++ hasHeadings && "mt-8 border-t bg-white pt-8 shadow-[0_-12px_16px_white] dark:bg-dark dark:shadow-[0_-12px_16px_#111]", ++ "sticky bottom-0 pb-8 dark:border-neutral-800 flex flex-col items-start gap-2", ++ "contrast-more:shadow-none contrast-more:border-t contrast-more:border-neutral-400 contrast-more:dark:border-neutral-400" ++ ) ++ }, config.feedback.content ? /* @__PURE__ */ React37.createElement(Anchor, { + className: linkClassName, + href: getGitIssueUrl({ + repository: config.docsRepositoryBase, +@@ -2092,22 +2367,27 @@ function TOC({ headings, filePath }) { + } + + // src/components/match-sorter-search.tsx +-import React37, { useMemo as useMemo4, useState as useState7 } from "react"; ++import React38, { useMemo as useMemo4, useState as useState8 } from "react"; + import { matchSorter } from "match-sorter"; + function MatchSorterSearch({ + className, + directories = [] + }) { +- const [search, setSearch] = useState7(""); +- const results = useMemo4(() => search ? matchSorter(directories, search, { keys: ["title"] }).map(({ route, title }) => ({ +- id: route + title, +- route, +- children: /* @__PURE__ */ React37.createElement(HighlightMatches, { +- value: title, +- match: search +- }) +- })) : [], [search]); +- return /* @__PURE__ */ React37.createElement(Search, { ++ const [search, setSearch] = useState8(""); ++ const results = useMemo4( ++ () => search ? matchSorter(directories, search, { keys: ["title"] }).map( ++ ({ route, title }) => ({ ++ id: route + title, ++ route, ++ children: /* @__PURE__ */ React38.createElement(HighlightMatches, { ++ value: title, ++ match: search ++ }) ++ }) ++ ) : [], ++ [search] ++ ); ++ return /* @__PURE__ */ React38.createElement(Search, { + value: search, + onChange: setSearch, + className, +@@ -2125,7 +2405,7 @@ var DEFAULT_THEME = { + text: "" + }, + chat: { +- icon: /* @__PURE__ */ React38.createElement(React38.Fragment, null, /* @__PURE__ */ React38.createElement(DiscordIcon, null), /* @__PURE__ */ React38.createElement("span", { ++ icon: /* @__PURE__ */ React39.createElement(React39.Fragment, null, /* @__PURE__ */ React39.createElement(DiscordIcon, null), /* @__PURE__ */ React39.createElement("span", { + className: "sr-only" + }, "Discord")), + link: "" +@@ -2140,7 +2420,7 @@ var DEFAULT_THEME = { + if (!editUrl) { + return null; + } +- return /* @__PURE__ */ React38.createElement(Anchor, { ++ return /* @__PURE__ */ React39.createElement(Anchor, { + className, + href: editUrl + }, children); +@@ -2157,42 +2437,42 @@ var DEFAULT_THEME = { + text: `MIT ${new Date().getFullYear()} \xA9 Nextra.` + }, + gitTimestamp({ timestamp }) { +- const { locale = DEFAULT_LOCALE } = useRouter8(); +- return /* @__PURE__ */ React38.createElement(React38.Fragment, null, "Last updated on", " ", timestamp.toLocaleDateString(locale, { ++ const { locale = DEFAULT_LOCALE } = useRouter9(); ++ return /* @__PURE__ */ React39.createElement(React39.Fragment, null, "Last updated on", " ", timestamp.toLocaleDateString(locale, { + day: "numeric", + month: "long", + year: "numeric" + })); + }, +- head: /* @__PURE__ */ React38.createElement(React38.Fragment, null, /* @__PURE__ */ React38.createElement("meta", { ++ head: /* @__PURE__ */ React39.createElement(React39.Fragment, null, /* @__PURE__ */ React39.createElement("meta", { + name: "msapplication-TileColor", + content: "#fff" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + httpEquiv: "Content-Language", + content: "en" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + name: "description", + content: "Nextra: the next docs builder" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + name: "twitter:card", + content: "summary_large_image" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + name: "twitter:site", + content: "@shuding_" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + property: "og:title", + content: "Nextra: the next docs builder" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + property: "og:description", + content: "Nextra: the next docs builder" +- }), /* @__PURE__ */ React38.createElement("meta", { ++ }), /* @__PURE__ */ React39.createElement("meta", { + name: "apple-mobile-web-app-title", + content: "Nextra" + })), + i18n: [], +- logo: /* @__PURE__ */ React38.createElement(React38.Fragment, null, /* @__PURE__ */ React38.createElement("span", { ++ logo: /* @__PURE__ */ React39.createElement(React39.Fragment, null, /* @__PURE__ */ React39.createElement("span", { + className: "mr-2 hidden font-extrabold md:inline" +- }, "Nextra"), /* @__PURE__ */ React38.createElement("span", { ++ }, "Nextra"), /* @__PURE__ */ React39.createElement("span", { + className: "hidden font-normal text-gray-600 md:inline" + }, "The Next Docs Builder")), + main: { +@@ -2216,7 +2496,7 @@ var DEFAULT_THEME = { + light: 212 + }, + project: { +- icon: /* @__PURE__ */ React38.createElement(React38.Fragment, null, /* @__PURE__ */ React38.createElement(GitHubIcon, null), /* @__PURE__ */ React38.createElement("span", { ++ icon: /* @__PURE__ */ React39.createElement(React39.Fragment, null, /* @__PURE__ */ React39.createElement(GitHubIcon, null), /* @__PURE__ */ React39.createElement("span", { + className: "sr-only" + }, "GitHub")), + link: "" +@@ -2224,18 +2504,18 @@ var DEFAULT_THEME = { + search: { + component({ className, directories }) { + const config = useConfig(); +- return config.unstable_flexsearch ? /* @__PURE__ */ React38.createElement(Flexsearch, { ++ return config.unstable_flexsearch ? /* @__PURE__ */ React39.createElement(Flexsearch, { + className +- }) : /* @__PURE__ */ React38.createElement(MatchSorterSearch, { ++ }) : /* @__PURE__ */ React39.createElement(MatchSorterSearch, { + className, + directories + }); + }, +- emptyResult: /* @__PURE__ */ React38.createElement("span", { ++ emptyResult: /* @__PURE__ */ React39.createElement("span", { + className: "block select-none p-8 text-center text-sm text-gray-400" + }, "No results found."), + placeholder() { +- const { locale } = useRouter8(); ++ const { locale } = useRouter9(); + if (locale === "zh-CN") + return "\u641C\u7D22\u6587\u6863..."; + return "Search documentation..."; +@@ -2247,7 +2527,7 @@ var DEFAULT_THEME = { + }, + sidebar: { + defaultMenuCollapsed: false, +- titleComponent: ({ title }) => /* @__PURE__ */ React38.createElement(React38.Fragment, null, title) ++ titleComponent: ({ title }) => /* @__PURE__ */ React39.createElement(React39.Fragment, null, title) + }, + titleSuffix: " \u2013 Nextra", + toc: { +@@ -2316,10 +2596,10 @@ if (IS_BROWSER) { + } + + // src/mdx-components.tsx +-import React40, { +- useEffect as useEffect7, ++import React41, { ++ useEffect as useEffect8, + useRef as useRef6, +- useState as useState9, ++ useState as useState10, + cloneElement, + Children + } from "react"; +@@ -2327,11 +2607,11 @@ import "intersection-observer"; + import cn16 from "clsx"; + + // ../nextra/dist/components/index.mjs +-import React39 from "react"; ++import React40 from "react"; + import React210, { + useCallback as useCallback4, +- useEffect as useEffect6, +- useState as useState8 ++ useEffect as useEffect7, ++ useState as useState9 + } from "react"; + import React310 from "react"; + import React43, { useCallback as useCallback22 } from "react"; +@@ -2347,7 +2627,7 @@ var Button = (_a) => { + "children", + "className" + ]); +- return /* @__PURE__ */ React39.createElement("button", __spreadValues2({ ++ return /* @__PURE__ */ React40.createElement("button", __spreadValues2({ + className: [ + "nextra-button transition-colors", + "bg-primary-700/5 border border-black/5 text-gray-600 hover:text-gray-900 rounded-md p-2", +@@ -2362,8 +2642,8 @@ var CopyToClipboard = (_a) => { + } = _b, props = __objRest2(_b, [ + "value" + ]); +- const [isCopied, setCopied] = useState8(false); +- useEffect6(() => { ++ const [isCopied, setCopied] = useState9(false); ++ useEffect7(() => { + if (!isCopied) + return; + const timerId = setTimeout(() => { +@@ -2484,43 +2764,46 @@ var observer; + var setActiveAnchor; + var slugs = /* @__PURE__ */ new WeakMap(); + if (IS_BROWSER) { +- observer || (observer = new IntersectionObserver((entries) => { +- setActiveAnchor((f) => { +- const ret = __spreadValues({}, f); +- for (const entry of entries) { +- if ((entry == null ? void 0 : entry.rootBounds) && slugs.has(entry.target)) { +- const [slug, index] = slugs.get(entry.target); +- const aboveHalfViewport = entry.boundingClientRect.y + entry.boundingClientRect.height <= entry.rootBounds.y + entry.rootBounds.height; +- const insideHalfViewport = entry.intersectionRatio > 0; +- ret[slug] = { +- index, +- aboveHalfViewport, +- insideHalfViewport +- }; +- } +- } +- let activeSlug = ""; +- let smallestIndexInViewport = Infinity; +- let largestIndexAboveViewport = -1; +- for (let s in ret) { +- ret[s].isActive = false; +- if (ret[s].insideHalfViewport && ret[s].index < smallestIndexInViewport) { +- smallestIndexInViewport = ret[s].index; +- activeSlug = s; ++ observer || (observer = new IntersectionObserver( ++ (entries) => { ++ setActiveAnchor((f) => { ++ const ret = __spreadValues({}, f); ++ for (const entry of entries) { ++ if ((entry == null ? void 0 : entry.rootBounds) && slugs.has(entry.target)) { ++ const [slug, index] = slugs.get(entry.target); ++ const aboveHalfViewport = entry.boundingClientRect.y + entry.boundingClientRect.height <= entry.rootBounds.y + entry.rootBounds.height; ++ const insideHalfViewport = entry.intersectionRatio > 0; ++ ret[slug] = { ++ index, ++ aboveHalfViewport, ++ insideHalfViewport ++ }; ++ } + } +- if (smallestIndexInViewport === Infinity && ret[s].aboveHalfViewport && ret[s].index > largestIndexAboveViewport) { +- largestIndexAboveViewport = ret[s].index; +- activeSlug = s; ++ let activeSlug = ""; ++ let smallestIndexInViewport = Infinity; ++ let largestIndexAboveViewport = -1; ++ for (let s in ret) { ++ ret[s].isActive = false; ++ if (ret[s].insideHalfViewport && ret[s].index < smallestIndexInViewport) { ++ smallestIndexInViewport = ret[s].index; ++ activeSlug = s; ++ } ++ if (smallestIndexInViewport === Infinity && ret[s].aboveHalfViewport && ret[s].index > largestIndexAboveViewport) { ++ largestIndexAboveViewport = ret[s].index; ++ activeSlug = s; ++ } + } +- } +- if (ret[activeSlug]) +- ret[activeSlug].isActive = true; +- return ret; +- }); +- }, { +- rootMargin: "0px 0px -50%", +- threshold: [0, 1] +- })); ++ if (ret[activeSlug]) ++ ret[activeSlug].isActive = true; ++ return ret; ++ }); ++ }, ++ { ++ rootMargin: "0px 0px -50%", ++ threshold: [0, 1] ++ } ++ )); + } + var createHeaderLink = (Tag, context) => function HeaderLink(_a) { + var _b = _a, { +@@ -2532,7 +2815,7 @@ var createHeaderLink = (Tag, context) => function HeaderLink(_a) { + ]); + setActiveAnchor != null ? setActiveAnchor : setActiveAnchor = useSetActiveAnchor(); + const obRef = useRef6(null); +- useEffect7(() => { ++ useEffect8(() => { + const heading = obRef.current; + if (!heading) + return; +@@ -2548,19 +2831,22 @@ var createHeaderLink = (Tag, context) => function HeaderLink(_a) { + }); + }; + }, []); +- return /* @__PURE__ */ React40.createElement(Tag, __spreadValues({ +- className: cn16("font-semibold tracking-tight", { +- h2: "mt-10 text-3xl border-b pb-1 dark:border-primary-100/10 contrast-more:border-neutral-400 contrast-more:dark:border-neutral-400", +- h3: "mt-8 text-2xl", +- h4: "mt-8 text-xl", +- h5: "mt-8 text-lg", +- h6: "mt-8 text-base" +- }[Tag]) +- }, props), /* @__PURE__ */ React40.createElement("span", { ++ return /* @__PURE__ */ React41.createElement(Tag, __spreadValues({ ++ className: cn16( ++ "font-semibold tracking-tight", ++ { ++ h2: "mt-10 text-3xl border-b pb-1 dark:border-primary-100/10 contrast-more:border-neutral-400 contrast-more:dark:border-neutral-400", ++ h3: "mt-8 text-2xl", ++ h4: "mt-8 text-xl", ++ h5: "mt-8 text-lg", ++ h6: "mt-8 text-base" ++ }[Tag] ++ ) ++ }, props), /* @__PURE__ */ React41.createElement("span", { + className: "subheading-anchor -mt-20", + id, + ref: obRef +- }), /* @__PURE__ */ React40.createElement("a", { ++ }), /* @__PURE__ */ React41.createElement("a", { + href: `#${id}` + }, children)); + }; +@@ -2594,10 +2880,10 @@ var Details = (_a) => { + "children", + "open" + ]); +- const [openState, setOpen] = useState9(!!open); ++ const [openState, setOpen] = useState10(!!open); + const [summary, restChildren] = findSummary(children); +- const [delayedOpenState, setDelayedOpenState] = useState9(openState); +- useEffect7(() => { ++ const [delayedOpenState, setDelayedOpenState] = useState10(openState); ++ useEffect8(() => { + if (openState) { + setDelayedOpenState(true); + } else { +@@ -2605,20 +2891,24 @@ var Details = (_a) => { + return () => clearTimeout(timeout); + } + }, [openState]); +- return /* @__PURE__ */ React40.createElement("details", __spreadValues(__spreadProps(__spreadValues({ ++ return /* @__PURE__ */ React41.createElement("details", __spreadValues(__spreadProps(__spreadValues({ + className: "my-4 rounded border border-gray-200 bg-white p-2 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 first:mt-0 last:mb-0" + }, props), { + open: delayedOpenState +- }), openState && { "data-expanded": true }), /* @__PURE__ */ React40.createElement(DetailsProvider, { ++ }), openState && { "data-expanded": true }), /* @__PURE__ */ React41.createElement(DetailsProvider, { + value: setOpen +- }, summary), /* @__PURE__ */ React40.createElement(Collapse, { ++ }, summary), /* @__PURE__ */ React41.createElement(Collapse, { + open: openState + }, restChildren)); + }; + var Summary = (props) => { + const setOpen = useDetails(); +- return /* @__PURE__ */ React40.createElement("summary", __spreadProps(__spreadValues({ +- className: cn16("list-none cursor-pointer p-1 transition-colors hover:bg-gray-100 dark:hover:bg-neutral-800", "before:mr-1 before:content-[''] before:inline-block before:transition-transform dark:before:invert", "[[data-expanded]>&]:before:rotate-90 rtl:before:rotate-180") ++ return /* @__PURE__ */ React41.createElement("summary", __spreadProps(__spreadValues({ ++ className: cn16( ++ "list-none cursor-pointer p-1 transition-colors hover:bg-gray-100 dark:hover:bg-neutral-800", ++ "before:mr-1 before:content-[''] before:inline-block before:transition-transform dark:before:invert", ++ "[[data-expanded]>&]:before:rotate-90 rtl:before:rotate-180" ++ ) + }, props), { + onClick: (e) => { + e.preventDefault(); +@@ -2628,7 +2918,7 @@ var Summary = (props) => { + }; + var A = (_a) => { + var _b = _a, { href = "" } = _b, props = __objRest(_b, ["href"]); +- return /* @__PURE__ */ React40.createElement(Anchor, __spreadValues({ ++ return /* @__PURE__ */ React41.createElement(Anchor, __spreadValues({ + href, + newWindow: href.startsWith("https://") + }, props)); +@@ -2642,7 +2932,7 @@ var getComponents = ({ + } + const context = { index: 0 }; + return __spreadValues({ +- h1: (props) => /* @__PURE__ */ React40.createElement("h1", __spreadValues({ ++ h1: (props) => /* @__PURE__ */ React41.createElement("h1", __spreadValues({ + className: "mt-2 text-4xl font-bold tracking-tight" + }, props)), + h2: createHeaderLink("h2", context), +@@ -2650,28 +2940,31 @@ var getComponents = ({ + h4: createHeaderLink("h4", context), + h5: createHeaderLink("h5", context), + h6: createHeaderLink("h6", context), +- ul: (props) => /* @__PURE__ */ React40.createElement("ul", __spreadValues({ ++ ul: (props) => /* @__PURE__ */ React41.createElement("ul", __spreadValues({ + className: "ltr:ml-6 rtl:mr-6 mt-6 list-disc first:mt-0" + }, props)), +- ol: (props) => /* @__PURE__ */ React40.createElement("ol", __spreadValues({ ++ ol: (props) => /* @__PURE__ */ React41.createElement("ol", __spreadValues({ + className: "ltr:ml-6 rtl:mr-6 mt-6 list-decimal" + }, props)), +- li: (props) => /* @__PURE__ */ React40.createElement("li", __spreadValues({ ++ li: (props) => /* @__PURE__ */ React41.createElement("li", __spreadValues({ + className: "my-2" + }, props)), +- blockquote: (props) => /* @__PURE__ */ React40.createElement("blockquote", __spreadValues({ +- className: cn16("mt-6 first:mt-0 border-gray-300 italic text-gray-700 dark:border-gray-700 dark:text-gray-400", "rtl:border-r-2 rtl:pr-6 ltr:border-l-2 ltr:pl-6") ++ blockquote: (props) => /* @__PURE__ */ React41.createElement("blockquote", __spreadValues({ ++ className: cn16( ++ "mt-6 first:mt-0 border-gray-300 italic text-gray-700 dark:border-gray-700 dark:text-gray-400", ++ "rtl:border-r-2 rtl:pr-6 ltr:border-l-2 ltr:pl-6" ++ ) + }, props)), +- hr: (props) => /* @__PURE__ */ React40.createElement("hr", __spreadValues({ ++ hr: (props) => /* @__PURE__ */ React41.createElement("hr", __spreadValues({ + className: "my-8 dark:border-gray-900" + }, props)), +- a: (props) => /* @__PURE__ */ React40.createElement(A, __spreadProps(__spreadValues({}, props), { ++ a: (props) => /* @__PURE__ */ React41.createElement(A, __spreadProps(__spreadValues({}, props), { + className: "text-primary-500 underline decoration-from-font [text-underline-position:under]" + })), +- table: (props) => /* @__PURE__ */ React40.createElement(Table, __spreadValues({ ++ table: (props) => /* @__PURE__ */ React41.createElement(Table, __spreadValues({ + className: "nextra-scrollbar mt-6 first:mt-0 p-0" + }, props)), +- p: (props) => /* @__PURE__ */ React40.createElement("p", __spreadValues({ ++ p: (props) => /* @__PURE__ */ React41.createElement("p", __spreadValues({ + className: "mt-6 first:mt-0 leading-7" + }, props)), + tr: Tr, +@@ -2699,7 +2992,7 @@ if (IS_BROWSER) { + })); + } + function useDirectoryInfo(pageMap) { +- const { locale = DEFAULT_LOCALE, defaultLocale, route } = useRouter9(); ++ const { locale = DEFAULT_LOCALE, defaultLocale, route } = useRouter10(); + return useMemo5(() => { + const fsPath = getFSRoute(route, locale); + return normalizePages({ +@@ -2719,7 +3012,7 @@ var Body = ({ + }) => { + const mainElement = useRef7(null); + const config = useConfig(); +- useEffect8(() => { ++ useEffect9(() => { + if (mainElement.current) { + resizeObserver.observe(mainElement.current); + } +@@ -2728,25 +3021,28 @@ var Body = ({ + }; + }, []); + if (themeContext.layout === "raw") { +- return /* @__PURE__ */ React41.createElement("div", { ++ return /* @__PURE__ */ React44.createElement("div", { + className: "w-full overflow-x-hidden" + }, children); + } + const date = themeContext.timestamp && config.gitTimestamp && timestamp ? new Date(timestamp) : null; +- const gitTimestampEl = date ? /* @__PURE__ */ React41.createElement("div", { ++ const gitTimestampEl = date ? /* @__PURE__ */ React44.createElement("div", { + className: "pointer-default mt-12 mb-8 block ltr:text-right rtl:text-left text-xs text-gray-500 dark:text-gray-400" +- }, renderComponent(config.gitTimestamp, { timestamp: date })) : /* @__PURE__ */ React41.createElement("div", { ++ }, renderComponent(config.gitTimestamp, { timestamp: date })) : /* @__PURE__ */ React44.createElement("div", { + className: "mt-16" + }); +- const body = /* @__PURE__ */ React41.createElement(React41.Fragment, null, children, gitTimestampEl, navigation, renderComponent(config.main.extraContent)); ++ const body = /* @__PURE__ */ React44.createElement(React44.Fragment, null, children, gitTimestampEl, navigation, renderComponent(config.main.extraContent)); + if (themeContext.layout === "full") { +- return /* @__PURE__ */ React41.createElement("article", { ++ return /* @__PURE__ */ React44.createElement("article", { + className: "min-h-[calc(100vh-4rem)] w-full overflow-x-hidden pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)]" + }, body); + } +- return /* @__PURE__ */ React41.createElement("article", { +- className: cn17("min-h-[calc(100vh-4rem)] w-full flex min-w-0 max-w-full justify-center pb-8 pr-[calc(env(safe-area-inset-right)-1.5rem)]", themeContext.typesetting === "article" && "nextra-body-typesetting-article") +- }, /* @__PURE__ */ React41.createElement("main", { ++ return /* @__PURE__ */ React44.createElement("article", { ++ className: cn17( ++ "min-h-[calc(100vh-4rem)] w-full flex min-w-0 max-w-full justify-center pb-8 pr-[calc(env(safe-area-inset-right)-1.5rem)]", ++ themeContext.typesetting === "article" && "nextra-body-typesetting-article" ++ ) ++ }, /* @__PURE__ */ React44.createElement("main", { + className: "w-full min-w-0 max-w-4xl px-6 pt-4 md:px-8", + ref: mainElement + }, breadcrumb, body)); +@@ -2774,47 +3070,50 @@ var InnerLayout = ({ + const themeContext = __spreadValues(__spreadValues({}, activeThemeContext), frontMatter); + const hideSidebar = !themeContext.sidebar || themeContext.layout === "raw" || activeType === "page"; + const tocClassName = "nextra-toc order-last hidden w-64 flex-shrink-0 xl:block"; +- const tocEl = activeType === "page" || !themeContext.toc || themeContext.layout !== "default" ? themeContext.layout !== "full" && themeContext.layout !== "raw" && /* @__PURE__ */ React41.createElement("div", { ++ const tocEl = activeType === "page" || !themeContext.toc || themeContext.layout !== "default" ? themeContext.layout !== "full" && themeContext.layout !== "raw" && /* @__PURE__ */ React44.createElement("div", { + className: tocClassName +- }) : /* @__PURE__ */ React41.createElement("div", { ++ }) : /* @__PURE__ */ React44.createElement("div", { + className: cn17(tocClassName, "px-4") + }, renderComponent(config.toc.component, { + headings: config.toc.float ? headings : [], + filePath + })); +- const { locale = DEFAULT_LOCALE } = useRouter9(); ++ const { locale = DEFAULT_LOCALE } = useRouter10(); + const localeConfig = config.i18n.find((l) => l.locale === locale); + const isRTL = localeConfig ? localeConfig.direction === "rtl" : config.direction === "rtl"; + const direction = isRTL ? "rtl" : "ltr"; +- return /* @__PURE__ */ React41.createElement("div", { ++ return /* @__PURE__ */ React44.createElement("div", { + dir: direction +- }, /* @__PURE__ */ React41.createElement("script", { ++ }, /* @__PURE__ */ React44.createElement("script", { + dangerouslySetInnerHTML: { + __html: `document.documentElement.setAttribute('dir','${direction}')` + } +- }), /* @__PURE__ */ React41.createElement(Head, null), /* @__PURE__ */ React41.createElement(Banner, null), themeContext.navbar && renderComponent(config.navbar, { ++ }), /* @__PURE__ */ React44.createElement(Head, null), /* @__PURE__ */ React44.createElement(Banner, null), themeContext.navbar && renderComponent(config.navbar, { + flatDirectories, + items: topLevelNavbarItems +- }), /* @__PURE__ */ React41.createElement("div", { +- className: cn17("mx-auto flex", themeContext.layout !== "raw" && "max-w-[90rem]") +- }, /* @__PURE__ */ React41.createElement(ActiveAnchorProvider, null, /* @__PURE__ */ React41.createElement(Sidebar, { ++ }), /* @__PURE__ */ React44.createElement("div", { ++ className: cn17( ++ "mx-auto flex", ++ themeContext.layout !== "raw" && "max-w-[90rem]" ++ ) ++ }, /* @__PURE__ */ React44.createElement(ActiveAnchorProvider, null, /* @__PURE__ */ React44.createElement(Sidebar, { + docsDirectories, + flatDirectories, + fullDirectories: directories, + headings, + asPopover: hideSidebar, + includePlaceholder: themeContext.layout === "default" +- }), tocEl, /* @__PURE__ */ React41.createElement(SkipNavContent, null), /* @__PURE__ */ React41.createElement(Body, { ++ }), tocEl, /* @__PURE__ */ React44.createElement(SkipNavContent, null), /* @__PURE__ */ React44.createElement(Body, { + themeContext, +- breadcrumb: activeType !== "page" && themeContext.breadcrumb ? /* @__PURE__ */ React41.createElement(Breadcrumb, { ++ breadcrumb: activeType !== "page" && themeContext.breadcrumb ? /* @__PURE__ */ React44.createElement(Breadcrumb, { + activePath + }) : null, + timestamp, +- navigation: activeType !== "page" && themeContext.pagination ? /* @__PURE__ */ React41.createElement(NavLinks, { ++ navigation: activeType !== "page" && themeContext.pagination ? /* @__PURE__ */ React44.createElement(NavLinks, { + flatDirectories: flatDocsDirectories, + currentIndex: activeIndex + }) : null +- }, /* @__PURE__ */ React41.createElement(MDXProvider, { ++ }, /* @__PURE__ */ React44.createElement(MDXProvider, { + components: getComponents({ + isRawLayout: themeContext.layout === "raw", + components: config.components +@@ -2822,14 +3121,14 @@ var InnerLayout = ({ + }, children)))), themeContext.footer && renderComponent(config.footer.component, { menu: hideSidebar })); + }; + function Layout(props) { +- const { route } = useRouter9(); ++ const { route } = useRouter10(); + const context = globalThis.__nextra_pageContext__[route]; + if (!context) + throw new Error(`No content found for ${route}.`); + const { pageOpts, Content } = context; +- return /* @__PURE__ */ React41.createElement(ConfigProvider, { ++ return /* @__PURE__ */ React44.createElement(ConfigProvider, { + value: context +- }, /* @__PURE__ */ React41.createElement(InnerLayout, __spreadValues({}, pageOpts), /* @__PURE__ */ React41.createElement(Content, __spreadValues({}, props)))); ++ }, /* @__PURE__ */ React44.createElement(InnerLayout, __spreadValues({}, pageOpts), /* @__PURE__ */ React44.createElement(Content, __spreadValues({}, props)))); + } + export { + Bleed, \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b9fd0057a..0ecbe6411 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "importHelpers": true, "experimentalDecorators": true, "module": "node16", - "target": "es2018", + "target": "ES2020", "lib": ["es6", "esnext", "es2015", "dom"], "suppressImplicitAnyIndexErrors": true, "moduleResolution": "node", diff --git a/website/algolia-lockfile.json b/website/algolia-lockfile.json index 9e359f524..d5281857e 100644 --- a/website/algolia-lockfile.json +++ b/website/algolia-lockfile.json @@ -361,11 +361,6 @@ "children": [], "title": "`onSchemaChange(api)`", "anchor": "onschemachangeapi" - }, - { - "children": [], - "title": "`onResolverCalled(api)`", - "anchor": "onresolvercalledapi" } ], "title": "`onPluginInit(api)`", diff --git a/website/next.config.mjs b/website/next.config.mjs index 426418e62..17b26b6ce 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -19,6 +19,10 @@ export default withGuildDocs({ '/plugins/use-depth-limit': '/plugins/graphql-armor-max-depth', '/docs/introduction': '/docs', '/docs/plugins/introduction': '/docs/plugins', + '/plugins/use-async-schema': '/docs/guides/migrating-from-v2-to-v3#3-remove-useasyncschema-plugin', + '/plugins/use-timing': '/docs/guides/migrating-from-v2-to-v3#2-drop-usetiming-plugin', + '/plugins/use-lazy-loaded-schema': + '/docs/guides/migrating-from-v2-to-v3#4-rename-uselazyloadedschema-to-useschemabycontext', }).map(([from, to]) => ({ source: from, destination: to, diff --git a/website/src/lib/plugins.ts b/website/src/lib/plugins.ts index 83fafb2f0..67dd57bcf 100644 --- a/website/src/lib/plugins.ts +++ b/website/src/lib/plugins.ts @@ -74,22 +74,11 @@ export const pluginsArr: { tags: ['core', 'schema'], }, { - identifier: 'use-async-schema', - title: 'useAsyncSchema', + identifier: 'use-schema-by-context', + title: 'useSchemaByContext', githubReadme: { repo: 'n1ru4l/envelop', - path: 'packages/core/docs/use-async-schema.md', - }, - npmPackage: '@envelop/core', - icon: envelopIcon, - tags: ['core', 'schema'], - }, - { - identifier: 'use-lazy-loaded-schema', - title: 'useLazyLoadedSchema', - githubReadme: { - repo: 'n1ru4l/envelop', - path: 'packages/core/docs/use-lazy-loaded-schema.md', + path: 'packages/core/docs/use-schema-by-context.md', }, npmPackage: '@envelop/core', icon: envelopIcon, @@ -117,6 +106,17 @@ export const pluginsArr: { icon: envelopIcon, tags: ['core', 'errors', 'security'], }, + { + identifier: 'use-engine', + title: 'useEngine', + githubReadme: { + repo: 'n1ru4l/envelop', + path: 'packages/core/docs/use-engine.md', + }, + npmPackage: '@envelop/core', + icon: envelopIcon, + tags: ['core', 'utilities'], + }, { identifier: 'use-extend-context', title: 'useExtendContext', @@ -161,17 +161,6 @@ export const pluginsArr: { icon: envelopIcon, tags: ['core', 'utilities'], }, - { - identifier: 'use-timing', - title: 'useTiming', - githubReadme: { - repo: 'n1ru4l/envelop', - path: 'packages/core/docs/use-timing.md', - }, - npmPackage: '@envelop/core', - icon: envelopIcon, - tags: ['core', 'tracing', 'utilities'], - }, { identifier: 'use-graphql-jit', title: 'useGraphQLJit', diff --git a/website/src/pages/_meta.json b/website/src/pages/_meta.json index 1137b7785..32e0ff627 100644 --- a/website/src/pages/_meta.json +++ b/website/src/pages/_meta.json @@ -7,9 +7,15 @@ "layout": "raw" } }, + "v3": { + "title": "v3", + "type": "page", + "versioned": true + }, "docs": { - "title": "Docs", - "type": "page" + "title": "v2", + "type": "page", + "versioned": true }, "plugins": { "title": "Plugins", diff --git a/website/src/pages/v3/_meta.json b/website/src/pages/v3/_meta.json new file mode 100644 index 000000000..6ce5d607a --- /dev/null +++ b/website/src/pages/v3/_meta.json @@ -0,0 +1,10 @@ +{ + "index": "Introduction", + "getting-started": "First Steps", + "integrations": "Integrations and Examples", + "core": "@envelop/core", + "composing-envelop": "Sharing Envelops", + "tracing": "Tracing", + "plugins": "Plugins", + "guides": "Guides" +} diff --git a/website/src/pages/v3/composing-envelop.mdx b/website/src/pages/v3/composing-envelop.mdx new file mode 100644 index 000000000..0faedacff --- /dev/null +++ b/website/src/pages/v3/composing-envelop.mdx @@ -0,0 +1,37 @@ +# Sharing Envelops + +## Sharing / Composing `envelop` Instances + +After an `envelop` has been created, you can re-use the envelop and compose it with different envelops or plugins. This is useful if you wish to create a predefined layer of plugins and share it with others. +This allows writing shareable pieces that can be used. + +Here's a small example for sharing envelops: + +```ts +import { parse, validate, execute, subscribe } from 'graphql' +import { envelop, useEnvelop, useSchema, useEngine } from '@envelop/core' + +// Somewhere where you wish to create the basics of what you wish to share +// This defined the base plugins you wish to use as a base. +const myBaseEnvelop = envelop({ + plugins: [useOrgAuth(), useOrgTracing(), useOrgLogsCollector()] +}) + +// Later, when you create your own Envelop, you can extend that and add custom plugins. +// You can also specify the schema only at this point +const myEnvelop = envelop({ + plugins: [ + useEngine({ + parse, + validate, + execute, + subscribe + }), + useEnvelop(myBaseEnvelop), + useSchema(myServerSchema), + useMyCustomPlugin() + ] +}) +``` + +This approach allows developers to create a base Envelop and share it across the organization: you can define your monitoring setup, logging, authentication, etc., only once in a shared package and share it with others without losing the ability to extend it. diff --git a/website/src/pages/v3/core.mdx b/website/src/pages/v3/core.mdx new file mode 100644 index 000000000..92d13095f --- /dev/null +++ b/website/src/pages/v3/core.mdx @@ -0,0 +1,178 @@ +import { Callout } from '@theguild/components' + +# `@envelop/core` + +This is the core package for `envelop`, it comes with the execution pipeline wrapper and some basic core plugins you can use for creating your initial Envelop instance. + +## Built-In Plugins + +### `useSchema` + +This plugin is the simplest plugin for specifying your GraphQL schema. You can specify a schema created from any tool that emits `GraphQLSchema` object. + +```ts +import { envelop, useSchema, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const mySchema = buildSchema(/* ... */) + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useSchema(mySchema) + // ... other plugins + ] +}) +``` + +### `useErrorHandler` + +This plugin invokes a custom function every time execution encounters an error. + +```ts +import { envelop, useErrorHandler, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useErrorHandler(error => { + // This callback is called per each GraphQLError emitted during the execution phase + }) + // ... other plugins + ] +}) +``` + + + **Note:** The handler is invoked for each error. So an execution result with multiple errors will yield multiple + calls. + + +### `useExtendContext` + +Easily extends the context with custom fields. + +```ts +import { envelop, useExtendContext, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useExtendContext(async contextSoFar => { + return { + myCustomField: { + /* ... */ + } + } + }) + // ... other plugins + ] +}) +``` + +### `useLogger` + +Logs parameters and information about the execution phases. You can easily plug in your custom logger. + +```ts +import { envelop, useLogger, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + useLogger({ + logFn(eventName, args) { + // Event could be `execute-start` / `execute-end` / `subscribe-start` / `subscribe-end` + // `args` will include the arguments passed to execute/subscribe (in case of "start" event) and additional result in case of "end" event. + } + }) + // ... other plugins + ] +}) +``` + +### `usePayloadFormatter` + +Allow you to format/modify the execution result payload before returning it to your consumer. + +The second argument `executionArgs` provides additional information for your formatter. It consists of `contextValue`, `variableValues`, `document`, `operationName`, and other properties. + +```ts +import { envelop, usePayloadFormatter, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + usePayloadFormatter((result, executionArgs) => { + // Return a modified result here, + // Or `false`y value to keep it as-is. + }) + // ... other plugins + ] +}) +``` + +### `useEngine` + +This plugin can be used to customize the GraphQL Engine. + +```ts +import { envelop, useEngine } from '@envelop/core' +import { parse, validate, execute, subscribe } from 'graphql' +import { parser } from 'my-custom-graphql-parser' + +const getEnveloped = envelop({ + plugins: [ + useEngine({ + parse, + validate, + execute, + subscribe + }) + // ... other plugins + ] +}) +``` + +### `useMaskedErrors` + +Prevent unexpected error messages from leaking to the GraphQL API consumers. + +```ts +import { envelop, useSchema, useMaskedErrors, EnvelopError } from '@envelop/core' +import { makeExecutableSchema } from 'graphql' + +const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + something: String! + somethingElse: String! + somethingSpecial: String! + } + `, + resolvers: { + Query: { + something() { + throw new EnvelopError('Error that is propagated to the clients.') + }, + somethingElse() { + throw new Error("Unsafe error that will be masked as 'Unexpected Error.'.") + }, + somethingSpecial() { + throw new EnvelopError('The error will have an extensions field.', { + code: 'ERR_CODE', + randomNumber: 123 + }) + } + } + } +}) + +const getEnveloped = envelop({ + plugins: [useSchema(schema), useMaskedErrors()] +}) +``` diff --git a/website/src/pages/v3/getting-started.mdx b/website/src/pages/v3/getting-started.mdx new file mode 100644 index 000000000..48b68aaa2 --- /dev/null +++ b/website/src/pages/v3/getting-started.mdx @@ -0,0 +1,168 @@ +import { PackageCmd, Callout } from '@theguild/components' + +# First Steps + +Using Envelop is straight-forward and intuitive, in this section you will learn how to install and set up a GraphQL server using Envelop from scratch. + + + In case you are being referred to the Envelop documentation from a framework or library that implements envelop, you + can straight dive into the [Plugin Hub](/plugins) or head over to the [Plugin Tutorial](/docs/plugins) for learning + how to write your plugins. + +## Installation + +Start by adding the core of `envelop` and `graphql` to your codebase. + + + +## Create Your First Envelop + +After installing the `@envelop/core` package, you can use the `envelop` function for creating your `getEnveloped` function. We use a simple GraphQL schema that we build with the `buildSchema` function from `graphql`. + +```ts +import { parse, validate, execute, subcribe } from 'graphql' +import { envelop, useSchema, useEngine } from '@envelop/core' +import { buildSchema } from 'graphql' + +const schema = buildSchema(/* GraphQL */ ` + type Query { + hello: String + } +`) + +export const getEnveloped = envelop({ + plugins: [useEngine({ parse, validate, execute, subscribe }), useSchema(schema)] +}) +``` + +## Use Your Envelop + +The result of `envelop` is a factory function that allows you to get everything you need for the GraphQL execution: `parse`, `validate`, `contextBuilder`, `execute` and `subscribe`. It is usually named `getEnveloped`. + +By calling the `getEnveloped` function you will get all the primitive functions required for the GraphQL execution layer. + +```ts +// prettier-ignore +const { + contextFactory, + parse, + validate, + execute, + subscribe, + schema, +} = getEnveloped() +``` + +## Add Plugins to Your Envelop + +After you set up your base Envelop, you can start customizing your GraphQL server. Adding new functionality is as easy as adding a new envelop plugin to your base envelop setup. + +Let's add a parser and validation cache, so sending the same operation string sent to our server is cached using an LRU cache, allowing to improve the overall performance of the GraphQL execution layer. + + + +```ts +import { parse, validate, execute, subcribe } from 'graphql' +import { envelop, useSchema, useEngine } from '@envelop/core' +import { buildSchema } from 'graphql' +import { useParserCache } from '@envelop/parser-cache' +import { useValidationCache } from '@envelop/validation-cache' + +const schema = buildSchema(/* GraphQL */ ` + type Query { + hello: String + } +`) + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, execute, subscribe }), + // all enabled plugins + useSchema(schema), + useParserCache(), + useValidationCache() + ] +}) +``` + +A list of all available plugins can be found in the [Plugins](/plugins) page. + +## Use Your Envelop with an HTTP Server + +You can build a very simple GraphQL HTTP server from scratch, by using the Node.js `http` module and only these functions. + +```ts +import { createServer } from 'node:http' +import { GraphQLError } from 'graphql' +import { getEnveloped } from './envelop' + +const httpServer = createServer(async (req, res) => { + try { + // body parsing + req.body = await new Promise((resolve, reject) => { + let data = '' + req.on('data', chunk => { + data += String(chunk) + }) + req.on('end', () => { + resolve(data) + }) + req.on('error', err => reject(err)) + }) + + const { + // Get the GraphQL execution functions with attached plugin handlers + parse, + validate, + contextFactory, + execute, + schema + // pass in an initial context that all plugins can consume and extend + } = getEnveloped({ req }) + + // Parse request body JSON + const { query, variables } = JSON.parse(req.body) + const document = parse(query) + const validationErrors = validate(schema, document) + + if (validationErrors.length > 0) { + return res.end(JSON.stringify({ errors: validationErrors })) + } + + // Build the context and execute + const contextValue = await contextFactory() + const result = await execute({ + document, + schema, + variableValues: variables, + contextValue + }) + + // Send the response + res.end(JSON.stringify(result)) + } catch (err) { + if (err instanceof GraphQLError === false) { + err = new GraphQLError(err.message) + } + res.end(JSON.stringify({ errors: [err] })) + } +}) + +httpServer.listen(3000) +``` + +After starting this server we can send a GraphQL operation using `curl`, yielding a response of `{"data":{"__typename":"Query"}}{:json}`. + +```bash +curl \ + -X POST \ + -d '{"query":"{__typename}"}' \ + http://localhost:3000 +``` + + + This example uses the Node.js `http` module and is a very simplified handler that does not strictly follow the + [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http) as it does not handle headers or + respect HTTP methods. For actual production usage, we recommend using Envelop with one of the popular and + battle-tested HTTP frameworks and servers. Check the examples on our [Integrations page](/docs/integrations). + diff --git a/website/src/pages/v3/guides/_meta.json b/website/src/pages/v3/guides/_meta.json new file mode 100644 index 000000000..cf4420a2e --- /dev/null +++ b/website/src/pages/v3/guides/_meta.json @@ -0,0 +1,10 @@ +{ + "migrating-from-v2-to-v3": "Migrating from v2 to v3", + "securing-your-graphql-api": "Securing Your GraphQL API", + "adding-authentication-with-auth0": "Adding Authentication with Auth0", + "monitoring-and-tracing": "Monitoring and Tracing", + "using-graphql-features-from-the-future": "Using GraphQL Features from the Future", + "resolving-subscription-data-loader-caching-issues": "Resolving Subscription DataLoader Caching Issues", + "adding-a-graphql-response-cache": "Adding a GraphQL Response Cache", + "integrating-with-databases": "Integrating with Databases" +} diff --git a/website/src/pages/v3/guides/adding-a-graphql-response-cache.mdx b/website/src/pages/v3/guides/adding-a-graphql-response-cache.mdx new file mode 100644 index 000000000..5dc6afda5 --- /dev/null +++ b/website/src/pages/v3/guides/adding-a-graphql-response-cache.mdx @@ -0,0 +1,341 @@ +import { Callout, PackageCmd } from '@theguild/components' + +# Adding a GraphQL Response Cache + +## A Brief Introduction to Caching + +Huge GraphQL query operations can slow down your server as deeply nested selection sets can cause a lot of subsequent database reads or calls to other remote services. Tools like `DataLoader` can reduce the amount of concurrent and subsequent requests via batching and caching during the execution of a single GraphQL operation. Features like `@defer` and `@stream` can help with streaming slow-to-retrieve result partials to the clients progressively. However, for subsequent requests we hit the same bottleneck over and over again. + +What if we don't need to go through the execution phase at all for subsequent requests that execute the same query operation with the same variables? + +A common practice for reducing slow requests is to leverage caching. There are many types of caching available. E.g. We could cache the whole HTTP responses based on the POST body of the request or an in memory cache within our GraphQL field resolver business logic in order to hit slow services less frequently. + +Having a cache comes with the drawback of requiring some kind of cache invalidation mechanism. Expiring the cache via a TTL (time to live) is a widespread practice, but can result in hitting the cache too often or too scarcely. Another popular strategy is to incorporate cache invalidation logic into the business logic. Writing such logic can potentially become too verbose and hard to maintain. Other systems might use database write log observers for invalidating entities based on updated database rows. + +In a strict REST API environment, caching entities is significantly easier, as each endpoint represents one resource, and thus a `GET` method can be cached and a `PATCH` method can be used to automatically invalidate the cache for the corresponding `GET` request, which is described via the HTTP path (`/api/user/12`). + +With GraphQL such things become much harder and more complicated. First of all, we usually only have a single HTTP endpoint `/graphql` that only accepts `POST` requests. A query operation execution result could contain many different types of entities, thus, we need different strategies for caching GraphQL APIs. + +SaaS services like GraphCDN started popping up providing proxies for your existing GraphQL API, that magically add response based caching. But how does this even work? + +## How Does GraphQL Response Caching Work? + +### Caching Query Operations + +In order to cache a GraphQL execution result (response) we need to build an identifier based on the input that can be used to identify whether a response can be served from the cache or must be executed and then stored within the cache. + +```graphql filename="Example: GraphQL Query Operation" +query UserProfileQuery($id: ID!) { + user(id: $id) { + __typename + id + login + repositories + friends(first: 2) { + __typename + id + login + } + } +} +``` + +```json filename="Example: GraphQL Variables" +{ + "id": "1" +} +``` + +Usually, those inputs are the Query operation document and the variables for such an operation document. + +Thus a response cache can store the execution result under a cache key that is built from those inputs: + +```text +OperationCacheKey (e.g. SHA1) = hash(GraphQLOperationString, Stringify(GraphQLVariables)) +``` + +Under some circumstances, it is also required to cache based on the request initiator. For example, a user requesting his profile should not receive the cached profile of another user. In such a scenario, building the operation cache key should also include a part that uniquely identifies the requestor. This could be a user ID extracted from an authorization token. + +```text +OperationCacheKey (e.g. SHA1) = hash(GraphQLOperationString, Stringify(GraphQLVariables), RequestorId) +``` + +This allows us to identify recurring operations with the same variables and serve them from the cache for subsequent requests. If we can serve a response from the cache we don't need to parse the GraphQL operation document and furthermore can skip the expensive execution phase. That will result in significant speed improvements. + +But to make our cache smart we still need a suitable cache invalidation mechanism. + +### Invalidating Cached GraphQL Query Operations + +Let's take a look at a possible execution result for the GraphQL operation. + +```json filename="Example: GraphQL Execution Result" +{ + "data": { + "user": { + "__typename": "User", + "id": "1", + "login": "dotan", + "repositories": ["codegen"], + "friends": [ + { + "__typename": "User", + "id": "2", + "login": "urigo" + }, + { + "__typename": "User", + "id": "3", + "login": "n1ru4l" + } + ] + } + } +} +``` + +Many frontend frameworks cache GraphQL operation results in a normalized cache. The identifier for storing the single entities of a GraphQL operation result within the cache is usually the `id` field of object types for schemas that use global unique IDs or a compound of the `__typename` and `id` field for schemas that use non-global ID fields. + +```json filename="Example: Normalized GraphQL Client Cache" +{ + "User:1": { + "__typename": "User", + "id": "1", + "login": "dotan", + "repositories": ["codegen"], + "friends": ["$$ref:User:2", "$$ref:User:3"] + }, + "User:2": { + "__typename": "User", + "id": "2", + "login": "urigo" + }, + "User:3": { + "__typename": "User", + "id": "3", + "login": "n1ru4l" + } +} +``` + +Interestingly, the same strategy for constructing cache keys on the client can also be used on the backend for tracking which GraphQL operations contain which entities. That allows invalidating GraphQL query operation results based on +entity IDs. + +For the execution result entity IDS that could be used for invalidating the operation are the following: `User:1`, `User:2` and `User:3`. + +And also keep a register that maps entities to operation cache keys. + +```text +Entity List of Operation cache keys that reference an entity + +User:1 OperationCacheKey1, OperationCacheKey2, ... +User:2 OperationCacheKey2, OperationCacheKey3, ... +User:3 OperationCacheKey3, OperationCacheKey1, ... +``` + +This allows us to keep track of which GraphQL operations must be invalidated once a certain entity becomes stale. + +The remaining question is, how can we track an entity becoming stale? + +As mentioned before, listening to a database write log is a possible option - but the implementation is very specific and differs based on the chosen database type. Time to live is also possible, but a very inaccurate solution. + +Another solution is to add invalidation logic within our GraphQL mutation resolver. By the GraphQL Specification mutations are meant to modify our GraphQL graph. + +A common pattern when sending mutations from clients is to select and return affected/mutated entities with the selection set. + +For our example from above, the following could be a possible mutation for adding a new repository to the repositories field on the user entity. + +```graphql filename="Example: GraphQL Mutation" +mutation RepositoryAddMutation($userId: ID, $repositoryName: String!) { + repositoryAdd(userId: $userId, repositoryName: $repositoryName) { + user { + id + repositories + } + } +} +``` + +```json filename="Example: GraphQL Mutation Execution Result" +{ + "data": { + "repositoryAdd": { + "user": { + "id": "1", + "repositories": ["codegen", "envelop"] + } + } + } +} +``` + +Similar to how we build entity identifiers from the execution result of query operations for identifying what entities are referenced in which operations, we can extract the entity identifiers from the mutation operation result for invalidating affected operations. + +In this specific case, all operations that select `User:1` should be invalidated. + +Such an implementation assumes that all mutations by default select affected entities and, furthermore, all mutations of underlying entities are done through the GraphQL gateway via mutations. In a scenario where we have actors that are not GraphQL services or services that operate directly on the database, we can use this approach in a hybrid model with other methods such as listening to database write logs. + +## Envelop Response Cache + +The envelop response cache plugin now provides primitives and a reference in memory store implementation for adopting such a cache with all the features mentioned above with any GraphQL server. + +The goal of the response cache plugin is to educate how such mechanisms are implemented and furthermore give developers the building blocks for constructing their own global cache with their cloud provider of choice. + + + Watch [Episode #34 of `graphql.wtf`](https://graphql.wtf/episodes/34-response-cache-plugin-with-envelop) for a quick + introduction to using the Response Cache plugin with Envelop: + + +