Skip to content

Commit 3ebaa3b

Browse files
authored
feat(core): New plugin utils withState (#2607)
* feat(core): New plugin utils `withState` * changeset * fix changeset
1 parent c574979 commit 3ebaa3b

File tree

4 files changed

+444
-0
lines changed

4 files changed

+444
-0
lines changed

.changeset/sharp-goats-trade.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'@envelop/core': minor
3+
---
4+
5+
Added new `withState` plugin utility for easy data sharing between hooks.
6+
7+
## New plugin utility to ease data sharing between hooks
8+
9+
Sometimes, plugins can grow in complexity and need to share data between its hooks.
10+
11+
A way to solve this can be to mutate the graphql context, but this context is not always available
12+
in all hooks in Yoga or Hive Gateway plugins. Moreover, mutating the context gives access to your
13+
internal data to all other plugins and graphql resolvers, without mentioning performance impact on
14+
field access on this object.
15+
16+
The recommended approach to this problem was to use a `WeakMap` with a stable key (often the
17+
`context` or `request` object). While it works, it's not very convenient for plugin developers, and
18+
is prone to error with the choice of key.
19+
20+
The new `withState` utility solves this DX issue by providing an easy and straightforward API for
21+
data sharing between hooks.
22+
23+
```ts
24+
import { withState } from '@envelop/core'
25+
26+
type State = { foo: string }
27+
28+
const myPlugin = () =>
29+
withState<Plugin, State>(() => ({
30+
onParse({ state }) {
31+
state.forOperation.foo = 'foo'
32+
},
33+
onValidate({ state }) {
34+
const { foo } = state.forOperation
35+
console.log('foo', foo)
36+
}
37+
}))
38+
```
39+
40+
The `state` payload field will be available in all relevant hooks, making it easy to access shared
41+
data. It also forces the developer to choose the scope for the data:
42+
43+
- `forOperation` for a data scoped to GraphQL operation (Envelop, Yoga and Hive Gateway)
44+
- `forRequest` for a data scoped to HTTP request (Yoga and Hive Gateway)
45+
- `forSubgraphExecution` for a data scoped to the subgraph execution (Hive Gateway)
46+
47+
Not all scopes are available in all hooks, the type reflects which scopes are available
48+
49+
Under the hood, those states are kept in memory using `WeakMap`, which avoid any memory leaks.
50+
51+
It is also possible to manually retrieve the state with the `getState` function:
52+
53+
```ts
54+
const myPlugin = () =>
55+
withState(getState => ({
56+
onParse({ context }) {
57+
// You can provide a payload, which will dictate which scope you have access to.
58+
// The scope can contain `context`, `request` and `executionRequest` fields.
59+
const state = getState({ context })
60+
// Use the state elsewhere.
61+
}
62+
}))
63+
```

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export * from './plugins/use-payload-formatter.js';
1212
export * from './plugins/use-masked-errors.js';
1313
export * from './plugins/use-engine.js';
1414
export * from './plugins/use-validation-rule.js';
15+
export * from './plugin-with-state.js';
1516
export { getDocumentString } from './document-string-map.js';
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { MaybePromise } from '@whatwg-node/promise-helpers';
2+
3+
export function withState<
4+
P extends { instrumentation?: GenericInstrumentation },
5+
HttpState = object,
6+
GraphqlState = object,
7+
SubExecState = object,
8+
>(
9+
pluginFactory: (
10+
getState: <SP extends {}>(
11+
payload: SP,
12+
) => PayloadWithState<SP, HttpState, GraphqlState, SubExecState>['state'],
13+
) => PluginWithState<P, HttpState, GraphqlState, SubExecState>,
14+
): P {
15+
const states: {
16+
forRequest?: WeakMap<Request, Partial<HttpState>>;
17+
forOperation?: WeakMap<object, Partial<GraphqlState>>;
18+
forSubgraphExecution?: WeakMap<{ context: any }, Partial<SubExecState>>;
19+
} = {};
20+
21+
function getProp(scope: keyof typeof states, key: any): PropertyDescriptor {
22+
return {
23+
get() {
24+
if (!states[scope]) states[scope] = new WeakMap<any, any>();
25+
let value = states[scope].get(key as any);
26+
if (!value) states[scope].set(key, (value = {}));
27+
return value;
28+
},
29+
enumerable: true,
30+
};
31+
}
32+
33+
function getState(payload: Payload) {
34+
if (!payload) {
35+
return undefined;
36+
}
37+
let { executionRequest, context, request } = payload;
38+
const state = {};
39+
const defineState = (scope: keyof typeof states, key: any) =>
40+
Object.defineProperty(state, scope, getProp(scope, key));
41+
42+
if (executionRequest) {
43+
defineState('forSubgraphExecution', executionRequest);
44+
// ExecutionRequest can happen outside of any Graphql Operation for Gateway internal usage like Introspection queries.
45+
// We check for `params` to be present, which means it's actually a GraphQL context.
46+
if (executionRequest.context?.params) context = executionRequest.context;
47+
}
48+
if (context) {
49+
defineState('forOperation', context);
50+
if (context.request) request = context.request;
51+
}
52+
if (request) {
53+
defineState('forRequest', request);
54+
}
55+
return state;
56+
}
57+
58+
function addStateGetters(src: any) {
59+
const result: any = {};
60+
for (const [hookName, hook] of Object.entries(src) as any) {
61+
if (typeof hook !== 'function') {
62+
result[hookName] = hook;
63+
} else {
64+
result[hookName] = {
65+
[hook.name](payload: any, ...args: any[]) {
66+
if (payload && (payload.request || payload.context || payload.executionRequest)) {
67+
return hook(
68+
{
69+
...payload,
70+
get state() {
71+
return getState(payload);
72+
},
73+
},
74+
...args,
75+
);
76+
} else {
77+
return hook(payload, ...args);
78+
}
79+
},
80+
}[hook.name];
81+
}
82+
}
83+
return result;
84+
}
85+
86+
const { instrumentation, ...hooks } = pluginFactory(getState as any);
87+
88+
const pluginWithState = addStateGetters(hooks);
89+
if (instrumentation) {
90+
pluginWithState.instrumentation = addStateGetters(instrumentation);
91+
}
92+
93+
return pluginWithState as P;
94+
}
95+
96+
export type HttpState<T> = {
97+
forRequest: Partial<T>;
98+
};
99+
100+
export type GraphQLState<T> = {
101+
forOperation: Partial<T>;
102+
};
103+
104+
export type GatewayState<T> = {
105+
forSubgraphExecution: Partial<T>;
106+
};
107+
108+
export function getMostSpecificState<T>(
109+
state: Partial<HttpState<T> & GraphQLState<T> & GatewayState<T>> = {},
110+
): Partial<T> | undefined {
111+
const { forOperation, forRequest, forSubgraphExecution } = state;
112+
return forSubgraphExecution ?? forOperation ?? forRequest;
113+
}
114+
115+
type Payload = {
116+
request?: Request;
117+
context?: any;
118+
executionRequest?: { context: any };
119+
};
120+
121+
type GenericInstrumentation = Record<
122+
string,
123+
(payload: any, wrapped: () => MaybePromise<void>) => MaybePromise<void>
124+
>;
125+
126+
// Brace yourself! TS Wizardry is coming!
127+
128+
type PayloadWithState<T, Http, GraphQL, Gateway> = T extends {
129+
executionRequest: any;
130+
}
131+
? T & {
132+
state: Partial<HttpState<Http> & GraphQLState<GraphQL>> & GatewayState<Gateway>;
133+
}
134+
: T extends {
135+
executionRequest?: any;
136+
}
137+
? T & {
138+
state: Partial<HttpState<Http> & GraphQLState<GraphQL> & GatewayState<Gateway>>;
139+
}
140+
: T extends { context: any }
141+
? T & { state: HttpState<Http> & GraphQLState<GraphQL> }
142+
: T extends { request: any }
143+
? T & { state: HttpState<Http> }
144+
: T extends { request?: any }
145+
? T & { state: Partial<HttpState<Http>> }
146+
: T;
147+
148+
export type PluginWithState<P, Http = object, GraphQL = object, Gateway = object> = {
149+
[K in keyof P]: K extends 'instrumentation'
150+
? P[K] extends infer Instrumentation | undefined
151+
? {
152+
[I in keyof Instrumentation]: Instrumentation[I] extends
153+
| ((payload: infer IP, ...args: infer Args) => infer IR)
154+
| undefined
155+
?
156+
| ((payload: PayloadWithState<IP, Http, GraphQL, Gateway>, ...args: Args) => IR)
157+
| undefined
158+
: Instrumentation[I];
159+
}
160+
: P[K]
161+
: P[K] extends ((payload: infer T) => infer R) | undefined
162+
? ((payload: PayloadWithState<T, Http, GraphQL, Gateway>) => R) | undefined
163+
: P[K];
164+
};

0 commit comments

Comments
 (0)