graphql-modules
Version:
Create reusable, maintainable, testable and extendable GraphQL modules
240 lines (239 loc) • 11.7 kB
JavaScript
import { ReflectiveInjector } from '../di';
import { once, merge } from '../shared/utils';
import async_context from '#async-context';
import { attachGlobalProvidersMap } from './di';
import { CONTEXT } from './tokens';
/**
* Installs a getter-based view of `refs.context` onto `target` — every
* string/symbol-keyed property of `source` (captured at call time) becomes
* an accessor on `target` that delegates to `refs.context` dynamically.
*
* The view therefore holds no user data of its own; once `refs.context`
* is nulled (in `ɵdestroy`), every accessor returns `undefined` and the
* original user-context object is no longer reachable from the view.
*
* The helper lives at module scope (rather than inside `contextBuilder`)
* so it doesn't close over the per-operation `context` parameter — that
* keeps the operation's V8 scope free of any captured reference to the
* user context other than `refs`, which is what makes the leak fix work.
*/
function defineUserContextAccessors(target, source, refs) {
if (source === undefined || source === null)
return;
const define = (key) => {
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get() {
return refs.context === undefined
? undefined
: refs.context[key];
},
set(value) {
if (refs.context !== undefined) {
refs.context[key] = value;
}
},
});
};
for (const key of Object.keys(source))
define(key);
for (const sym of Object.getOwnPropertySymbols(source))
define(sym);
}
export function createContextBuilder({ appInjector, modulesMap, appLevelOperationProviders, singletonGlobalProvidersMap, operationGlobalProvidersMap, }) {
// This is very critical. It creates an execution context.
// It has to run on every operation.
const contextBuilder = (context) => {
// Cache for context per module
let contextCache = {};
// A list of providers with OnDestroy hooks
// It's a tuple because we want to know which Injector controls the provider
// and we want to know if the provider was even instantiated.
let providersToDestroy = [];
function registerProvidersToDestroy(injector) {
injector._providers.forEach((provider) => {
if (provider.factory.hasOnDestroyHook) {
// keep provider key's id (it doesn't change over time)
// and related injector
providersToDestroy.push([injector, provider.key.id]);
}
});
}
// See https://github.com/graphql-hive/graphql-modules/pull/2681
//
// Heavy per-operation values (the user-supplied `context`, and the
// `appContext` we derive from it) are reachable via this function's
// closure scope, which is shared by every closure created below of it —
// including the one used by AsyncLocalStorae frame.
//
// Since https://github.com/nodejs/node/pull/48528, any async resource
// scheduled while we're inside that `AsyncLocalStorage.run` (like global setTimeout,
// an telemetry exporter timer, a deferred promise reaction, …)
// snapshots and captures the current `AsyncContextFrame`.
//
// If that resource outlives the operation (=defined globally, or just have a longer lifetime),
// then the snapshot keeps the AsyncLocalStorage-stored object alive, which keeps this scope alive — which
// pins `context` forever.
//
// Routing the heavy values through a mutable holder lets `ɵdestroy`
// detach them from the (still pinned) scope by nulling the holder's
// properties; the closures continue to exist but no longer reach
// anything that matters.
const refs = {
context,
appContext: undefined,
};
attachGlobalProvidersMap({
injector: appInjector,
globalProvidersMap: singletonGlobalProvidersMap,
moduleInjectorGetter(moduleId) {
return modulesMap.get(moduleId).injector;
},
});
appInjector.setExecutionContextGetter(function executionContextGetter() {
var _a;
return (((_a = async_context.getAsyncContext()) === null || _a === void 0 ? void 0 : _a.getApplicationContext()) ||
refs.appContext);
});
function createModuleExecutionContextGetter(moduleId) {
return function moduleExecutionContextGetter() {
var _a;
return (((_a = async_context.getAsyncContext()) === null || _a === void 0 ? void 0 : _a.getModuleContext(moduleId)) ||
(refs.context ? getModuleContext(moduleId, refs.context) : undefined));
};
}
modulesMap.forEach((mod, moduleId) => {
mod.injector.setExecutionContextGetter(createModuleExecutionContextGetter(moduleId));
});
// The cached `CONTEXT` injection value. `ReflectiveInjector` caches
// the resolved instance forever in `_objs[i]`, so if we returned the
// raw `refs.context` here that cache would pin the user's context
// object even after `ɵdestroy` nulls `refs.context`. Returning a
// view that *reads through* `refs.context` keeps the cached object
// payload-free. See `defineUserContextAccessors` at module scope.
const contextView = Object.create(null);
defineUserContextAccessors(contextView, context, refs);
// As the name of the Injector says, it's an Operation scoped Injector
// Application level
// Operation scoped - means it's created and destroyed on every GraphQL Operation
const operationAppInjector = ReflectiveInjector.createFromResolved({
name: 'App (Operation Scope)',
providers: appLevelOperationProviders.concat(ReflectiveInjector.resolve([
{
provide: CONTEXT,
useFactory: () => contextView,
deps: [],
},
])),
parent: appInjector,
});
// Create a context for application-level ExecutionContext
refs.appContext = merge(refs.context, {
injector: operationAppInjector,
});
// Track Providers with OnDestroy hooks
registerProvidersToDestroy(operationAppInjector);
function getModuleContext(moduleId, ctx) {
var _a;
// Reuse a context or create if not available
if (!contextCache[moduleId]) {
// We're interested in operation-scoped providers only
const providers = (_a = modulesMap.get(moduleId)) === null || _a === void 0 ? void 0 : _a.operationProviders;
// Create module-level Operation-scoped Injector
const operationModuleInjector = ReflectiveInjector.createFromResolved({
name: `Module "${moduleId}" (Operation Scope)`,
providers: providers.concat(ReflectiveInjector.resolve([
{
provide: CONTEXT,
useFactory() {
return contextCache[moduleId];
},
},
])),
// This injector has a priority
parent: modulesMap.get(moduleId).injector,
// over this one
fallbackParent: operationAppInjector,
});
// Same as on application level, we need to collect providers with OnDestroy hooks
registerProvidersToDestroy(operationModuleInjector);
contextCache[moduleId] = merge(ctx, {
injector: operationModuleInjector,
moduleId,
});
}
return contextCache[moduleId];
}
// sharedContext — exposed publicly as `env.context`. Same shape as a
// `merge(context, { ɵgetModuleContext })` would produce, but its
// user-context fields are getter accessors over `refs.context`
// (see `defineUserContextAccessors` at module scope) rather than
// independent shallow copies. Once `refs.context` is nulled the user
// payload is unreachable from `sharedContext` too, without us having
// to mutate the object's keys in `ɵdestroy`.
const sharedContext = {
// It's a function that is used in module's context creation
ɵgetModuleContext: getModuleContext,
};
defineUserContextAccessors(sharedContext, context, refs);
attachGlobalProvidersMap({
injector: operationAppInjector,
globalProvidersMap: operationGlobalProvidersMap,
moduleInjectorGetter(moduleId) {
return getModuleContext(moduleId, sharedContext).injector;
},
});
const env = {
ɵdestroy: once(() => {
providersToDestroy.forEach(([injector, keyId]) => {
// If provider was instantiated
if (injector._isObjectDefinedByKeyId(keyId)) {
// call its OnDestroy hook
injector._getObjByKeyId(keyId).onDestroy();
}
});
contextCache = {};
providersToDestroy = [];
// All retention paths from this closure scope to the
// user-supplied `context` route through `refs`:
// - `sharedContext` (= env.context) and the cached CONTEXT
// injector value are getter-based views over `refs.context`,
// - `appInjector._executionContextGetter` reads through
// `refs.appContext`.
// Nulling the holder slots is the only cleanup required —
// public-facing identities (`sharedContext`, `ɵinjector`) keep
// working for any post-destroy reads, but the heavy user
// payload becomes unreachable from this scope.
refs.context = undefined;
refs.appContext = undefined;
// The function parameter `context` is itself a captured binding
// in this lexical scope. Even though no surviving closure reads
// it directly (everything goes through `refs`), V8's scope info
// may keep the binding alive for as long as any closure in this
// scope is reachable. Reassigning it here makes that binding
// empty too — without this, the original user `context` object
// remains pinned by the scope itself.
context = undefined;
}),
ɵinjector: operationAppInjector,
context: sharedContext,
};
return {
...env,
runWithContext(cb) {
return async_context.runWithAsyncContext({
getApplicationContext() {
return refs.appContext;
},
getModuleContext(moduleId) {
return refs.context
? getModuleContext(moduleId, refs.context)
: undefined;
},
}, cb, env);
},
};
};
return contextBuilder;
}