From f7d6cafdad81c408571d5e552a7331bd3a75e61c Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 19 Jun 2025 11:23:33 +0200 Subject: [PATCH 1/3] feat(core): New plugin utils `withState` --- packages/core/src/index.ts | 1 + packages/core/src/plugin-with-state.ts | 164 ++++++++++++++ packages/core/test/plugin-with-state.spec.ts | 216 +++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 packages/core/src/plugin-with-state.ts create mode 100644 packages/core/test/plugin-with-state.spec.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fe12466d37..336d85b92e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,4 +12,5 @@ export * from './plugins/use-payload-formatter.js'; export * from './plugins/use-masked-errors.js'; export * from './plugins/use-engine.js'; export * from './plugins/use-validation-rule.js'; +export * from './plugin-with-state.js'; export { getDocumentString } from './document-string-map.js'; diff --git a/packages/core/src/plugin-with-state.ts b/packages/core/src/plugin-with-state.ts new file mode 100644 index 0000000000..d4497deccc --- /dev/null +++ b/packages/core/src/plugin-with-state.ts @@ -0,0 +1,164 @@ +import { MaybePromise } from '@whatwg-node/promise-helpers'; + +export function withState< + P extends { instrumentation?: GenericInstrumentation }, + HttpState = object, + GraphqlState = object, + SubExecState = object, +>( + pluginFactory: ( + getState: ( + payload: SP, + ) => PayloadWithState['state'], + ) => PluginWithState, +): P { + const states: { + forRequest?: WeakMap>; + forOperation?: WeakMap>; + forSubgraphExecution?: WeakMap<{ context: any }, Partial>; + } = {}; + + function getProp(scope: keyof typeof states, key: any): PropertyDescriptor { + return { + get() { + if (!states[scope]) states[scope] = new WeakMap(); + let value = states[scope].get(key as any); + if (!value) states[scope].set(key, (value = {})); + return value; + }, + enumerable: true, + }; + } + + function getState(payload: Payload) { + if (!payload) { + return undefined; + } + let { executionRequest, context, request } = payload; + const state = {}; + const defineState = (scope: keyof typeof states, key: any) => + Object.defineProperty(state, scope, getProp(scope, key)); + + if (executionRequest) { + defineState('forSubgraphExecution', executionRequest); + // ExecutionRequest can happen outside of any Graphql Operation for Gateway internal usage like Introspection queries. + // We check for `params` to be present, which means it's actually a GraphQL context. + if (executionRequest.context?.params) context = executionRequest.context; + } + if (context) { + defineState('forOperation', context); + if (context.request) request = context.request; + } + if (request) { + defineState('forRequest', request); + } + return state; + } + + function addStateGetters(src: any) { + const result: any = {}; + for (const [hookName, hook] of Object.entries(src) as any) { + if (typeof hook !== 'function') { + result[hookName] = hook; + } else { + result[hookName] = { + [hook.name](payload: any, ...args: any[]) { + if (payload && (payload.request || payload.context || payload.executionRequest)) { + return hook( + { + ...payload, + get state() { + return getState(payload); + }, + }, + ...args, + ); + } else { + return hook(payload, ...args); + } + }, + }[hook.name]; + } + } + return result; + } + + const { instrumentation, ...hooks } = pluginFactory(getState as any); + + const pluginWithState = addStateGetters(hooks); + if (instrumentation) { + pluginWithState.instrumentation = addStateGetters(instrumentation); + } + + return pluginWithState as P; +} + +export type HttpState = { + forRequest: Partial; +}; + +export type GraphQLState = { + forOperation: Partial; +}; + +export type GatewayState = { + forSubgraphExecution: Partial; +}; + +export function getMostSpecificState( + state: Partial & GraphQLState & GatewayState> = {}, +): Partial | undefined { + const { forOperation, forRequest, forSubgraphExecution } = state; + return forSubgraphExecution ?? forOperation ?? forRequest; +} + +type Payload = { + request?: Request; + context?: any; + executionRequest?: { context: any }; +}; + +type GenericInstrumentation = Record< + string, + (payload: any, wrapped: () => MaybePromise) => MaybePromise +>; + +// Brace yourself! TS Wizardry is coming! + +type PayloadWithState = T extends { + executionRequest: any; +} + ? T & { + state: Partial & GraphQLState> & GatewayState; + } + : T extends { + executionRequest?: any; + } + ? T & { + state: Partial & GraphQLState & GatewayState>; + } + : T extends { context: any } + ? T & { state: HttpState & GraphQLState } + : T extends { request: any } + ? T & { state: HttpState } + : T extends { request?: any } + ? T & { state: Partial> } + : T; + +export type PluginWithState = { + [K in keyof P]: K extends 'instrumentation' + ? P[K] extends infer Instrumentation | undefined + ? { + [I in keyof Instrumentation]: Instrumentation[I] extends + | ((payload: infer IP, ...args: infer Args) => infer IR) + | undefined + ? + | ((payload: PayloadWithState, ...args: Args) => IR) + | undefined + : Instrumentation[I]; + } + : P[K] + : P[K] extends ((payload: infer T) => infer R) | undefined + ? ((payload: PayloadWithState) => R) | undefined + : P[K]; +}; diff --git a/packages/core/test/plugin-with-state.spec.ts b/packages/core/test/plugin-with-state.spec.ts new file mode 100644 index 0000000000..66f1ddd52e --- /dev/null +++ b/packages/core/test/plugin-with-state.spec.ts @@ -0,0 +1,216 @@ +import { getMostSpecificState, withState } from '../src/plugin-with-state'; + +describe('pluginWithState', () => { + const plugin = withState(() => ({ hook: (...args: any[]) => args })); + + it('should work with an empty plugin', () => { + const plugin = withState(() => ({})); + expect(plugin).toEqual({}); + }); + + it('should allow to have hooks without parameters', () => { + expect(plugin.hook()).toEqual([]); + }); + + it('should allow to have attribute that are not function', () => { + const plugin = withState(() => ({ test: 'test' })); + + expect(plugin).toEqual({ test: 'test' }); + }); + + it('should keep parameters when there is no state to add', () => { + const objectPayload = {}; + expect(plugin.hook(objectPayload)[0]).toBe(objectPayload); + + const arrayPayload: any[] = []; + expect(plugin.hook(arrayPayload)[0]).toBe(arrayPayload); + + expect(plugin.hook('test')[0]).toBe('test'); + expect(plugin.hook(1)[0]).toBe(1); + expect(plugin.hook(true)[0]).toBe(true); + expect(plugin.hook(false)[0]).toBe(false); + }); + + it('should add request state', () => { + expect(plugin.hook({ request: {} })).toMatchObject([{ state: { forRequest: {} } }]); + }); + + it('should add operation state', () => { + expect(plugin.hook({ context: {} })).toMatchObject([{ state: { forOperation: {} } }]); + }); + + it('should add subgraph execution state', () => { + expect(plugin.hook({ executionRequest: {} })).toMatchObject([ + { state: { forSubgraphExecution: {} } }, + ]); + }); + + it('should combine all states', () => { + expect(plugin.hook({ context: {}, request: {} })).toMatchObject([ + { state: { forOperation: {}, forRequest: {} } }, + ]); + expect(plugin.hook({ context: {}, executionRequest: {} })).toMatchObject([ + { state: { forOperation: {}, forSubgraphExecution: {} } }, + ]); + + expect(plugin.hook({ executionRequest: {}, request: {} })).toMatchObject([ + { state: { forSubgraphExecution: {}, forRequest: {} } }, + ]); + + expect(plugin.hook({ executionRequest: {}, request: {}, context: {} })).toMatchObject([ + { state: { forSubgraphExecution: {}, forRequest: {}, forOperation: {} } }, + ]); + }); + + it('should have a stable state', () => { + const refs = { request: {}, context: {}, executionRequest: {} }; + const { forRequest, forOperation, forSubgraphExecution } = plugin.hook(refs); + + expect(plugin.hook(refs)[0].forRequest).toBe(forRequest); + expect(plugin.hook(refs)[0].forOperation).toBe(forOperation); + expect(plugin.hook(refs)[0].forSubgraphExecution).toBe(forSubgraphExecution); + + expect(plugin.hook({ request: refs.request })[0].forRequest).toBe(forRequest); + expect(plugin.hook({ context: refs.context })[0].forOperation).toBe(forOperation); + expect(plugin.hook({ executionRequest: refs.executionRequest })[0].forSubgraphExecution).toBe( + forSubgraphExecution, + ); + }); + + describe('instruments', () => { + const plugin = withState(() => ({ instrumentation: { hook: (...args: any[]): any => args } })); + + it('should add request state', () => { + expect(plugin.instrumentation.hook({ request: {} })).toMatchObject([ + { state: { forRequest: {} } }, + ]); + }); + + it('should add operation state', () => { + expect(plugin.instrumentation.hook({ context: {} })).toMatchObject([ + { state: { forOperation: {} } }, + ]); + }); + + it('should add subgraph execution state', () => { + expect(plugin.instrumentation.hook({ executionRequest: {} })).toMatchObject([ + { state: { forSubgraphExecution: {} } }, + ]); + }); + + it('should combine all states', () => { + expect(plugin.instrumentation.hook({ context: {}, request: {} })).toMatchObject([ + { state: { forOperation: {}, forRequest: {} } }, + ]); + expect(plugin.instrumentation.hook({ context: {}, executionRequest: {} })).toMatchObject([ + { state: { forOperation: {}, forSubgraphExecution: {} } }, + ]); + + expect(plugin.instrumentation.hook({ executionRequest: {}, request: {} })).toMatchObject([ + { state: { forSubgraphExecution: {}, forRequest: {} } }, + ]); + + expect( + plugin.instrumentation.hook({ executionRequest: {}, request: {}, context: {} }), + ).toMatchObject([{ state: { forSubgraphExecution: {}, forRequest: {}, forOperation: {} } }]); + }); + + it('should have a stable state', () => { + const refs = { request: {}, context: {}, executionRequest: {} }; + const { forRequest, forOperation, forSubgraphExecution } = plugin.instrumentation.hook(refs); + + expect(plugin.instrumentation.hook(refs)[0].forRequest).toBe(forRequest); + expect(plugin.instrumentation.hook(refs)[0].forOperation).toBe(forOperation); + expect(plugin.instrumentation.hook(refs)[0].forSubgraphExecution).toBe(forSubgraphExecution); + + expect(plugin.instrumentation.hook({ request: refs.request })[0].forRequest).toBe(forRequest); + expect(plugin.instrumentation.hook({ context: refs.context })[0].forOperation).toBe( + forOperation, + ); + expect( + plugin.instrumentation.hook({ executionRequest: refs.executionRequest })[0] + .forSubgraphExecution, + ).toBe(forSubgraphExecution); + }); + }); + + describe('getState', () => { + let getState: (p: any) => any; + withState(_getState => { + getState = _getState; + return {}; + }); + + it('should add request state', () => { + expect(getState({ request: {} })).toMatchObject({ forRequest: {} }); + }); + + it('should add operation state', () => { + expect(getState({ context: {} })).toMatchObject({ forOperation: {} }); + }); + + it('should add subgraph execution state', () => { + expect(getState({ executionRequest: {} })).toMatchObject({ forSubgraphExecution: {} }); + }); + + it('should combine all states', () => { + expect(getState({ context: {}, request: {} })).toMatchObject({ + forOperation: {}, + forRequest: {}, + }); + expect(getState({ context: {}, executionRequest: {} })).toMatchObject({ + forOperation: {}, + forSubgraphExecution: {}, + }); + + expect(getState({ executionRequest: {}, request: {} })).toMatchObject({ + forSubgraphExecution: {}, + forRequest: {}, + }); + + expect(getState({ executionRequest: {}, request: {}, context: {} })).toMatchObject({ + forSubgraphExecution: {}, + forRequest: {}, + forOperation: {}, + }); + }); + + it('should have a stable state', () => { + const refs = { request: {}, context: {}, executionRequest: {} }; + const { forRequest, forOperation, forSubgraphExecution } = getState(refs); + + expect(getState(refs).forRequest).toBe(forRequest); + expect(getState(refs).forOperation).toBe(forOperation); + expect(getState(refs).forSubgraphExecution).toBe(forSubgraphExecution); + + expect(getState({ request: refs.request }).forRequest).toBe(forRequest); + expect(getState({ context: refs.context }).forOperation).toBe(forOperation); + expect(getState({ executionRequest: refs.executionRequest }).forSubgraphExecution).toBe( + forSubgraphExecution, + ); + }); + }); + + describe('getMostSpecificState', () => { + it('should return the most specific state', () => { + const forRequest = {}; + const forOperation = {}; + const forSubgraphExecution = {}; + + expect(getMostSpecificState({ forRequest })).toBe(forRequest); + expect(getMostSpecificState({ forOperation })).toBe(forOperation); + expect(getMostSpecificState({ forSubgraphExecution })).toBe(forSubgraphExecution); + + expect(getMostSpecificState({ forRequest, forOperation })).toBe(forOperation); + expect(getMostSpecificState({ forRequest, forSubgraphExecution })).toBe(forSubgraphExecution); + + expect(getMostSpecificState({ forOperation, forSubgraphExecution })).toBe( + forSubgraphExecution, + ); + + expect(getMostSpecificState({ forOperation, forRequest, forSubgraphExecution })).toBe( + forSubgraphExecution, + ); + }); + }); +}); From 005caf314ca2e738992e56b76a3ad27c9b5943b4 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 19 Jun 2025 13:34:29 +0200 Subject: [PATCH 2/3] changeset --- .changeset/sharp-goats-trade.md | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .changeset/sharp-goats-trade.md diff --git a/.changeset/sharp-goats-trade.md b/.changeset/sharp-goats-trade.md new file mode 100644 index 0000000000..ac080a462f --- /dev/null +++ b/.changeset/sharp-goats-trade.md @@ -0,0 +1,61 @@ +--- +'@envelop/core': minor +--- + +## New plugin utility to ease data sharing between hooks. + +Sometimes, plugins can grow in complexity and need to share data between its hooks. + +A way to solve this can be to mutate the graphql context, but this context is not always available +in all hooks in Yoga or Hive Gateway plugins. Moreover, mutating the context gives access to your +internal data to all other plugins and graphql resolvers, without mentioning performance impact on +field access on this object. + +The recommended approach to this problem was to use a `WeakMap` with a stable key (often the +`context` or `request` object). While it works, it's not very convenient for plugin developers, and +is prone to error with the choice of key. + +The new `withState` utility solves this DX issue by providing an easy and straightforward API for +data sharing between hooks. + +```ts +import { withState } from '@envelop/core' + +type State = { foo: string } + +const myPlugin = () => + withState(() => ({ + onParse({ state }) { + state.forOperation.foo = 'foo' + }, + onValidate({ state }) { + const { foo } = state.forOperation + console.log('foo', foo) + } + })) +``` + +The `state` payload field will be available in all relevant hooks, making it easy to access shared +data. It also forces the developer to choose the scope for the data: + +- `forOperation` for a data scoped to GraphQL operation (Envelop, Yoga and Hive Gateway) +- `forRequest` for a data scoped to HTTP request (Yoga and Hive Gateway) +- `forSubgraphExecution` for a data scoped to the subgraph execution (Hive Gateway) + +Not all scopes are available in all hooks, the type reflects which scopes are available + +Under the hood, those states are kept in memory using `WeakMap`, which avoid any memory leaks. + +It is also possible to manually retrieve the state with the `getState` function: + +```ts +const myPlugin = () => + withState(getState => ({ + onParse({ context }) { + // You can provide a payload, which will dictate which scope you have access to. + // The scope can contain `context`, `request` and `executionRequest` fields. + const state = getState({ context }) + // Use the state elsewhere. + } + })) +``` From 34350ff4ef8e7fc3f05ccdfff4465668f5002b76 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 19 Jun 2025 14:38:59 +0200 Subject: [PATCH 3/3] fix changeset --- .changeset/sharp-goats-trade.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/sharp-goats-trade.md b/.changeset/sharp-goats-trade.md index ac080a462f..4450cd0993 100644 --- a/.changeset/sharp-goats-trade.md +++ b/.changeset/sharp-goats-trade.md @@ -2,7 +2,9 @@ '@envelop/core': minor --- -## New plugin utility to ease data sharing between hooks. +Added new `withState` plugin utility for easy data sharing between hooks. + +## New plugin utility to ease data sharing between hooks Sometimes, plugins can grow in complexity and need to share data between its hooks.