diff --git a/packages/nestjs/src/driver.ts b/packages/nestjs/src/driver.ts new file mode 100644 index 0000000000..20054645ed --- /dev/null +++ b/packages/nestjs/src/driver.ts @@ -0,0 +1,319 @@ +import type { + Express, + Request as ExpressRequest, + Response as ExpressResponse, +} from 'express' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { printSchema } from 'graphql' +import { createYoga, YogaServerInstance, YogaServerOptions } from 'graphql-yoga' +import type { ExecutionParams } from 'subscriptions-transport-ws' +import { Injectable, Logger } from '@nestjs/common' +import { + AbstractGraphQLDriver, + GqlModuleOptions, + GqlSubscriptionService, + SubscriptionConfig, +} from '@nestjs/graphql' + +export type YogaDriverPlatform = 'express' | 'fastify' + +export type YogaDriverServerContext = + Platform extends 'fastify' + ? { + req: FastifyRequest + reply: FastifyReply + } + : { + req: ExpressRequest + res: ExpressResponse + } + +export type YogaDriverServerOptions = Omit< + YogaServerOptions, never>, + 'context' | 'schema' +> + +export type YogaDriverServerInstance = + YogaServerInstance, never> + +export type YogaDriverConfig = + GqlModuleOptions & + YogaDriverServerOptions & { + /** + * Subscriptions configuration. Passing `true` will install only `graphql-ws`. + */ + subscriptions?: boolean | YogaDriverSubscriptionConfig + } + +export type YogaDriverSubscriptionConfig = { + 'graphql-ws'?: Omit + 'subscriptions-transport-ws'?: Omit< + SubscriptionConfig['subscriptions-transport-ws'], + 'onOperation' + > +} + +export abstract class AbstractYogaDriver< + Platform extends YogaDriverPlatform, +> extends AbstractGraphQLDriver> { + protected yoga!: YogaDriverServerInstance + + public async start(options: YogaDriverConfig) { + const platformName = this.httpAdapterHost.httpAdapter.getType() as Platform + options = { + ...options, + // disable error masking by default + maskedErrors: options.maskedErrors == null ? false : options.maskedErrors, + // disable graphiql in production + graphiql: + options.graphiql == null + ? process.env.NODE_ENV !== 'production' + : options.graphiql, + } + if (platformName === 'express') { + return this.registerExpress(options as YogaDriverConfig<'express'>) + } + if (platformName === 'fastify') { + return this.registerFastify(options as YogaDriverConfig<'fastify'>) + } + throw new Error(`Provided HttpAdapter "${platformName}" not supported`) + } + + public async stop() { + // noop + } + + protected registerExpress( + options: YogaDriverConfig<'express'>, + { preStartHook }: { preStartHook?: (app: Express) => void } = {}, + ) { + const app: Express = this.httpAdapterHost.httpAdapter.getInstance() + + preStartHook?.(app) + + // nest's logger doesnt have the info method + class LoggerWithInfo extends Logger { + constructor(context: string) { + super(context) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info(message: any, ...args: any[]) { + this.log(message, ...args) + } + } + + const yoga = createYoga>({ + ...options, + graphqlEndpoint: options.path, + // disable logging by default + // however, if `true` use nest logger + logging: + options.logging == null + ? false + : options.logging + ? new LoggerWithInfo('YogaDriver') + : options.logging, + }) + + this.yoga = yoga as YogaDriverServerInstance + + app.use(yoga.graphqlEndpoint, (req, res) => yoga(req, res, { req, res })) + } + + protected registerFastify( + options: YogaDriverConfig<'fastify'>, + { preStartHook }: { preStartHook?: (app: FastifyInstance) => void } = {}, + ) { + const app: FastifyInstance = this.httpAdapterHost.httpAdapter.getInstance() + + preStartHook?.(app) + + const yoga = createYoga>({ + ...options, + graphqlEndpoint: options.path, + // disable logging by default + // however, if `true` use fastify logger + logging: + options.logging == null + ? false + : options.logging + ? app.log + : options.logging, + }) + + this.yoga = yoga as YogaDriverServerInstance + + app.all(yoga.graphqlEndpoint, async (req, reply) => { + const response = await yoga.handleNodeRequest(req, { + req, + reply, + }) + response.headers.forEach((value, key) => reply.header(key, value)) + reply.status(response.status) + reply.send(response.body) + return reply + }) + } +} + +@Injectable() +export class YogaDriver< + Platform extends YogaDriverPlatform = 'express', +> extends AbstractYogaDriver { + private subscriptionService?: GqlSubscriptionService + + public async start(options: YogaDriverConfig) { + const opts = await this.graphQlFactory.mergeWithSchema< + YogaDriverConfig + >(options) + + if (opts.definitions?.path) { + if (!opts.schema) { + throw new Error('Schema is required when generating definitions') + } + await this.graphQlFactory.generateDefinitions( + printSchema(opts.schema), + opts, + ) + } + + await super.start(opts) + + if (opts.subscriptions) { + if (!opts.schema) { + throw new Error('Schema is required when using subscriptions') + } + + const config: SubscriptionConfig = + opts.subscriptions === true + ? { + 'graphql-ws': true, + } + : opts.subscriptions + + if (config['graphql-ws']) { + config['graphql-ws'] = + typeof config['graphql-ws'] === 'object' ? config['graphql-ws'] : {} + + config['graphql-ws'].onSubscribe = async (ctx, msg) => { + const { + schema, + execute, + subscribe, + contextFactory, + parse, + validate, + } = this.yoga.getEnveloped({ + ...ctx, + // @ts-expect-error context extra is from graphql-ws/lib/use/ws + req: ctx.extra.request, + // @ts-expect-error context extra is from graphql-ws/lib/use/ws + socket: ctx.extra.socket, + params: msg.payload, + }) + + const args = { + schema, + operationName: msg.payload.operationName, + document: parse(msg.payload.query), + variableValues: msg.payload.variables, + contextValue: await contextFactory({ execute, subscribe }), + } + + const errors = validate(args.schema, args.document) + if (errors.length) return errors + return args + } + } + + if (config['subscriptions-transport-ws']) { + config['subscriptions-transport-ws'] = + typeof config['subscriptions-transport-ws'] === 'object' + ? config['subscriptions-transport-ws'] + : {} + + config['subscriptions-transport-ws'].onOperation = async ( + _msg: unknown, + params: ExecutionParams, + ws: WebSocket, + ) => { + const { + schema, + execute, + subscribe, + contextFactory, + parse, + validate, + } = this.yoga.getEnveloped({ + ...params.context, + req: + // @ts-expect-error upgradeReq does exist but is untyped + ws.upgradeReq, + socket: ws, + params, + }) + + const args = { + schema, + operationName: params.operationName, + document: + typeof params.query === 'string' + ? parse(params.query) + : params.query, + variables: params.variables, + context: await contextFactory({ execute, subscribe }), + } + + const errors = validate(args.schema, args.document) + if (errors.length) return errors + return args + } + } + + this.subscriptionService = new GqlSubscriptionService( + { + schema: opts.schema, + path: opts.path, + execute: (...args) => { + const contextValue = + args[0].contextValue || + // @ts-expect-error args can be inlined with graphql-js@<=15 + args[3] + if (!contextValue) { + throw new Error( + 'Execution arguments are missing the context value', + ) + } + return ( + contextValue + // @ts-expect-error execute method will be available, see above + .execute(...args) + ) + }, + subscribe: (...args) => { + const contextValue = + args[0].contextValue || + // @ts-expect-error args can be inlined with graphql-js@<=15 + args?.[3] + if (!contextValue) { + throw new Error( + 'Subscribe arguments are missing the context value', + ) + } + return ( + contextValue + // @ts-expect-error execute method will be available, see above + .subscribe(...args) + ) + }, + ...config, + }, + this.httpAdapterHost.httpAdapter.getHttpServer(), + ) + } + } + + public async stop() { + await this.subscriptionService?.stop() + } +} diff --git a/packages/nestjs/src/federation.ts b/packages/nestjs/src/federation.ts index 24775dea85..be5c8ce21b 100644 --- a/packages/nestjs/src/federation.ts +++ b/packages/nestjs/src/federation.ts @@ -8,7 +8,7 @@ import { YogaDriver, YogaDriverConfig, YogaDriverPlatform, -} from './index.js' +} from './driver.js' export type YogaFederationDriverConfig< Platform extends YogaDriverPlatform = 'express', diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index 04401a86e4..b00780fac7 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -1,321 +1,2 @@ -import type { - Express, - Request as ExpressRequest, - Response as ExpressResponse, -} from 'express' -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' -import { printSchema } from 'graphql' -import { createYoga, YogaServerInstance, YogaServerOptions } from 'graphql-yoga' -import type { ExecutionParams } from 'subscriptions-transport-ws' -import { Injectable, Logger } from '@nestjs/common' -import { - AbstractGraphQLDriver, - GqlModuleOptions, - GqlSubscriptionService, - SubscriptionConfig, -} from '@nestjs/graphql' - +export * from './driver.js' export * from './federation.js' - -export type YogaDriverPlatform = 'express' | 'fastify' - -export type YogaDriverServerContext = - Platform extends 'fastify' - ? { - req: FastifyRequest - reply: FastifyReply - } - : { - req: ExpressRequest - res: ExpressResponse - } - -export type YogaDriverServerOptions = Omit< - YogaServerOptions, never>, - 'context' | 'schema' -> - -export type YogaDriverServerInstance = - YogaServerInstance, never> - -export type YogaDriverConfig = - GqlModuleOptions & - YogaDriverServerOptions & { - /** - * Subscriptions configuration. Passing `true` will install only `graphql-ws`. - */ - subscriptions?: boolean | YogaDriverSubscriptionConfig - } - -export type YogaDriverSubscriptionConfig = { - 'graphql-ws'?: Omit - 'subscriptions-transport-ws'?: Omit< - SubscriptionConfig['subscriptions-transport-ws'], - 'onOperation' - > -} - -export abstract class AbstractYogaDriver< - Platform extends YogaDriverPlatform, -> extends AbstractGraphQLDriver> { - protected yoga!: YogaDriverServerInstance - - public async start(options: YogaDriverConfig) { - const platformName = this.httpAdapterHost.httpAdapter.getType() as Platform - options = { - ...options, - // disable error masking by default - maskedErrors: options.maskedErrors == null ? false : options.maskedErrors, - // disable graphiql in production - graphiql: - options.graphiql == null - ? process.env.NODE_ENV !== 'production' - : options.graphiql, - } - if (platformName === 'express') { - return this.registerExpress(options as YogaDriverConfig<'express'>) - } - if (platformName === 'fastify') { - return this.registerFastify(options as YogaDriverConfig<'fastify'>) - } - throw new Error(`Provided HttpAdapter "${platformName}" not supported`) - } - - public async stop() { - // noop - } - - protected registerExpress( - options: YogaDriverConfig<'express'>, - { preStartHook }: { preStartHook?: (app: Express) => void } = {}, - ) { - const app: Express = this.httpAdapterHost.httpAdapter.getInstance() - - preStartHook?.(app) - - // nest's logger doesnt have the info method - class LoggerWithInfo extends Logger { - constructor(context: string) { - super(context) - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - info(message: any, ...args: any[]) { - this.log(message, ...args) - } - } - - const yoga = createYoga>({ - ...options, - graphqlEndpoint: options.path, - // disable logging by default - // however, if `true` use nest logger - logging: - options.logging == null - ? false - : options.logging - ? new LoggerWithInfo('YogaDriver') - : options.logging, - }) - - this.yoga = yoga as YogaDriverServerInstance - - app.use(yoga.graphqlEndpoint, (req, res) => yoga(req, res, { req, res })) - } - - protected registerFastify( - options: YogaDriverConfig<'fastify'>, - { preStartHook }: { preStartHook?: (app: FastifyInstance) => void } = {}, - ) { - const app: FastifyInstance = this.httpAdapterHost.httpAdapter.getInstance() - - preStartHook?.(app) - - const yoga = createYoga>({ - ...options, - graphqlEndpoint: options.path, - // disable logging by default - // however, if `true` use fastify logger - logging: - options.logging == null - ? false - : options.logging - ? app.log - : options.logging, - }) - - this.yoga = yoga as YogaDriverServerInstance - - app.all(yoga.graphqlEndpoint, async (req, reply) => { - const response = await yoga.handleNodeRequest(req, { - req, - reply, - }) - response.headers.forEach((value, key) => reply.header(key, value)) - reply.status(response.status) - reply.send(response.body) - return reply - }) - } -} - -@Injectable() -export class YogaDriver< - Platform extends YogaDriverPlatform = 'express', -> extends AbstractYogaDriver { - private subscriptionService?: GqlSubscriptionService - - public async start(options: YogaDriverConfig) { - const opts = await this.graphQlFactory.mergeWithSchema< - YogaDriverConfig - >(options) - - if (opts.definitions?.path) { - if (!opts.schema) { - throw new Error('Schema is required when generating definitions') - } - await this.graphQlFactory.generateDefinitions( - printSchema(opts.schema), - opts, - ) - } - - await super.start(opts) - - if (opts.subscriptions) { - if (!opts.schema) { - throw new Error('Schema is required when using subscriptions') - } - - const config: SubscriptionConfig = - opts.subscriptions === true - ? { - 'graphql-ws': true, - } - : opts.subscriptions - - if (config['graphql-ws']) { - config['graphql-ws'] = - typeof config['graphql-ws'] === 'object' ? config['graphql-ws'] : {} - - config['graphql-ws'].onSubscribe = async (ctx, msg) => { - const { - schema, - execute, - subscribe, - contextFactory, - parse, - validate, - } = this.yoga.getEnveloped({ - ...ctx, - // @ts-expect-error context extra is from graphql-ws/lib/use/ws - req: ctx.extra.request, - // @ts-expect-error context extra is from graphql-ws/lib/use/ws - socket: ctx.extra.socket, - params: msg.payload, - }) - - const args = { - schema, - operationName: msg.payload.operationName, - document: parse(msg.payload.query), - variableValues: msg.payload.variables, - contextValue: await contextFactory({ execute, subscribe }), - } - - const errors = validate(args.schema, args.document) - if (errors.length) return errors - return args - } - } - - if (config['subscriptions-transport-ws']) { - config['subscriptions-transport-ws'] = - typeof config['subscriptions-transport-ws'] === 'object' - ? config['subscriptions-transport-ws'] - : {} - - config['subscriptions-transport-ws'].onOperation = async ( - _msg: unknown, - params: ExecutionParams, - ws: WebSocket, - ) => { - const { - schema, - execute, - subscribe, - contextFactory, - parse, - validate, - } = this.yoga.getEnveloped({ - ...params.context, - req: - // @ts-expect-error upgradeReq does exist but is untyped - ws.upgradeReq, - socket: ws, - params, - }) - - const args = { - schema, - operationName: params.operationName, - document: - typeof params.query === 'string' - ? parse(params.query) - : params.query, - variables: params.variables, - context: await contextFactory({ execute, subscribe }), - } - - const errors = validate(args.schema, args.document) - if (errors.length) return errors - return args - } - } - - this.subscriptionService = new GqlSubscriptionService( - { - schema: opts.schema, - path: opts.path, - execute: (...args) => { - const contextValue = - args[0].contextValue || - // @ts-expect-error args can be inlined with graphql-js@<=15 - args[3] - if (!contextValue) { - throw new Error( - 'Execution arguments are missing the context value', - ) - } - return ( - contextValue - // @ts-expect-error execute method will be available, see above - .execute(...args) - ) - }, - subscribe: (...args) => { - const contextValue = - args[0].contextValue || - // @ts-expect-error args can be inlined with graphql-js@<=15 - args?.[3] - if (!contextValue) { - throw new Error( - 'Subscribe arguments are missing the context value', - ) - } - return ( - contextValue - // @ts-expect-error execute method will be available, see above - .subscribe(...args) - ) - }, - ...config, - }, - this.httpAdapterHost.httpAdapter.getHttpServer(), - ) - } - } - - public async stop() { - await this.subscriptionService?.stop() - } -}