From ca94fc55bafc4c4411634454d7b1436027c547b5 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Sat, 15 Feb 2025 14:44:01 +0100 Subject: [PATCH 01/13] feat(core) New tracer API proposal --- packages/core/src/create.ts | 19 ++++++++---- packages/core/src/tracer.ts | 60 ++++++++++++++++++++++++++++++++++++ packages/types/src/plugin.ts | 17 ++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/tracer.ts diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index ae3b2de330..b54c1213c6 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -1,5 +1,6 @@ import { ArbitraryObject, ComposeContext, GetEnvelopedFn, Optional, Plugin } from '@envelop/types'; import { createEnvelopOrchestrator, EnvelopOrchestrator } from './orchestrator.js'; +import { getTraced, getTracer } from './tracer.js'; type ExcludeFalsy = Exclude[]; @@ -12,6 +13,7 @@ export function envelop>[]>(options: { enableInternalTracing?: boolean; }): GetEnvelopedFn>> { const plugins = options.plugins.filter(notEmpty); + const tracer = getTracer>>(plugins); const orchestrator = createEnvelopOrchestrator>>({ plugins, }); @@ -19,18 +21,23 @@ export function envelop>[]>(options: { const getEnveloped = ( initialContext: TInitialContext = {} as TInitialContext, ) => { + const traced = getTraced(initialContext); const typedOrchestrator = orchestrator as EnvelopOrchestrator< TInitialContext, ComposeContext> >; - typedOrchestrator.init(initialContext); + + traced.sync(tracer?.init, orchestrator.init)(initialContext); return { - parse: typedOrchestrator.parse(initialContext), - validate: typedOrchestrator.validate(initialContext), - contextFactory: typedOrchestrator.contextFactory(initialContext as any), - execute: typedOrchestrator.execute, - subscribe: typedOrchestrator.subscribe, + parse: traced.sync(tracer?.parse, typedOrchestrator.parse(initialContext)), + validate: traced.sync(tracer?.validate, typedOrchestrator.validate(initialContext)), + contextFactory: traced.sync( + tracer?.context, + typedOrchestrator.contextFactory(initialContext as any), + ), + execute: traced.maybeAsync(tracer?.execute, typedOrchestrator.execute), + subscribe: traced.maybeAsync(tracer?.subscribe, typedOrchestrator.subscribe), schema: typedOrchestrator.getCurrentSchema(), }; }; diff --git a/packages/core/src/tracer.ts b/packages/core/src/tracer.ts new file mode 100644 index 0000000000..5acedf44e0 --- /dev/null +++ b/packages/core/src/tracer.ts @@ -0,0 +1,60 @@ +import type { Plugin, PromiseOrValue, Tracer } from '@envelop/types'; +import { isPromise, mapMaybePromise } from './utils'; + +export function getTracer>( + plugins: Plugin[], +): Tracer | undefined { + let tracer: Tracer | undefined; + for (const plugin of plugins) { + if (plugin.tracer) { + if (tracer) { + throw new Error('A plugin has already declared a tracer. Only one tracer is allowed'); + } + tracer = plugin.tracer; + } + } + return tracer; +} + +export const getTraced = (context: any) => ({ + sync( + tracer: ((payload: { context: any }, wrapped: () => void) => void) | undefined, + wrapped: (...args: TArgs) => TResult, + ): (...args: TArgs) => TResult { + if (!tracer) { + return wrapped; + } + + return (...args) => { + let result: TResult; + tracer({ context }, () => { + result = wrapped(...args); + }); + return result!; + }; + }, + + maybeAsync( + tracer: + | ((payload: { context: any }, wrapped: () => PromiseOrValue) => PromiseOrValue) + | undefined, + wrapped: (...args: TArgs) => PromiseOrValue, + ): (...args: TArgs) => PromiseOrValue { + if (!tracer) { + return wrapped; + } + + return (...args) => { + let result: PromiseOrValue; + return mapMaybePromise( + tracer({ context }, () => { + result = wrapped(...args); + return isPromise(result) ? result.then(undefined) : undefined; + }), + () => { + return result; + }, + ); + }; + }, +}); diff --git a/packages/types/src/plugin.ts b/packages/types/src/plugin.ts index 9e0af9692c..003226fd73 100644 --- a/packages/types/src/plugin.ts +++ b/packages/types/src/plugin.ts @@ -8,8 +8,10 @@ import { OnSubscribeHook, OnValidateHook, } from './hooks.js'; +import type { PromiseOrValue } from './utils.js'; export interface Plugin = {}> { + tracer?: Tracer; /** * Invoked for each call to getEnveloped. */ @@ -43,3 +45,18 @@ export interface Plugin = {}> { */ onContextBuilding?: OnContextBuildingHook; } + +export type Tracer> = { + init?: (payload: { context: TContext }, wrapped: () => void) => void; + parse?: (payload: { context: TContext }, wrapped: () => void) => void; + validate?: (payload: { context: TContext }, wrapped: () => void) => void; + context?: (payload: { context: TContext }, wrapped: () => void) => void; + execute?: ( + payload: { context: TContext }, + wrapped: () => PromiseOrValue, + ) => PromiseOrValue; + subscribe?: ( + payload: { context: TContext }, + wrapped: () => PromiseOrValue, + ) => PromiseOrValue; +}; From dfffa6ce07c1556a3cb5eef9e603595059be0b9e Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 18 Feb 2025 17:52:32 +0100 Subject: [PATCH 02/13] parameterize the payload type, so that `getTraced` can be reused in Yoga/Hive Gateway --- .gitignore | 2 ++ packages/core/src/create.ts | 19 ++++++++----------- packages/core/src/tracer.ts | 14 +++++++------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 9d8ed26a51..2f9baabe07 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,5 @@ website/public/sitemap.xml .tool-versions .mise.toml +.helix/config.toml +.helix/languages.toml diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index b54c1213c6..018f1cfbb8 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -19,25 +19,22 @@ export function envelop>[]>(options: { }); const getEnveloped = ( - initialContext: TInitialContext = {} as TInitialContext, + context: TInitialContext = {} as TInitialContext, ) => { - const traced = getTraced(initialContext); + const traced = getTraced<{ context: any }>({ context }); const typedOrchestrator = orchestrator as EnvelopOrchestrator< TInitialContext, ComposeContext> >; - traced.sync(tracer?.init, orchestrator.init)(initialContext); + traced.fn(tracer?.init, orchestrator.init)(context); return { - parse: traced.sync(tracer?.parse, typedOrchestrator.parse(initialContext)), - validate: traced.sync(tracer?.validate, typedOrchestrator.validate(initialContext)), - contextFactory: traced.sync( - tracer?.context, - typedOrchestrator.contextFactory(initialContext as any), - ), - execute: traced.maybeAsync(tracer?.execute, typedOrchestrator.execute), - subscribe: traced.maybeAsync(tracer?.subscribe, typedOrchestrator.subscribe), + parse: traced.fn(tracer?.parse, typedOrchestrator.parse(context)), + validate: traced.fn(tracer?.validate, typedOrchestrator.validate(context)), + contextFactory: traced.fn(tracer?.context, typedOrchestrator.contextFactory(context as any)), + execute: traced.asyncFn(tracer?.execute, typedOrchestrator.execute), + subscribe: traced.asyncFn(tracer?.subscribe, typedOrchestrator.subscribe), schema: typedOrchestrator.getCurrentSchema(), }; }; diff --git a/packages/core/src/tracer.ts b/packages/core/src/tracer.ts index 5acedf44e0..437ffe4e7e 100644 --- a/packages/core/src/tracer.ts +++ b/packages/core/src/tracer.ts @@ -16,9 +16,9 @@ export function getTracer>( return tracer; } -export const getTraced = (context: any) => ({ - sync( - tracer: ((payload: { context: any }, wrapped: () => void) => void) | undefined, +export const getTraced = (payload: TPayload) => ({ + fn( + tracer: ((payload: TPayload, wrapped: () => void) => void) | undefined, wrapped: (...args: TArgs) => TResult, ): (...args: TArgs) => TResult { if (!tracer) { @@ -27,16 +27,16 @@ export const getTraced = (context: any) => ({ return (...args) => { let result: TResult; - tracer({ context }, () => { + tracer(payload, () => { result = wrapped(...args); }); return result!; }; }, - maybeAsync( + asyncFn( tracer: - | ((payload: { context: any }, wrapped: () => PromiseOrValue) => PromiseOrValue) + | ((payload: TPayload, wrapped: () => PromiseOrValue) => PromiseOrValue) | undefined, wrapped: (...args: TArgs) => PromiseOrValue, ): (...args: TArgs) => PromiseOrValue { @@ -47,7 +47,7 @@ export const getTraced = (context: any) => ({ return (...args) => { let result: PromiseOrValue; return mapMaybePromise( - tracer({ context }, () => { + tracer(payload, () => { result = wrapped(...args); return isPromise(result) ? result.then(undefined) : undefined; }), From c16a808d949186ddc83170fefc381f2a7489eeda Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 18 Feb 2025 19:43:53 +0100 Subject: [PATCH 03/13] export tracer utils --- packages/core/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e90394970..d2a39a91df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,4 +11,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 './tracer.js'; export { getDocumentString } from './document-string-map.js'; From 0887285df1f7ffeed03b13c87a4823fbdb2ecaab Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 21 Feb 2025 16:58:58 +0100 Subject: [PATCH 04/13] changeset --- .changeset/tough-ears-suffer.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tough-ears-suffer.md diff --git a/.changeset/tough-ears-suffer.md b/.changeset/tough-ears-suffer.md new file mode 100644 index 0000000000..297a36c68e --- /dev/null +++ b/.changeset/tough-ears-suffer.md @@ -0,0 +1,6 @@ +--- +'@envelop/types': minor +'@envelop/core': minor +--- + +Add new Tracer API From 7a1ec9710151e0deee6eabc230c17fb773bd21cc Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 25 Feb 2025 22:37:37 +0100 Subject: [PATCH 05/13] rename tracer to instruments and compose instruments --- packages/core/src/create.ts | 34 ++++-- packages/core/src/tracer.ts | 60 ---------- packages/core/src/utils.ts | 1 - packages/core/test/instruments.spec.ts | 97 +++++++++++++++ packages/instruments/README.md | 41 +++++++ packages/instruments/package.json | 66 +++++++++++ packages/instruments/src/index.ts | 2 + packages/instruments/src/instruments.ts | 112 ++++++++++++++++++ packages/instruments/test/instruments.spec.ts | 101 ++++++++++++++++ packages/types/src/plugin.ts | 7 +- 10 files changed, 447 insertions(+), 74 deletions(-) delete mode 100644 packages/core/src/tracer.ts create mode 100644 packages/core/test/instruments.spec.ts create mode 100644 packages/instruments/README.md create mode 100644 packages/instruments/package.json create mode 100644 packages/instruments/src/index.ts create mode 100644 packages/instruments/src/instruments.ts create mode 100644 packages/instruments/test/instruments.spec.ts diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index 018f1cfbb8..a3fa498513 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -1,6 +1,13 @@ -import { ArbitraryObject, ComposeContext, GetEnvelopedFn, Optional, Plugin } from '@envelop/types'; +import { + ArbitraryObject, + ComposeContext, + GetEnvelopedFn, + Instruments, + Optional, + Plugin, +} from '@envelop/types'; import { createEnvelopOrchestrator, EnvelopOrchestrator } from './orchestrator.js'; -import { getTraced, getTracer } from './tracer.js'; +import { composeInstruments, getInstrumented, getInstrumentsAndPlugins } from './tracer.js'; type ExcludeFalsy = Exclude[]; @@ -12,8 +19,10 @@ export function envelop>[]>(options: { plugins: PluginsType; enableInternalTracing?: boolean; }): GetEnvelopedFn>> { - const plugins = options.plugins.filter(notEmpty); - const tracer = getTracer>>(plugins); + const { pluginInstruments, plugins } = getInstrumentsAndPlugins, Plugin>( + options.plugins.filter(notEmpty), + ); + const instruments = composeInstruments(pluginInstruments); const orchestrator = createEnvelopOrchestrator>>({ plugins, }); @@ -21,20 +30,23 @@ export function envelop>[]>(options: { const getEnveloped = ( context: TInitialContext = {} as TInitialContext, ) => { - const traced = getTraced<{ context: any }>({ context }); + const instrumented = getInstrumented<{ context: any }>({ context }); const typedOrchestrator = orchestrator as EnvelopOrchestrator< TInitialContext, ComposeContext> >; - traced.fn(tracer?.init, orchestrator.init)(context); + instrumented.fn(instruments?.init, orchestrator.init)(context); return { - parse: traced.fn(tracer?.parse, typedOrchestrator.parse(context)), - validate: traced.fn(tracer?.validate, typedOrchestrator.validate(context)), - contextFactory: traced.fn(tracer?.context, typedOrchestrator.contextFactory(context as any)), - execute: traced.asyncFn(tracer?.execute, typedOrchestrator.execute), - subscribe: traced.asyncFn(tracer?.subscribe, typedOrchestrator.subscribe), + parse: instrumented.fn(instruments?.parse, typedOrchestrator.parse(context)), + validate: instrumented.fn(instruments?.validate, typedOrchestrator.validate(context)), + contextFactory: instrumented.fn( + instruments?.context, + typedOrchestrator.contextFactory(context as any), + ), + execute: instrumented.asyncFn(instruments?.execute, typedOrchestrator.execute), + subscribe: instrumented.asyncFn(instruments?.subscribe, typedOrchestrator.subscribe), schema: typedOrchestrator.getCurrentSchema(), }; }; diff --git a/packages/core/src/tracer.ts b/packages/core/src/tracer.ts deleted file mode 100644 index 437ffe4e7e..0000000000 --- a/packages/core/src/tracer.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Plugin, PromiseOrValue, Tracer } from '@envelop/types'; -import { isPromise, mapMaybePromise } from './utils'; - -export function getTracer>( - plugins: Plugin[], -): Tracer | undefined { - let tracer: Tracer | undefined; - for (const plugin of plugins) { - if (plugin.tracer) { - if (tracer) { - throw new Error('A plugin has already declared a tracer. Only one tracer is allowed'); - } - tracer = plugin.tracer; - } - } - return tracer; -} - -export const getTraced = (payload: TPayload) => ({ - fn( - tracer: ((payload: TPayload, wrapped: () => void) => void) | undefined, - wrapped: (...args: TArgs) => TResult, - ): (...args: TArgs) => TResult { - if (!tracer) { - return wrapped; - } - - return (...args) => { - let result: TResult; - tracer(payload, () => { - result = wrapped(...args); - }); - return result!; - }; - }, - - asyncFn( - tracer: - | ((payload: TPayload, wrapped: () => PromiseOrValue) => PromiseOrValue) - | undefined, - wrapped: (...args: TArgs) => PromiseOrValue, - ): (...args: TArgs) => PromiseOrValue { - if (!tracer) { - return wrapped; - } - - return (...args) => { - let result: PromiseOrValue; - return mapMaybePromise( - tracer(payload, () => { - result = wrapped(...args); - return isPromise(result) ? result.then(undefined) : undefined; - }), - () => { - return result; - }, - ); - }; - }, -}); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 17971d9e79..6697727a26 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -32,7 +32,6 @@ function getSubscribeArgs(args: PolymorphicSubscribeArguments): ExecutionArgs { subscribeFieldResolver: args[7], }; } - /** * Utility function for making a subscribe function that handles polymorphic arguments. */ diff --git a/packages/core/test/instruments.spec.ts b/packages/core/test/instruments.spec.ts new file mode 100644 index 0000000000..5893420dda --- /dev/null +++ b/packages/core/test/instruments.spec.ts @@ -0,0 +1,97 @@ +import { chain, envelop, GenericInstruments, Instruments, useEngine } from '@envelop/core'; + +describe('instruments', () => { + it('should instrument all graphql phases', async () => { + const result: string[] = []; + const instrument: Instruments = { + init: (_, w) => { + result.push('pre-init'); + expect(w()).toBeUndefined(); + result.push('post-init'); + return 'instrument'; + }, + parse: (_, w) => { + result.push('pre-parse'); + expect(w()).toBeUndefined(); + result.push('post-parse'); + return 'instrument'; + }, + validate: (_, w) => { + result.push('pre-validate'); + expect(w()).toBeUndefined(); + result.push('post-validate'); + return 'instrument'; + }, + context: (_, w) => { + result.push('pre-context'); + expect(w()).toBeUndefined(); + result.push('post-context'); + return 'instrument'; + }, + // @ts-expect-error Returning something other than undefined should not be allowed + execute: async (_, w) => { + result.push('pre-execute'); + expect(await w()).toBeUndefined(); + result.push('post-execute'); + return 'instrument'; + }, + // @ts-expect-error Returning something other than undefined shoould not be allowed + subscribe: async (_, w) => { + result.push('pre-subscribe'); + expect(await w()).toBeUndefined(); + result.push('post-subscribe'); + return 'instrument'; + }, + }; + + const getEnveloped = envelop({ + plugins: [ + useEngine({ + execute: () => { + result.push('execute'); + return new Promise(r => setTimeout(() => r('test'), 10)); + }, + subscribe: () => { + result.push('subscribe'); + return new Promise(r => setTimeout(() => r('test'), 10)); + }, + parse: () => { + result.push('parse'); + return { test: 'foo' }; + }, + validate: () => { + result.push('validate'); + return 'test'; + }, + }), + { instruments: instrument }, + ], + }); + + const gql = getEnveloped({ test: 'foo' }); + expect(gql.parse('')).toEqual({ test: 'foo' }); + expect(gql.validate({}, {})).toEqual('test'); + expect(gql.contextFactory()).toEqual({ test: 'foo' }); + expect(await gql.execute({ document: {}, schema: {} })).toEqual('test'); + expect(await gql.subscribe({ document: {}, schema: {} })).toEqual('test'); + + expect(result).toEqual([ + 'pre-init', + 'post-init', + 'pre-parse', + 'parse', + 'post-parse', + 'pre-validate', + 'validate', + 'post-validate', + 'pre-context', + 'post-context', + 'pre-execute', + 'execute', + 'post-execute', + 'pre-subscribe', + 'subscribe', + 'post-subscribe', + ]); + }); +}); diff --git a/packages/instruments/README.md b/packages/instruments/README.md new file mode 100644 index 0000000000..d7d153ec95 --- /dev/null +++ b/packages/instruments/README.md @@ -0,0 +1,41 @@ +## `@envelop/instruments` + +This package contains uitility functions and types to ease the use of instruments accross Envelop, +Yoga, wathwg-node and Hive Gateway plugins. + +### `getInstrumentsAndPlugins(plugins: Plugin[]): { pluginInstruments: Instruments[], plugins: Plugin[] }` + +This function extracts the instruments from the plugins and returns both the extracted instruments +and the plugins without their `instruments` field. + +This is usefull when you want to customize the execution order of the instruments. + +```ts +import { getInstrumentsAndPlugins } from '@envelop/instruments' + +const { pluginInstruments, plugins } = getInstrumentsAndPlugins([ + // put you plugin list here. This list can contain plugins with and without instruments. +]) +``` + +## `composeInstruments(instruments: Instruments[]): Instruments` + +This function composes all the instruments into one. The instruments will be called in the same +order than they are in the array (first is outter most call, last is inner most). + +This can be used in conjonction with `getInstrumentsAndPlugins` function to customize the order of +execution of the instruments if the default one doesn't suites your needs. + +```ts +import { getInstrumentsAndPlugins } from '@envelop/instruments' + +const { pluginInstruments, plugins } = getInstrumentsAndPlugins([ + // put you plugin list here. This list can contain plugins with and without instruments. +]) + +const instruments = composeInstruments(pluginInstruments) + +const getEnveloped = envelop({ + plugins: [...plugins, { instruments }] +}) +``` diff --git a/packages/instruments/package.json b/packages/instruments/package.json new file mode 100644 index 0000000000..3e04d34cda --- /dev/null +++ b/packages/instruments/package.json @@ -0,0 +1,66 @@ +{ + "name": "@envelop/instruments", + "version": "5.0.3", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/n1ru4l/envelop.git", + "directory": "packages/instruments" + }, + "author": "Valentin Cocaud ", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "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", + "dependencies": { + "tslib": "^2.5.0" + }, + "devDependencies": { + "typescript": "5.7.3" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "sideEffects": false, + "buildOptions": { + "input": "./src/index.ts" + }, + "typescript": { + "definition": "dist/typings/index.d.ts" + } +} diff --git a/packages/instruments/src/index.ts b/packages/instruments/src/index.ts new file mode 100644 index 0000000000..156431ca5c --- /dev/null +++ b/packages/instruments/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/export +export * from './instruments'; diff --git a/packages/instruments/src/instruments.ts b/packages/instruments/src/instruments.ts new file mode 100644 index 0000000000..50cce6eee5 --- /dev/null +++ b/packages/instruments/src/instruments.ts @@ -0,0 +1,112 @@ +import { isPromise, mapMaybePromise } from '@envelop/core'; +import type { PromiseOrValue } from '@envelop/types'; + +export type GenericInstruments = Record< + string, + (payload: any, wrapped: () => PromiseOrValue) => PromiseOrValue +>; + +/** + * Composes 2 instrumentations together into one instrumentation. + * The first one will be the outer call, the second one the inner call. + */ +export function chain( + first: First, + next: Next, +) { + const merged: GenericInstruments = { ...next, ...first }; + + for (const key of Object.keys(merged)) { + if (key in first && key in next) { + merged[key] = (payload, wrapped) => first[key](payload, () => next[key](payload, wrapped)); + } + } + return merged as First & Next; +} + +/** + * Composes a list of instruments together into one instruments object. + * The order of execution will respect the order of the array, + * the first one being the outter most call, the last one the inner most call. + */ +export function composeInstruments(instrumentations: T[]): T { + return instrumentations.reduce(chain); +} + +/** + * Extract instruments from a list of plugins. + * It returns instruments found, and the list of plugins without their insturments. + * + * You can use this to easily customize the composition of the instruments if the default one + * doesn't suits your needs. + */ +export function getInstrumentsAndPlugins( + plugins: P[], +): { pluginInstruments: T[]; plugins: Omit[] } { + const pluginInstruments: T[] = []; + const newPlugins: Omit[] = []; + for (const { instruments, ...plugin } of plugins) { + if (instruments) { + pluginInstruments.push(instruments); + } + newPlugins.push(plugin); + } + return { pluginInstruments, plugins: newPlugins }; +} + +/** + * A helper to instrument a function. + * + * @param payload: The first argument that will be passed to the instruments on each function call + * @returns Function and Async Functions factories allowing to wrap a function with a given instrument. + */ +export const getInstrumented = (payload: TPayload) => ({ + /** + * Wraps the `wrapped` function with the given `instrument` wrapper. + * @returns The wrapped function, or `undefined` if the instrument is `undefined`. + */ + fn( + instrument: ((payload: TPayload, wrapped: () => void) => void) | undefined, + wrapped: (...args: TArgs) => TResult, + ): (...args: TArgs) => TResult { + if (!instrument) { + return wrapped; + } + + return (...args) => { + let result: TResult; + instrument(payload, () => { + result = wrapped(...args); + }); + return result!; + }; + }, + + /** + * Wraps the `wrapped` function with the given `instrument` wrapper. + * @returns The wrapped function, or `undefined` if the instrument is `undefined`. + */ + asyncFn( + instrument: + | ((payload: TPayload, wrapped: () => PromiseOrValue) => PromiseOrValue) + | undefined, + wrapped: (...args: TArgs) => PromiseOrValue, + ): (...args: TArgs) => PromiseOrValue { + if (!instrument) { + return wrapped; + } + + return (...args) => { + let result: PromiseOrValue; + return mapMaybePromise( + instrument(payload, () => { + result = wrapped(...args); + return isPromise(result) ? result.then(() => undefined) : undefined; + }), + () => { + return result; + }, + ); + }; + }, +}); diff --git a/packages/instruments/test/instruments.spec.ts b/packages/instruments/test/instruments.spec.ts new file mode 100644 index 0000000000..930cfa2d5f --- /dev/null +++ b/packages/instruments/test/instruments.spec.ts @@ -0,0 +1,101 @@ +import { chain, envelop, GenericInstruments, Instruments, useEngine } from '@envelop/core'; + +describe('instruments', () => { + describe('chain', () => { + it('should execute instrument in the same order than the array', () => { + const result: number[] = []; + const createInstrument = (name: number): GenericInstruments => ({ + execute: (_, wrapped) => { + result.push(name); + wrapped(); + result.push(name); + }, + }); + + let [instrument, ...instruments] = [ + createInstrument(1), + createInstrument(2), + createInstrument(3), + createInstrument(4), + ]; + + for (const other of instruments) { + instrument = chain(instrument, other); + } + + instrument.execute({}, () => {}); + expect(result).toEqual([1, 2, 3, 4, 4, 3, 2, 1]); + }); + + it('should execute instrument in the same order when async', async () => { + const result: number[] = []; + const createInstrument = (name: number): GenericInstruments => ({ + execute: async (_, wrapped) => { + result.push(name); + await wrapped(); + result.push(name); + }, + }); + + let [instrument, ...instruments] = [ + createInstrument(1), + createInstrument(2), + createInstrument(3), + createInstrument(4), + ]; + + for (const other of instruments) { + instrument = chain(instrument, other); + } + + await instrument.execute({}, () => new Promise(r => setTimeout(r, 10))); + expect(result).toEqual([1, 2, 3, 4, 4, 3, 2, 1]); + }); + + it('should merge all instrument methods', () => { + const instrument1 = { + execute: jest.fn().mockImplementation(dumbInstrument), + operation: jest.fn().mockImplementation(dumbInstrument), + }; + + const instrument2 = { + execute: jest.fn().mockImplementation(dumbInstrument), + request: jest.fn().mockImplementation(dumbInstrument), + }; + + const instrument = chain(instrument1, instrument2); + + const executeSpy = jest.fn(); + instrument.execute({}, executeSpy); + expect(executeSpy).toHaveBeenCalled(); + expect(instrument1.execute).toHaveBeenCalled(); + expect(instrument2.execute).toHaveBeenCalled(); + + const operationSpy = jest.fn(); + instrument.operation({}, operationSpy); + expect(operationSpy).toHaveBeenCalled(); + expect(instrument1.operation).toHaveBeenCalled(); + + const requestSpy = jest.fn(); + instrument.request({}, requestSpy); + expect(instrument2.request).toHaveBeenCalled(); + }); + + it('should pass the payload all the way down the instrument chain', () => { + const make = () => ({ + execute: jest.fn().mockImplementation(dumbInstrument), + }); + + const instruments = [make(), make(), make(), make()]; + + const payload = { test: 'foo' }; + + instruments.reduce(chain).execute(payload, () => {}); + for (const instrument of instruments) { + expect(instrument.execute).toHaveBeenCalledWith(payload, expect.anything()); + } + }); + }); +}); + +const dumbInstrument = (_: unknown, w: () => void) => w(); diff --git a/packages/types/src/plugin.ts b/packages/types/src/plugin.ts index 003226fd73..3b240d5f78 100644 --- a/packages/types/src/plugin.ts +++ b/packages/types/src/plugin.ts @@ -11,7 +11,10 @@ import { import type { PromiseOrValue } from './utils.js'; export interface Plugin = {}> { - tracer?: Tracer; + /** + * Instruments wrapping each phases, including hooks executions. + */ + instruments?: Instruments; /** * Invoked for each call to getEnveloped. */ @@ -46,7 +49,7 @@ export interface Plugin = {}> { onContextBuilding?: OnContextBuildingHook; } -export type Tracer> = { +export type Instruments> = { init?: (payload: { context: TContext }, wrapped: () => void) => void; parse?: (payload: { context: TContext }, wrapped: () => void) => void; validate?: (payload: { context: TContext }, wrapped: () => void) => void; From 5c5826b2207bb3052af2c8b33f09b8d64d9f718b Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 25 Feb 2025 22:58:33 +0100 Subject: [PATCH 06/13] use wahtwg-node/promise-helpers instead of envelop utils --- packages/instruments/package.json | 1 + packages/instruments/src/instruments.ts | 24 ++++++++++++------------ pnpm-lock.yaml | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/instruments/package.json b/packages/instruments/package.json index 3e04d34cda..459aa95b4d 100644 --- a/packages/instruments/package.json +++ b/packages/instruments/package.json @@ -47,6 +47,7 @@ }, "typings": "dist/typings/index.d.ts", "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" }, "devDependencies": { diff --git a/packages/instruments/src/instruments.ts b/packages/instruments/src/instruments.ts index 50cce6eee5..67d003b66e 100644 --- a/packages/instruments/src/instruments.ts +++ b/packages/instruments/src/instruments.ts @@ -1,9 +1,8 @@ -import { isPromise, mapMaybePromise } from '@envelop/core'; -import type { PromiseOrValue } from '@envelop/types'; +import { handleMaybePromise, isPromise, MaybePromise } from '@whatwg-node/promise-helpers'; export type GenericInstruments = Record< string, - (payload: any, wrapped: () => PromiseOrValue) => PromiseOrValue + (payload: any, wrapped: () => MaybePromise) => MaybePromise >; /** @@ -88,21 +87,22 @@ export const getInstrumented = (payload: TPayload) => ({ */ asyncFn( instrument: - | ((payload: TPayload, wrapped: () => PromiseOrValue) => PromiseOrValue) + | ((payload: TPayload, wrapped: () => MaybePromise) => MaybePromise) | undefined, - wrapped: (...args: TArgs) => PromiseOrValue, - ): (...args: TArgs) => PromiseOrValue { + wrapped: (...args: TArgs) => MaybePromise, + ): (...args: TArgs) => MaybePromise { if (!instrument) { return wrapped; } return (...args) => { - let result: PromiseOrValue; - return mapMaybePromise( - instrument(payload, () => { - result = wrapped(...args); - return isPromise(result) ? result.then(() => undefined) : undefined; - }), + let result: MaybePromise; + return handleMaybePromise( + () => + instrument(payload, () => { + result = wrapped(...args); + return isPromise(result) ? result.then(() => undefined) : undefined; + }), () => { return result; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74d27688e0..90e5371506 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -685,6 +685,20 @@ importers: version: 5.7.3 publishDirectory: dist + packages/instruments: + dependencies: + '@whatwg-node/promise-helpers': + specifier: ^1.2.1 + version: 1.2.1 + tslib: + specifier: ^2.5.0 + version: 2.8.1 + devDependencies: + typescript: + specifier: 5.7.3 + version: 5.7.3 + publishDirectory: dist + packages/plugins/apollo-datasources: dependencies: '@apollo/utils.keyvaluecache': @@ -5126,6 +5140,10 @@ packages: resolution: {integrity: sha512-6cJoRLP6/0Bf4k2i36R1f9lisId6fIYEOQ5CUHSPRCmiJfo+HGAm8P/5Qoy28lvYngw3SKBLJ5YqtGWjUaMA6g==} engines: {node: '>=18.0.0'} + '@whatwg-node/promise-helpers@1.2.1': + resolution: {integrity: sha512-+faGtJlS4U8NSaSzRVN37xAprPdhoobYzUSUo4DgH8APtfFyizmNxp0ckwKcURoL8cy2B+bKxOWU/VIH2nFeLg==} + engines: {node: '>=18.0.0'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -16184,6 +16202,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@whatwg-node/promise-helpers@1.2.1': + dependencies: + tslib: 2.8.1 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} From 55483fee1997846e2db622b41cfca15c2c1fe96c Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 25 Feb 2025 23:34:47 +0100 Subject: [PATCH 07/13] use @envelop/instruments everywhere --- packages/core/package.json | 1 + packages/core/src/create.ts | 6 +++++- packages/core/src/index.ts | 1 - packages/core/test/instruments.spec.ts | 2 +- packages/instruments/test/instruments.spec.ts | 2 +- pnpm-lock.yaml | 3 +++ tsconfig.json | 1 + 7 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 3efd0e96c2..c94a066e73 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,7 @@ "typescript" ], "dependencies": { + "@envelop/instruments": "workspace:^", "@envelop/types": "workspace:^", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.5.0" diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index a3fa498513..74db82101f 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -1,3 +1,8 @@ +import { + composeInstruments, + getInstrumented, + getInstrumentsAndPlugins, +} from '@envelop/instruments'; import { ArbitraryObject, ComposeContext, @@ -7,7 +12,6 @@ import { Plugin, } from '@envelop/types'; import { createEnvelopOrchestrator, EnvelopOrchestrator } from './orchestrator.js'; -import { composeInstruments, getInstrumented, getInstrumentsAndPlugins } from './tracer.js'; type ExcludeFalsy = Exclude[]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d2a39a91df..5e90394970 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,5 +11,4 @@ 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 './tracer.js'; export { getDocumentString } from './document-string-map.js'; diff --git a/packages/core/test/instruments.spec.ts b/packages/core/test/instruments.spec.ts index 5893420dda..0fb6156f8a 100644 --- a/packages/core/test/instruments.spec.ts +++ b/packages/core/test/instruments.spec.ts @@ -1,4 +1,4 @@ -import { chain, envelop, GenericInstruments, Instruments, useEngine } from '@envelop/core'; +import { envelop, Instruments, useEngine } from '@envelop/core'; describe('instruments', () => { it('should instrument all graphql phases', async () => { diff --git a/packages/instruments/test/instruments.spec.ts b/packages/instruments/test/instruments.spec.ts index 930cfa2d5f..ccaf801dcb 100644 --- a/packages/instruments/test/instruments.spec.ts +++ b/packages/instruments/test/instruments.spec.ts @@ -1,4 +1,4 @@ -import { chain, envelop, GenericInstruments, Instruments, useEngine } from '@envelop/core'; +import { chain, GenericInstruments } from '../src'; describe('instruments', () => { describe('chain', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90e5371506..666296c2c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -658,6 +658,9 @@ importers: packages/core: dependencies: + '@envelop/instruments': + specifier: workspace:^ + version: link:../instruments/dist '@envelop/types': specifier: workspace:^ version: link:../types/dist diff --git a/tsconfig.json b/tsconfig.json index d498876242..567f9a5fb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "@envelop/core": ["packages/core/src/index.ts"], "@envelop/testing": ["packages/testing/src/index.ts"], "@envelop/types": ["packages/types/src/index.ts"], + "@envelop/instruments": ["packages/instruments/src/index.ts"], "@envelop/*": ["packages/plugins/*/src/index.ts"] } }, From cb5845f791938bb0e2d6e3400040751ac70607b9 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 25 Feb 2025 23:59:14 +0100 Subject: [PATCH 08/13] fix instruments compositions of empty instruments list --- packages/instruments/src/instruments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/instruments/src/instruments.ts b/packages/instruments/src/instruments.ts index 67d003b66e..8644363f60 100644 --- a/packages/instruments/src/instruments.ts +++ b/packages/instruments/src/instruments.ts @@ -28,8 +28,8 @@ export function chain(instrumentations: T[]): T { - return instrumentations.reduce(chain); +export function composeInstruments(instruments: T[]): T | undefined { + return instruments.length > 0 ? instruments.reduce(chain) : undefined; } /** From 46ad3d53a0d740c5d2c68c5ecb6785f2667d65e6 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 27 Feb 2025 12:44:45 +0100 Subject: [PATCH 09/13] update changeset --- .changeset/tough-ears-suffer.md | 1 + packages/instruments/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/tough-ears-suffer.md b/.changeset/tough-ears-suffer.md index 297a36c68e..9bdb363504 100644 --- a/.changeset/tough-ears-suffer.md +++ b/.changeset/tough-ears-suffer.md @@ -1,6 +1,7 @@ --- '@envelop/types': minor '@envelop/core': minor +'@envelop/insturments': major --- Add new Tracer API diff --git a/packages/instruments/package.json b/packages/instruments/package.json index 459aa95b4d..ba2e93bcbd 100644 --- a/packages/instruments/package.json +++ b/packages/instruments/package.json @@ -1,6 +1,6 @@ { "name": "@envelop/instruments", - "version": "5.0.3", + "version": "0.0.0", "type": "module", "repository": { "type": "git", From e7b7798ef90d31200779122cc653570eb9ee2896 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Feb 2025 11:45:16 +0000 Subject: [PATCH 10/13] chore(dependencies): updated changesets for modified dependencies --- .changeset/@envelop_core-2430-dependencies.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/@envelop_core-2430-dependencies.md diff --git a/.changeset/@envelop_core-2430-dependencies.md b/.changeset/@envelop_core-2430-dependencies.md new file mode 100644 index 0000000000..5cd0bdd298 --- /dev/null +++ b/.changeset/@envelop_core-2430-dependencies.md @@ -0,0 +1,5 @@ +--- +"@envelop/core": patch +--- +dependencies updates: + - Added dependency [`@envelop/instruments@workspace:^` ↗︎](https://www.npmjs.com/package/@envelop/instruments/v/workspace:^) (to `dependencies`) From 64d7083775b6c1ee4ce365437ae5f77c33aee3a5 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 27 Feb 2025 12:46:49 +0100 Subject: [PATCH 11/13] changeset --- .changeset/tough-ears-suffer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tough-ears-suffer.md b/.changeset/tough-ears-suffer.md index 9bdb363504..93a30cc1e3 100644 --- a/.changeset/tough-ears-suffer.md +++ b/.changeset/tough-ears-suffer.md @@ -1,7 +1,7 @@ --- '@envelop/types': minor '@envelop/core': minor -'@envelop/insturments': major +'@envelop/instruments': major --- Add new Tracer API From 4afa9eed0d7a03d4a80dab18ba96f9d7e8baeb88 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 27 Feb 2025 13:20:58 +0100 Subject: [PATCH 12/13] esm is hell --- packages/instruments/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/instruments/src/index.ts b/packages/instruments/src/index.ts index 156431ca5c..4489c4f11d 100644 --- a/packages/instruments/src/index.ts +++ b/packages/instruments/src/index.ts @@ -1,2 +1,2 @@ // eslint-disable-next-line import/export -export * from './instruments'; +export * from './instruments.js'; From 42e2fa806ec3b7c2936e612924b153a20c8662c3 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Thu, 27 Feb 2025 13:23:24 +0100 Subject: [PATCH 13/13] actually build for bob check to test ESM output --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4adb3b628..0508551a8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: - name: Install Dependencies using pnpm run: pnpm install --no-frozen-lockfile && git checkout pnpm-lock.yaml - name: Build - run: pnpm run ts:check + run: pnpm run build - name: Test ESM & CJS exports integrity run: pnpm bob check