From 2b455584ad22ccd1d91272b3664969444709f990 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 1 May 2020 23:07:58 -0400 Subject: [PATCH 1/4] feat: app assembly --- src/lib/reflection/fork-script.ts | 12 +- src/runtime/app.ts | 209 +++++------ src/runtime/schema/index.spec.ts | 12 +- src/runtime/schema/index.ts | 2 +- src/runtime/schema/schema.ts | 122 +++---- src/runtime/schema/settings.ts | 18 + src/runtime/server/handle.ts | 48 +++ .../server/request-handler-playground.ts | 70 ++++ src/runtime/server/server-old.ts | 272 +++++++++++++++ src/runtime/server/server.ts | 324 ++++++------------ src/runtime/server/settings.ts | 15 + src/runtime/settings/index.ts | 83 +++++ src/runtime/start/dev-runner.ts | 9 +- src/runtime/start/start-module.ts | 6 +- src/runtime/testing.ts | 16 +- 15 files changed, 784 insertions(+), 434 deletions(-) create mode 100644 src/runtime/server/handle.ts create mode 100644 src/runtime/server/request-handler-playground.ts create mode 100644 src/runtime/server/server-old.ts create mode 100644 src/runtime/settings/index.ts diff --git a/src/lib/reflection/fork-script.ts b/src/lib/reflection/fork-script.ts index 4017b0a76..b8f0263ab 100644 --- a/src/lib/reflection/fork-script.ts +++ b/src/lib/reflection/fork-script.ts @@ -12,7 +12,7 @@ async function run() { throw new Error('process.env.NEXUS_REFLECTION_LAYOUT is required') } - const app = require('../../').default as App.InternalApp + const app = require('../../').default as App.PrivateApp const layout = Layout.createFromData(JSON.parse(process.env.NEXUS_REFLECTION_LAYOUT) as Layout.Data) const appRunner = createDevAppRunner(layout, app, { catchUnhandledErrors: false, @@ -24,13 +24,11 @@ async function run() { sendErrorToParent(err) } - const plugins = app.__state.plugins() - if (isReflectionStage('plugin')) { sendDataToParent({ type: 'success-plugin', data: { - plugins, + plugins: app.private.state.plugins, }, }) @@ -38,14 +36,12 @@ async function run() { } if (isReflectionStage('typegen')) { - const graphqlSchema = app.__state.schema() - try { await writeArtifacts({ - graphqlSchema, + graphqlSchema: app.private.state.schemaComponent.schema!, layout, schemaSettings: app.settings.current.schema, - plugins: Plugin.importAndLoadRuntimePlugins(plugins), + plugins: Plugin.importAndLoadRuntimePlugins(app.private.state.plugins), }) sendDataToParent({ type: 'success-typegen', diff --git a/src/runtime/app.ts b/src/runtime/app.ts index a9b602806..f6fe8df08 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -1,11 +1,10 @@ import * as Logger from '@nexus/logger' import { stripIndent } from 'common-tags' -import * as Lo from 'lodash' import * as Plugin from '../lib/plugin' +import * as Reflection from '../lib/reflection' import * as Schema from './schema' import * as Server from './server' -import * as NexusSchema from '@nexus/schema' -import * as Reflection from '../lib/reflection' +import * as Settings from './settings' const log = Logger.create({ name: 'app' }) @@ -27,168 +26,126 @@ export interface App { /** * todo */ - settings: Settings + settings: Settings.Settings /** * [API Reference](https://www.nexusjs.org/#/api/modules/main/exports/schema) // [Guide](todo) * * ### todo */ schema: Schema.Schema - + /** + * todo + */ use(plugin: Plugin.Plugin): void -} - -type SettingsInput = { - logger?: Logger.SettingsInput - schema?: Schema.SettingsInput - server?: Server.SettingsInput -} - -export type SettingsData = Readonly<{ - logger: Logger.SettingsData - schema: Schema.SettingsData - server: Server.SettingsData -}> - -/** - * todo - */ -export type Settings = { /** * todo */ - original: SettingsData + assemble(): any + /** + * todo + */ + start(): any /** * todo */ - current: SettingsData + stop(): any /** * todo */ - change(newSetting: SettingsInput): void } export type AppState = { - plugins: () => Plugin.Plugin[] - schema: () => NexusSchema.core.NexusGraphQLSchema - isWasServerStartCalled: boolean + plugins: Plugin.Plugin[] + // schema: () => NexusSchema.core.NexusGraphQLSchema + /** + * Once the app is started incremental component APIs can no longer be used. This + * flag let's those APIs detect that they are being used after app start. Then + * they can do something useful like tell the user about their mistake. + */ + assembled: boolean + running: boolean + schemaComponent: Schema.LazyState } -export type InternalApp = App & { - __state: AppState +export type PrivateApp = App & { + private: { + state: AppState + } +} + +/** + * Create new app state. Be careful to pass this state to components to complete its + * data. The data returned only contains core state, despite what the return + * type says. + */ +export function createAppState(): AppState { + const appState = { + assembled: false, + running: false, + plugins: [], + } as Omit + + return appState as any } /** - * Crate an app instance + * Create an app instance */ export function create(): App { - let _plugins: Plugin.Plugin[] = [] - let _schema: NexusSchema.core.NexusGraphQLSchema | null = null + const appState = createAppState() + const serverComponent = Server.create(appState) + const schemaComponent = Schema.create(appState) + const settingsComponent = Settings.create({ + serverSettings: serverComponent.private.settings, + schemaSettings: schemaComponent.private.settings, + log, + }) + const api: PrivateApp = { + log: log, + settings: settingsComponent.public, + schema: schemaComponent.public, + server: serverComponent.public, + // todo call this in the start module + assemble() { + if (appState.assembled) return - const __state: InternalApp['__state'] = { - plugins() { - return _plugins - }, - schema() { - if (_schema === null) { - throw new Error('GraphQL schema was not built yet. server.start() needs to be run first') - } + appState.assembled = true - return _schema - }, - isWasServerStartCalled: false, - } + if (Reflection.isReflectionStage('plugin')) return - const server = Server.create() - const schemaComponent = Schema.create(__state) + const loadedRuntimePlugins = Plugin.importAndLoadRuntimePlugins(appState.plugins) - const settings: Settings = { - change(newSettings) { - if (newSettings.logger) { - log.settings(newSettings.logger) - } - if (newSettings.schema) { - schemaComponent.private.settings.change(newSettings.schema) - } - if (newSettings.server) { - server.settings.change(newSettings.server) - } + schemaComponent.private.assemble(loadedRuntimePlugins) + + if (Reflection.isReflectionStage('typegen')) return + + schemaComponent.private.checks() + + serverComponent.private.assemble(loadedRuntimePlugins, appState.schemaComponent.schema!) }, - current: { - logger: log.settings, - schema: schemaComponent.private.settings.data, - server: server.settings.data, + async start() { + if (appState.running) return + await serverComponent.private.start() + appState.running = true + }, + async stop() { + if (!appState.running) return + // todo should components hook onto an app event, "onStop"? + // todo should app state be reset? + await serverComponent.private.stop() + appState.running = false }, - original: Lo.cloneDeep({ - logger: log.settings, - schema: schemaComponent.private.settings.data, - server: server.settings.data, - }), - } - - const api: InternalApp = { - __state, - log: log, - settings: settings, - schema: schemaComponent.public, use(plugin) { - if (__state.isWasServerStartCalled === true) { + if (appState.assembled === true) { log.warn(stripIndent` A plugin was ignored because it was loaded after the server was started Make sure to call \`use\` before you call \`server.start\` `) } - - _plugins.push(plugin) + appState.plugins.push(plugin) }, - server: { - express: server.express, - /** - * Start the server. If you do not call this explicitly then nexus will - * for you. You should not normally need to call this function yourself. - */ - async start() { - // Track the start call so that we can know in entrypoint whether to run - // or not start for the user. - __state.isWasServerStartCalled = true - - /** - * If loading plugins, we need to return here, before loading the runtime plugins - */ - if (Reflection.isReflectionStage('plugin')) { - return Promise.resolve() - } - - const plugins = Plugin.importAndLoadRuntimePlugins(__state.plugins()) - const { schema, assertValidSchema } = schemaComponent.private.makeSchema(plugins) - - /** - * Set the schema in the app state so that the reflection step can access it - */ - _schema = schema - - /** - * If execution in in reflection stage, do not run the server - */ - if (Reflection.isReflectionStage('typegen')) { - return Promise.resolve() - } - - /** - * This needs to be done **after** the reflection step, - * to make sure typegen can still run in case of missing types - */ - assertValidSchema() - - await server.setupAndStart({ - schema, - plugins, - contextContributors: schemaComponent.private.state.contextContributors, - }) - }, - stop() { - return server.stop() - }, + private: { + state: appState, }, } diff --git a/src/runtime/schema/index.spec.ts b/src/runtime/schema/index.spec.ts index b525b5cd2..0330b5ff0 100644 --- a/src/runtime/schema/index.spec.ts +++ b/src/runtime/schema/index.spec.ts @@ -1,12 +1,14 @@ import { plugin } from '@nexus/schema' -import { create } from './schema' +import { AppState, createAppState } from '../app' +import { create, SchemaInternal } from './schema' import { mapSettingsToNexusSchemaConfig } from './settings' -let schema: ReturnType +let schema: SchemaInternal +let appState: AppState -// TODO: Figure out how to deal with `schema` and `plugins` beforeEach(() => { - schema = create({ isWasServerStartCalled: false, plugins: () => [], schema: () => null as any }) + appState = createAppState() + schema = create(appState) }) it('defaults to outputs being nullable by default', () => { @@ -39,6 +41,6 @@ describe('use', () => { it('incrementally adds plugins', () => { schema.public.use(plugin({ name: 'foo' })) schema.public.use(plugin({ name: 'bar' })) - expect(schema.private.state.schemaPlugins.length).toEqual(2) + expect(appState.schemaComponent.plugins.length).toEqual(2) }) }) diff --git a/src/runtime/schema/index.ts b/src/runtime/schema/index.ts index 6ee00a8a3..b05b75afd 100644 --- a/src/runtime/schema/index.ts +++ b/src/runtime/schema/index.ts @@ -1,2 +1,2 @@ -export { create, Schema } from './schema' +export { create, LazyState, Schema } from './schema' export { SettingsData, SettingsInput } from './settings' diff --git a/src/runtime/schema/schema.ts b/src/runtime/schema/schema.ts index fb7b60056..556aad623 100644 --- a/src/runtime/schema/schema.ts +++ b/src/runtime/schema/schema.ts @@ -10,7 +10,39 @@ import { RuntimeContributions } from '../../lib/plugin' import { MaybePromise } from '../../lib/utils' import { AppState } from '../app' import { log } from './logger' -import { changeSettings, mapSettingsToNexusSchemaConfig, SettingsData, SettingsInput } from './settings' +import { + createSchemaSettingsManager, + mapSettingsToNexusSchemaConfig, + SchemaSettingsManager, +} from './settings' + +export type LazyState = { + contextContributors: ContextContributor[] + plugins: NexusSchema.core.NexusPlugin[] + /** + * GraphQL schema built by @nexus/schema + * + * @remarks + * + * Only available after assembly + */ + schema: null | NexusSchema.core.NexusGraphQLSchema + /** + * @remarks + * + * Only available after assembly + */ + missingTypes: null | Record +} + +function createLazyState(): LazyState { + return { + contextContributors: [], + plugins: [], + schema: null, + missingTypes: null, + } +} // Export this so that context typegen can import it, for example if users do // this: @@ -23,7 +55,7 @@ export interface Request extends HTTP.IncomingMessage { log: Logger.Logger } -export type ContextContributor = (req: Req) => MaybePromise> +export type ContextContributor = (req: Request) => MaybePromise> type MiddlewareFn = ( source: any, @@ -37,56 +69,30 @@ export interface Schema extends NexusSchemaStatefulBuilders { /** * todo link to website docs */ - use: (schemaPlugin: NexusSchema.core.NexusPlugin) => void + use(schemaPlugin: NexusSchema.core.NexusPlugin): void /** * todo link to website docs */ - middleware: (fn: (config: CreateFieldResolverInfo) => MiddlewareFn | undefined) => void + middleware(fn: (config: CreateFieldResolverInfo) => MiddlewareFn | undefined): void /** * todo link to website docs */ - addToContext: (contextContributor: ContextContributor) => void + addToContext(contextContributor: ContextContributor): void } -interface SchemaInternal { +export interface SchemaInternal { private: { - state: { - settings: SettingsData - schemaPlugins: NexusSchema.core.NexusPlugin[] - contextContributors: ContextContributor[] - } - /** - * Create the Nexus GraphQL Schema - */ - makeSchema: ( - plugins: RuntimeContributions[] - ) => { - /** - * GraphQL schema built by @nexus/schema - */ - schema: NexusSchema.core.NexusGraphQLSchema - /** - * Assert that there are no missing types after running typegen only - * so that we don't block writing the typegen when eg: renaming types - */ - assertValidSchema: () => void - } - settings: { - data: SettingsData - change: (newSettings: SettingsInput) => void - } + settings: SchemaSettingsManager + checks(): void + assemble(plugins: RuntimeContributions[]): void } public: Schema } export function create(appState: AppState): SchemaInternal { + appState.schemaComponent = createLazyState() const statefulNexusSchema = createNexusSchemaStateful() - - const state: SchemaInternal['private']['state'] = { - schemaPlugins: [], - settings: {}, - contextContributors: [], - } + const settings = createSchemaSettingsManager() const middleware: SchemaInternal['public']['middleware'] = (fn) => { api.public.use( @@ -104,46 +110,42 @@ export function create(appState: AppState): SchemaInternal { public: { ...statefulNexusSchema.builders, use(plugin) { - if (appState.isWasServerStartCalled === true) { + if (appState.assembled === true) { log.warn(stripIndent` A Nexus Schema plugin was ignored because it was loaded after the server was started Make sure to call \`schema.use\` before you call \`server.start\` `) } - state.schemaPlugins.push(plugin) + appState.schemaComponent.plugins.push(plugin) }, addToContext(contextContributor) { - state.contextContributors.push(contextContributor) + appState.schemaComponent.contextContributors.push(contextContributor) }, middleware, }, private: { - state: state, - makeSchema: (plugins) => { - const nexusSchemaConfig = mapSettingsToNexusSchemaConfig(plugins, state.settings) + settings: settings, + checks() { + NexusSchema.core.assertNoMissingTypes( + appState.schemaComponent.schema!, + appState.schemaComponent.missingTypes! + ) + + if (statefulNexusSchema.state.types.length === 0) { + log.warn(Layout.schema.emptyExceptionMessage()) + } + }, + assemble: (plugins) => { + const nexusSchemaConfig = mapSettingsToNexusSchemaConfig(plugins, settings.data) nexusSchemaConfig.types.push(...statefulNexusSchema.state.types) - nexusSchemaConfig.plugins!.push(...state.schemaPlugins) + nexusSchemaConfig.plugins!.push(...appState.schemaComponent.plugins) const { schema, missingTypes } = makeSchemaInternal(nexusSchemaConfig) - return { - schema, - assertValidSchema() { - NexusSchema.core.assertNoMissingTypes(schema, missingTypes) - - if (statefulNexusSchema.state.types.length === 0) { - log.warn(Layout.schema.emptyExceptionMessage()) - } - }, - } - }, - settings: { - data: state.settings, - change(newSettings) { - changeSettings(state.settings, newSettings) - }, + appState.schemaComponent.schema = schema + appState.schemaComponent.missingTypes = missingTypes }, }, } diff --git a/src/runtime/schema/settings.ts b/src/runtime/schema/settings.ts index b69598a06..02016bb12 100644 --- a/src/runtime/schema/settings.ts +++ b/src/runtime/schema/settings.ts @@ -171,3 +171,21 @@ function defaultConnectionPlugin(configBase: ConnectionConfig): NexusSchema.core nexusFieldName: 'connection', }) } + +function defaultSettings(): SettingsInput { + return {} +} + +export function createSchemaSettingsManager() { + const data = defaultSettings() + const change = (newSettings: SettingsInput) => { + return changeSettings(data, newSettings) + } + + return { + change, + data, + } +} + +export type SchemaSettingsManager = ReturnType diff --git a/src/runtime/server/handle.ts b/src/runtime/server/handle.ts new file mode 100644 index 000000000..f1d3ea8cb --- /dev/null +++ b/src/runtime/server/handle.ts @@ -0,0 +1,48 @@ +import { execute, GraphQLSchema, parse, Source, validate } from 'graphql' + +interface NexusRequest { + // todo currently assumes request body has been parsed as JSON + body: Record +} + +function createGraphQLRequestHandler(schema: GraphQLSchema) { + function handle(req: NexusRequest) { + const data = req.body + const source = new Source(data.query) + + let documentAST + try { + documentAST = parse(source) + } catch (syntaxError) { + // todo + // https://github.com/graphql/express-graphql/blob/master/src/index.js + return + } + + const validationFailures = validate(schema, documentAST) + + if (validationFailures.length > 1) { + // todo + return + } + + // todo validate that if operation is mutation or subscription then http method is not GET + // https://github.com/graphql/express-graphql/blob/master/src/index.js#L296 + + let result + try { + result = execute({ + schema: schema, + document: documentAST, + // todo other options + }) + } catch (error) { + // todo + return + } + } + + return { + handle, + } +} diff --git a/src/runtime/server/request-handler-playground.ts b/src/runtime/server/request-handler-playground.ts new file mode 100644 index 000000000..20d6fc28d --- /dev/null +++ b/src/runtime/server/request-handler-playground.ts @@ -0,0 +1,70 @@ +import { RequestHandler } from 'express' + +type Settings = { + graphqlEndpoint: string +} + +type Handler = (settings: Settings) => RequestHandler + +export const createRequestHandlerPlayground: Handler = (settings) => (_req, res) => { + res.send(` + + + + + + + GraphQL Playground + + + + + + +
+ + +
Loading + GraphQL Playground +
+
+ + + + + `) +} diff --git a/src/runtime/server/server-old.ts b/src/runtime/server/server-old.ts new file mode 100644 index 000000000..7d7d363b3 --- /dev/null +++ b/src/runtime/server/server-old.ts @@ -0,0 +1,272 @@ +import * as Logger from '@nexus/logger' +import createExpress, { Express } from 'express' +import * as ExpressGraphQL from 'express-graphql' +import * as GraphQL from 'graphql' +import * as HTTP from 'http' +import * as Net from 'net' +import stripAnsi from 'strip-ansi' +import * as Plugin from '../../lib/plugin' +import * as Utils from '../../lib/utils' +import * as DevMode from '../dev-mode' +import { log } from './logger' +import * as ServerSettings from './settings' + +// Avoid forcing users to use esModuleInterop +const createExpressGraphql = ExpressGraphQL.default +const resolverLogger = log.child('graphql') + +type Request = HTTP.IncomingMessage & { log: Logger.Logger } +type ContextContributor = (req: Request) => Utils.MaybePromise + +export type ExpressInstance = Omit + +export interface BaseServer { + /** + * Start the server instance + */ + start(): Promise + /** + * Stop the server instance + */ + stop(): Promise +} + +/** + * [API Reference](https://www.nexusjs.org/#/api/modules/main/exports/server) ⌁ [Guide](todo) + * + * ### todo + * + */ +export interface Server extends BaseServer { + /** + * Gives access to the underlying express instance + * Do not use `express.listen` but `server.start` instead + */ + express: ExpressInstance +} + +export type SetupExpressInput = ExpressGraphQL.OptionsData & + ServerSettings.SettingsData & { + context: ContextCreator + } + +function setupExpress(express: Express, settings: SetupExpressInput): BaseServer { + const http = HTTP.createServer() + + http.on('request', express) + + express.use( + settings.path, + createExpressGraphql((req) => { + return settings.context(req).then((resolvedCtx) => ({ + ...settings, + context: resolvedCtx, + customFormatErrorFn: (error: Error) => { + const colorlessMessage = stripAnsi(error.message) + + if (process.env.NEXUS_STAGE === 'dev') { + resolverLogger.error(error.stack ?? error.message) + } else { + resolverLogger.error('An exception occured in one of your resolver', { + error: error.stack ? stripAnsi(error.stack) : colorlessMessage, + }) + } + + error.message = colorlessMessage + + return error + }, + })) + }) + ) + if (settings.playground) { + express.get(settings.playground.path, (_req, res) => { + res.send(` + + + + + + + GraphQL Playground + + + + + + +
+ + +
Loading + GraphQL Playground +
+
+ + + + + `) + }) + } + + return { + start: () => + new Promise((res) => { + http.listen({ port: settings.port, host: settings.host }, () => { + // - We do not support listening on unix domain sockets so string + // value will never be present here. + // - We are working within the listen callback so address will not be null + const address = http.address()! as Net.AddressInfo + settings.startMessage({ + port: address.port, + host: address.address, + ip: address.address, + path: settings.path, + playgroundPath: settings.playground ? settings.playground.path : undefined, + }) + res() + }) + }), + stop: () => + new Promise((res, rej) => { + http.close((err) => { + if (err) { + rej(err) + } else { + res() + } + }) + }), + } +} + +interface ServerFactory { + setupAndStart(opts: { + schema: GraphQL.GraphQLSchema + plugins: Plugin.RuntimeContributions[] + contextContributors: ContextContributor[] + }): Promise + stop(): Promise + express: ExpressInstance + settings: { + change(settings: ServerSettings.SettingsInput): void + data: ServerSettings.SettingsData + } +} + +export function create(): ServerFactory { + const express = createExpress() + let server: BaseServer | null = null + const state = { + settings: ServerSettings.defaultSettings(), + } + + return { + express, + async setupAndStart(opts: { + schema: GraphQL.GraphQLSchema + plugins: Plugin.RuntimeContributions[] + contextContributors: ContextContributor[] + }) { + const context = contextFactory(opts.contextContributors, opts.plugins) + + server = setupExpress(express, { + ...state.settings, + schema: opts.schema, + context, + }) + + await server.start() + + DevMode.sendServerReadySignalToDevModeMaster() + }, + stop() { + if (!server) { + log.warn('You called `server.stop` before calling `server.start`') + return Promise.resolve() + } + + return server.stop() + }, + settings: { + change(newSettings) { + state.settings = ServerSettings.changeSettings(state.settings, newSettings) + }, + data: state.settings, + }, + } +} + +type AnonymousRequest = Record +type AnonymousContext = Record + +type ContextCreator< + Req extends AnonymousRequest = AnonymousRequest, + Context extends AnonymousContext = AnonymousContext +> = (req: Req) => Promise + +function contextFactory( + contextContributors: ContextContributor[], + plugins: Plugin.RuntimeContributions[] +): ContextCreator { + const createContext: ContextCreator = async (req) => { + let context: Record = {} + + // Integrate context from plugins + for (const plugin of plugins) { + if (!plugin.context) continue + const contextContribution = await plugin.context.create(req) + + Object.assign(context, contextContribution) + } + + // Integrate context from app context api + // TODO good runtime feedback to user if something goes wrong + for (const contextContributor of contextContributors) { + const contextContribution = await contextContributor((req as unknown) as Request) + + Object.assign(context, contextContribution) + } + + Object.assign(context, { log: log.child('request') }) + + return context + } + + return createContext +} diff --git a/src/runtime/server/server.ts b/src/runtime/server/server.ts index 7d7d363b3..9e9e1eed5 100644 --- a/src/runtime/server/server.ts +++ b/src/runtime/server/server.ts @@ -1,238 +1,111 @@ -import * as Logger from '@nexus/logger' import createExpress, { Express } from 'express' import * as ExpressGraphQL from 'express-graphql' -import * as GraphQL from 'graphql' +import { GraphQLSchema } from 'graphql' import * as HTTP from 'http' import * as Net from 'net' import stripAnsi from 'strip-ansi' import * as Plugin from '../../lib/plugin' -import * as Utils from '../../lib/utils' -import * as DevMode from '../dev-mode' +import { AppState } from '../app' +import { ContextContributor } from '../schema/schema' import { log } from './logger' -import * as ServerSettings from './settings' +import { createRequestHandlerPlayground } from './request-handler-playground' +import { createServerSettingsManager } from './settings' // Avoid forcing users to use esModuleInterop const createExpressGraphql = ExpressGraphQL.default -const resolverLogger = log.child('graphql') -type Request = HTTP.IncomingMessage & { log: Logger.Logger } -type ContextContributor = (req: Request) => Utils.MaybePromise - -export type ExpressInstance = Omit - -export interface BaseServer { - /** - * Start the server instance - */ - start(): Promise - /** - * Stop the server instance - */ - stop(): Promise -} - -/** - * [API Reference](https://www.nexusjs.org/#/api/modules/main/exports/server) ⌁ [Guide](todo) - * - * ### todo - * - */ -export interface Server extends BaseServer { - /** - * Gives access to the underlying express instance - * Do not use `express.listen` but `server.start` instead - */ - express: ExpressInstance -} - -export type SetupExpressInput = ExpressGraphQL.OptionsData & - ServerSettings.SettingsData & { - context: ContextCreator - } - -function setupExpress(express: Express, settings: SetupExpressInput): BaseServer { - const http = HTTP.createServer() - - http.on('request', express) - - express.use( - settings.path, - createExpressGraphql((req) => { - return settings.context(req).then((resolvedCtx) => ({ - ...settings, - context: resolvedCtx, - customFormatErrorFn: (error: Error) => { - const colorlessMessage = stripAnsi(error.message) - - if (process.env.NEXUS_STAGE === 'dev') { - resolverLogger.error(error.stack ?? error.message) - } else { - resolverLogger.error('An exception occured in one of your resolver', { - error: error.stack ? stripAnsi(error.stack) : colorlessMessage, - }) - } - - error.message = colorlessMessage - - return error - }, - })) - }) - ) - if (settings.playground) { - express.get(settings.playground.path, (_req, res) => { - res.send(` - - - - - - - GraphQL Playground - - - - - - -
- - -
Loading - GraphQL Playground -
-
- - - - - `) - }) - } - - return { - start: () => - new Promise((res) => { - http.listen({ port: settings.port, host: settings.host }, () => { - // - We do not support listening on unix domain sockets so string - // value will never be present here. - // - We are working within the listen callback so address will not be null - const address = http.address()! as Net.AddressInfo - settings.startMessage({ - port: address.port, - host: address.address, - ip: address.address, - path: settings.path, - playgroundPath: settings.playground ? settings.playground.path : undefined, - }) - res() - }) - }), - stop: () => - new Promise((res, rej) => { - http.close((err) => { - if (err) { - rej(err) - } else { - res() - } - }) - }), - } +export interface Server { + express: Express } -interface ServerFactory { - setupAndStart(opts: { - schema: GraphQL.GraphQLSchema - plugins: Plugin.RuntimeContributions[] - contextContributors: ContextContributor[] - }): Promise - stop(): Promise - express: ExpressInstance - settings: { - change(settings: ServerSettings.SettingsInput): void - data: ServerSettings.SettingsData - } -} - -export function create(): ServerFactory { +export function create(appState: AppState) { + const resolverLogger = log.child('graphql') + const settings = createServerSettingsManager() const express = createExpress() - let server: BaseServer | null = null const state = { - settings: ServerSettings.defaultSettings(), + running: false, + httpServer: HTTP.createServer(), } return { - express, - async setupAndStart(opts: { - schema: GraphQL.GraphQLSchema - plugins: Plugin.RuntimeContributions[] - contextContributors: ContextContributor[] - }) { - const context = contextFactory(opts.contextContributors, opts.plugins) - - server = setupExpress(express, { - ...state.settings, - schema: opts.schema, - context, - }) - - await server.start() - - DevMode.sendServerReadySignalToDevModeMaster() - }, - stop() { - if (!server) { - log.warn('You called `server.stop` before calling `server.start`') - return Promise.resolve() - } - - return server.stop() - }, - settings: { - change(newSettings) { - state.settings = ServerSettings.changeSettings(state.settings, newSettings) + private: { + settings, + state, + assemble(loadedRuntimePlugins: Plugin.RuntimeContributions[], schema: GraphQLSchema) { + state.httpServer.on('request', express) + + if (settings.data.playground) { + express.get( + settings.data.playground.path, + createRequestHandlerPlayground({ graphqlEndpoint: settings.data.playground.path }) + ) + } + + const createContext = createContextCreator( + appState.schemaComponent.contextContributors, + loadedRuntimePlugins + ) + + express.use( + settings.data.path, + createExpressGraphql((req) => { + return createContext(req).then((context) => ({ + ...settings, + context: context, + schema: schema, + customFormatErrorFn: (error: Error) => { + const colorlessMessage = stripAnsi(error.message) + + if (process.env.NEXUS_STAGE === 'dev') { + resolverLogger.error(error.stack ?? error.message) + } else { + resolverLogger.error('An exception occured in one of your resolver', { + error: error.stack ? stripAnsi(error.stack) : colorlessMessage, + }) + } + + error.message = colorlessMessage + + return error + }, + })) + }) + ) }, - data: state.settings, + async start() { + await httpListen(state.httpServer, { port: settings.data.port, host: settings.data.host }) + + // About ! + // 1. We do not support listening on unix domain sockets so string + // value will never be present here. + // 2. We are working within the listen callback so address will not be null + const address = state.httpServer.address()! as Net.AddressInfo + + settings.data.startMessage({ + port: address.port, + host: address.address, + ip: address.address, + path: settings.data.path, + playgroundPath: settings.data.playground ? settings.data.playground.path : undefined, + }) + }, + async stop() { + if (!state.running) { + log.warn('You called `server.stop` but the server was not running.') + return Promise.resolve() + } + await httpClose(state.httpServer) + state.running = false + }, + }, + public: { + express, }, } } type AnonymousRequest = Record + type AnonymousContext = Record type ContextCreator< @@ -240,8 +113,11 @@ type ContextCreator< Context extends AnonymousContext = AnonymousContext > = (req: Req) => Promise -function contextFactory( - contextContributors: ContextContributor[], +/** + * Combine all the context contributions defined in the app and in plugins. + */ +function createContextCreator( + contextContributors: ContextContributor[], plugins: Plugin.RuntimeContributions[] ): ContextCreator { const createContext: ContextCreator = async (req) => { @@ -258,7 +134,7 @@ function contextFactory( // Integrate context from app context api // TODO good runtime feedback to user if something goes wrong for (const contextContributor of contextContributors) { - const contextContribution = await contextContributor((req as unknown) as Request) + const contextContribution = await contextContributor(req as any) Object.assign(context, contextContribution) } @@ -270,3 +146,23 @@ function contextFactory( return createContext } + +function httpListen(server: HTTP.Server, options: Net.ListenOptions): Promise { + return new Promise((res, rej) => { + server.listen(options, () => { + res() + }) + }) +} + +function httpClose(server: HTTP.Server): Promise { + return new Promise((res, rej) => { + server.close((err) => { + if (err) { + rej(err) + } else { + res() + } + }) + }) +} diff --git a/src/runtime/server/settings.ts b/src/runtime/server/settings.ts index 8338f13a8..14ff5594e 100644 --- a/src/runtime/server/settings.ts +++ b/src/runtime/server/settings.ts @@ -58,6 +58,7 @@ export type SettingsData = Omit, 'host' | 'pla } export const defaultPlaygroundPath = '/' + export const defaultPlaygroundSettings: () => Readonly> = () => Object.freeze({ path: defaultPlaygroundPath, @@ -148,3 +149,17 @@ export function changeSettings(oldSettings: SettingsData, newSettings: SettingsI playground, } } + +export function createServerSettingsManager() { + const data = defaultSettings() + const change = (newSettings: SettingsInput) => { + return changeSettings(data, newSettings) + } + + return { + change, + data, + } +} + +export type ServerSettingsManager = ReturnType diff --git a/src/runtime/settings/index.ts b/src/runtime/settings/index.ts new file mode 100644 index 000000000..7e7e2212e --- /dev/null +++ b/src/runtime/settings/index.ts @@ -0,0 +1,83 @@ +import * as Logger from '@nexus/logger' +import { cloneDeep } from 'lodash' +import * as Schema from '../schema' +import { SchemaSettingsManager } from '../schema/settings' +import * as Server from '../server' +import { ServerSettingsManager } from '../server/settings' + +type SettingsInput = { + logger?: Logger.SettingsInput + schema?: Schema.SettingsInput + server?: Server.SettingsInput +} + +export type SettingsData = Readonly<{ + logger: Logger.SettingsData + schema: Schema.SettingsData + server: Server.SettingsData +}> + +/** + * todo + */ +export type Settings = { + /** + * todo + */ + original: SettingsData + /** + * todo + */ + current: SettingsData + /** + * todo + */ + change(newSetting: SettingsInput): void +} + +/** + * Create an app settings component. + * + * @remarks + * + * The app settings component centralizes settings management of all other + * components. Therefore it depends on the other components. It requires their + * settings managers. + */ +export function create({ + schemaSettings, + serverSettings, + log, +}: { + schemaSettings: SchemaSettingsManager + serverSettings: ServerSettingsManager + log: Logger.RootLogger +}) { + const settings: Settings = { + change(newSettings) { + if (newSettings.logger) { + log.settings(newSettings.logger) + } + if (newSettings.schema) { + schemaSettings.change(newSettings.schema) + } + if (newSettings.server) { + serverSettings.change(newSettings.server) + } + }, + current: { + logger: log.settings, + schema: schemaSettings.data, + server: serverSettings.data, + }, + original: cloneDeep({ + logger: log.settings, + schema: schemaSettings.data, + server: serverSettings.data, + }), + } + + return { + public: settings, + } +} diff --git a/src/runtime/start/dev-runner.ts b/src/runtime/start/dev-runner.ts index 5961b01c8..1e82c7cd5 100644 --- a/src/runtime/start/dev-runner.ts +++ b/src/runtime/start/dev-runner.ts @@ -1,8 +1,7 @@ import * as ts from 'typescript' import * as Layout from '../../lib/layout' import { transpileModule } from '../../lib/tsc' -import type { InternalApp } from '../app' -import * as Server from '../server' +import type { PrivateApp } from '../app' import { createStartModuleContent, StartModuleOptions } from './start-module' export interface DevRunner { @@ -22,7 +21,7 @@ export interface DevRunner { export function createDevAppRunner( layout: Layout.Layout, - appSingleton: InternalApp, + appSingleton: PrivateApp, opts?: { catchUnhandledErrors?: StartModuleOptions['catchUnhandledErrors'] } @@ -37,7 +36,7 @@ export function createDevAppRunner( layout: layout, absoluteModuleImports: true, runtimePluginManifests: [], - catchUnhandledErrors: opts?.catchUnhandledErrors + catchUnhandledErrors: opts?.catchUnhandledErrors, }) const transpiledStartModule = transpileModule(startModule, { @@ -50,7 +49,7 @@ export function createDevAppRunner( start: () => { return eval(transpiledStartModule) }, - stop: () => appSingleton.server.stop(), + stop: () => appSingleton.stop(), port: appSingleton.settings.current.server.port, } } diff --git a/src/runtime/start/start-module.ts b/src/runtime/start/start-module.ts index 946c9c029..6cbdec9d4 100644 --- a/src/runtime/start/start-module.ts +++ b/src/runtime/start/start-module.ts @@ -151,10 +151,8 @@ export function createStartModuleContent(config: StartModuleConfig): string { content += EOL + EOL + EOL content += stripIndent` - // Boot the server if the user did not already. - if (app.__state.isWasServerStartCalled === false) { - app.server.start() - } + app.assemble() + app.start() ` log.trace('created start module', { content }) diff --git a/src/runtime/testing.ts b/src/runtime/testing.ts index 659516689..342d90b08 100644 --- a/src/runtime/testing.ts +++ b/src/runtime/testing.ts @@ -3,7 +3,7 @@ import * as Lo from 'lodash' import { GraphQLClient } from '../lib/graphql-client' import * as Layout from '../lib/layout' import * as Plugin from '../lib/plugin' -import type { InternalApp } from './app' +import type { PrivateApp } from './app' import { createDevAppRunner } from './start' type AppClient = { @@ -72,31 +72,25 @@ export async function createTestContext(): Promise { const layout = await Layout.create() const pluginManifests = await Plugin.getUsedPlugins(layout) const randomPort = await getPort({ port: getPort.makeRange(4000, 6000) }) - const app = require('../index') as InternalApp + const app = require('../index') as PrivateApp app.settings.change({ server: { port: randomPort, - startMessage: () => {}, // Make server silent playground: false, // Disable playground during tests + startMessage() {}, // Make server silent }, }) const appRunner = await createDevAppRunner(layout, app) - - const server = { - start: appRunner.start, - stop: appRunner.stop, - } - const apiUrl = `http://localhost:${appRunner.port}/graphql` const appClient = createAppClient(apiUrl) const testContextCore: TestContextCore = { app: { query: appClient.query, server: { - start: server.start, - stop: app.server.stop, + start: appRunner.start, + stop: appRunner.stop, }, }, } From e7b01a297a6f59563a3243758c1663d1745589bc Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 1 May 2020 23:18:36 -0400 Subject: [PATCH 2/4] do not start during reflection --- src/lib/reflection/stage.ts | 7 +++++++ src/runtime/app.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/reflection/stage.ts b/src/lib/reflection/stage.ts index 8398267c5..8469aedb2 100644 --- a/src/lib/reflection/stage.ts +++ b/src/lib/reflection/stage.ts @@ -17,3 +17,10 @@ export function saveReflectionStageEnv(type: ReflectionType) { export function isReflectionStage(type: ReflectionType) { return process.env[REFLECTION_ENV_VAR] === type } + +/** + * Check whether the app is executing in its reflection stage + */ +export function isReflection() { + return process.env[REFLECTION_ENV_VAR] !== undefined +} diff --git a/src/runtime/app.ts b/src/runtime/app.ts index f6fe8df08..6566f81d1 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -124,14 +124,14 @@ export function create(): App { serverComponent.private.assemble(loadedRuntimePlugins, appState.schemaComponent.schema!) }, async start() { + if (Reflection.isReflection()) return if (appState.running) return await serverComponent.private.start() appState.running = true }, async stop() { + if (Reflection.isReflection()) return if (!appState.running) return - // todo should components hook onto an app event, "onStop"? - // todo should app state be reset? await serverComponent.private.stop() appState.running = false }, From d5ae7f1eea0d8d1073b099e86c328f1f224ab8e7 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 1 May 2020 23:28:24 -0400 Subject: [PATCH 3/4] some minor arch doc additions --- docs/architecture.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 5fc869564..14ae23a21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,3 +1,11 @@ +## Reflection + +todo + +## Assembly + +todo + ## Build Flow 1. The app layout is calculated @@ -37,3 +45,21 @@ what follows is a stub 1. validate imported value 1. load plugin 1. catch any load errors + +## Glossary + +### Assembly + +The process in which the app configuration (settings, schema type defs, used plugins, etc.) is processed into a runnable state. Nexus apps are only ever assembled once in their lifecycle. + +### Dynamic Reflection + +The process in which the app is run in order to extract data out of it. The data in turn can be used for anything. For example typegen to provide better TypeScript types or GraphQL SDL generation. + +### Static Reflection + +The process in which the app is analyzed with the TS API to extract data out of it. The data in turn can be used for anything. For example typegen to provide better TypeScript types. + +### Reflection + +The general idea of the app source code or its state at runtime being analyzed to extract data. From 06157d99b4a28142cf2b578e386632d4b0e78d66 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 1 May 2020 23:30:35 -0400 Subject: [PATCH 4/4] remove server start/stop doc --- docs/api/modules/main/exports/server.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/api/modules/main/exports/server.md b/docs/api/modules/main/exports/server.md index 0a911d585..33c4445f3 100644 --- a/docs/api/modules/main/exports/server.md +++ b/docs/api/modules/main/exports/server.md @@ -4,22 +4,6 @@ Use the server to run the HTTP server that clients will connect to. -### `start` - -Make the server start listening for incoming client connections. - -Calling start while already started is a no-op. - -Normally you should not need to use this method. When your app does not call `server.start`, Nexus will do so for you automatically. - -### `stop` - -Make the server stop listening for incoming client connections. - -Calling stop while the server is already stopped is a no-op. - -Normally you should not need to use this method. - ### `express` Gives you access to the underlying `express` instance.