diff --git a/docs/architecture.md b/docs/architecture.md index 2a3e369ed..252a44732 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ what follows is a stub ## Build Flow 1. The app layout is calculated - We discover things like where the entrypoint is, if any, and where graphql modules are, if any. + We discover things like where the entrypoint is, if any, and where [Nexus modules](/guides/project-layout?id=nexus-modules) are, if any. 1. Worktime plugins are loaded (see [Plugin Loading Flow](#plugin-loading-flow)) 1. Typegen is acquired This step is about processes that reflect upon the app's source code to extract type information that will be automatically used in other parts of the app. This approach is relatively novel among Node tools. There are dynamic and static processes. The static ones use the TypeScript compiler API while the dynamic ones literally run the app with node in a special reflective mode. @@ -103,7 +103,7 @@ what follows is a stub Static doesn't have to deal with the unpredictabilities of running an app and so has the benefit of being easier to reason about in a sense. It also has the benefit of extracting accurate type information using the native TS system whereas dynamic relies on building TS types from scratch. This makes static a fit for arbitrary code. On the downside, robust AST processing is hard work, and so, so far, static restricts how certain expressions can be written, otherwise AST traversal fails. - 1. A start module is created in memory. It imports the entrypoint and all graphql modules. It registers an extension hook to transpile the TypeScript app on the fly as it is run. The transpilation uses the project's tsconfig but overrides target and module so that it is runnable by Node (10 and up). Specificaly es2015 target and commonjs module. For example if user had module of `esnext` the transpilation result would not be runnable by Node. + 1. A start module is created in memory. It imports the entrypoint and all [Nexus modules](/guides/project-layout?id=nexus-modules). It registers an extension hook to transpile the TypeScript app on the fly as it is run. The transpilation uses the project's tsconfig but overrides target and module so that it is runnable by Node (10 and up). Specificaly es2015 target and commonjs module. For example if user had module of `esnext` the transpilation result would not be runnable by Node. 1. The start module is run in a sub-process for maximum isolation. (we're looking at running within workers [#752](https://github.com/graphql-nexus/nexus/issues/752)) 1. In parallel, a TypeScript instance is created and the app source is statically analyzed to extract context types. This does not require running the app at all. TypeScript cache called tsbuildinfo is stored under `node_modules/.nexus`. diff --git a/docs/guides/project-layout.md b/docs/guides/project-layout.md index 159141786..8a434903f 100644 --- a/docs/guides/project-layout.md +++ b/docs/guides/project-layout.md @@ -32,21 +32,19 @@ if `compilerOptions.noEmit` is set to `true` then Nexus will not output the buil Nexus imposes a few requirements about how you structure your codebase. -### Schema Module(s) +### Nexus module(s) ##### Pattern -A `graphql` module or directory of modules `graphql.ts` `graphql/*.ts`. - -This may be repeated multiple times in your source tree. +A file importing `nexus`. eg: `import { schema } from 'nexus'` ##### About -This convention is optional if entrypoint is present, required otherwise. +Nexus looks for modules that import `nexus` and uses codegen to statically import them before the server starts. -This is where you should write your GraphQL type definitions. +Beware if you have module-level side-effects coming from something else than Nexus, as these side-effects will always be run when your app starts. -In dev mode the modules are synchronously found and imported when the server starts. Conversely, at build time, codegen runs making the modules statically imported. This is done to support tree-shaking and decrease application start time. +> *Note: `require` is not supported. ### Entrypoint @@ -58,4 +56,4 @@ A custom entrypoint can also be configured using the `--entrypoint` or `-e` CLI ##### About -This convention is optional if schema modules are present, required otherwise. +This convention is optional if Nexus modules are present, required otherwise. diff --git a/src/cli/commands/create/app.ts b/src/cli/commands/create/app.ts index 07e0cd043..30fd953d6 100644 --- a/src/cli/commands/create/app.ts +++ b/src/cli/commands/create/app.ts @@ -385,7 +385,7 @@ const templates: Record = { return { files: [ { - path: path.join(internalConfig.sourceRoot, Layout.schema.CONVENTIONAL_SCHEMA_FILE_NAME), + path: path.join(internalConfig.sourceRoot, 'graphql.ts'), content: stripIndent` import { schema } from "nexus"; @@ -513,7 +513,7 @@ async function scaffoldBaseFiles(options: InternalConfig) { // use(prisma()) ` ), - fs.writeAsync(path.join(options.sourceRoot, Layout.schema.CONVENTIONAL_SCHEMA_FILE_NAME), ''), + fs.writeAsync(path.join(options.sourceRoot, 'graphql.ts'), ''), // An exhaustive .gitignore tailored for Node can be found here: // https://github.com/github/gitignore/blob/master/Node.gitignore // We intentionally stay minimal here, as we want the default ignore file diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 519012b5d..ce42d5fbd 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -37,6 +37,7 @@ export class Dev implements Command { } const entrypointPath = args['--entrypoint'] + const reflectionMode = args['--reflection'] === true let layout = await Layout.create({ entrypointPath }) const pluginReflectionResult = await Reflection.reflect(layout, { usedPlugins: true, onMainThread: true }) @@ -73,6 +74,14 @@ export class Dev implements Command { dev: { addToWatcherSettings: {}, async onBeforeWatcherStartOrRestart(change) { + if (change.type === 'change') { + const nexusModules = Layout.findNexusModules( + layout.tsConfig, + layout.app.exists ? layout.app.path : null + ) + layout.update({ nexusModules }) + } + if ( change.type === 'init' || change.type === 'add' || @@ -89,36 +98,16 @@ export class Dev implements Command { } runDebouncedReflection(layout) + + return { + entrypointScript: getTranspiledStartModule(layout, reflectionMode), + } }, }, } - const startModule = createStartModuleContent({ - registerTypeScript: { - ...layout.tsConfig.content.options, - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2015, - }, - internalStage: 'dev', - runtimePluginManifests: [], // tree-shaking not needed - layout, - absoluteModuleImports: true, - }) - - const transpiledStartModule = transpileModule(startModule, { - ...layout.tsConfig.content.options, - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2015, - }) - - /** - * We use an empty script when in reflection mode so that the user's app doesn't run. - * The watcher will keep running though and so will reflection in the devPlugin.onBeforeWatcherStartOrRestart hook above - */ - const entrypointScript = args['--reflection'] ? '' : transpiledStartModule - const watcher = await createWatcher({ - entrypointScript, + entrypointScript: getTranspiledStartModule(layout, reflectionMode), sourceRoot: layout.sourceRoot, cwd: process.cwd(), plugins: [devPlugin].concat(worktimePlugins.map((p) => p.hooks)), @@ -141,3 +130,31 @@ export class Dev implements Command { ` } } + +function getTranspiledStartModule(layout: Layout.Layout, reflectionMode: boolean) { + /** + * We use an empty script when in reflection mode so that the user's app doesn't run. + * The watcher will keep running though and so will reflection in the devPlugin.onBeforeWatcherStartOrRestart hook above + */ + if (reflectionMode === true) { + return '' + } + + const startModule = createStartModuleContent({ + registerTypeScript: { + ...layout.tsConfig.content.options, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2015, + }, + internalStage: 'dev', + runtimePluginManifests: [], // tree-shaking not needed + layout, + absoluteModuleImports: true, + }) + + return transpileModule(startModule, { + ...layout.tsConfig.content.options, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2015, + }) +} diff --git a/src/lib/layout/index.spec.ts b/src/lib/layout/index.spec.ts index e0c8ec8b3..f54cf0ba2 100644 --- a/src/lib/layout/index.spec.ts +++ b/src/lib/layout/index.spec.ts @@ -198,13 +198,12 @@ it('fails if no entrypoint and no graphql modules', async () => { await ctx.scan() - expect(stripAnsi(mockedStdoutBuffer)).toMatchInlineSnapshot(` - "■ nexus:layout We could not find any graphql modules or app entrypoint + expect(mockedStdoutBuffer).toMatchInlineSnapshot(` + "■ nexus:layout We could not find any modules that imports 'nexus' or app.ts entrypoint ■ nexus:layout Please do one of the following: - 1. Create a (graphql.ts file and write your GraphQL type definitions in it. - 2. Create a graphql directory and write your GraphQL type definitions inside files there. - 3. Create an app.ts file. + 1. Create a file, import { schema } from 'nexus' and write your GraphQL type definitions in it. + 2. Create an app.ts file. --- process.exit(1) --- @@ -214,20 +213,20 @@ it('fails if no entrypoint and no graphql modules', async () => { expect(mockExit).toHaveBeenCalledWith(1) }) -it('finds nested graphql modules', async () => { +it('finds nested nexus modules', async () => { ctx.setup({ ...fsTsConfig, src: { 'app.ts': '', graphql: { - '1.ts': '', - '2.ts': '', + '1.ts': `import { schema } from 'nexus'`, + '2.ts': `import { schema } from 'nexus'`, graphql: { - '3.ts': '', - '4.ts': '', + '3.ts': `import { schema } from 'nexus'`, + '4.ts': `import { schema } from 'nexus'`, graphql: { - '5.ts': '', - '6.ts': '', + '5.ts': `import { schema } from 'nexus'`, + '6.ts': `import { schema } from 'nexus'`, }, }, }, @@ -236,7 +235,7 @@ it('finds nested graphql modules', async () => { const result = await ctx.scan() - expect(result.schemaModules).toMatchInlineSnapshot(` + expect(result.nexusModules).toMatchInlineSnapshot(` Array [ "__DYNAMIC__/src/graphql/1.ts", "__DYNAMIC__/src/graphql/2.ts", @@ -324,39 +323,24 @@ it('fails if custom entrypoint is not a .ts file', async () => { `) }) -it('does not take custom entrypoint as schema module if its named graphql.ts', async () => { - await ctx.setup({ ...fsTsConfig, 'graphql.ts': '', graphql: { 'user.ts': '' } }) - const result = await ctx.scan({ entrypointPath: './graphql.ts' }) - expect({ - app: result.app, - schemaModules: result.schemaModules, - }).toMatchInlineSnapshot(` - Object { - "app": Object { - "exists": true, - "path": "__DYNAMIC__/graphql.ts", - }, - "schemaModules": Array [ - "__DYNAMIC__/graphql/user.ts", - ], - } - `) -}) - -it('does not take custom entrypoint as schema module if its inside a graphql/ folder', async () => { - await ctx.setup({ ...fsTsConfig, graphql: { 'user.ts': '', 'graphql.ts': '' } }) - const result = await ctx.scan({ entrypointPath: './graphql/graphql.ts' }) +it('does not take custom entrypoint as nexus module if contains a nexus import', async () => { + await ctx.setup({ + ...fsTsConfig, + 'app.ts': `import { schema } from 'nexus'`, + 'graphql.ts': `import { schema } from 'nexus'`, + }) + const result = await ctx.scan({ entrypointPath: './app.ts' }) expect({ app: result.app, - schemaModules: result.schemaModules, + nexusModules: result.nexusModules, }).toMatchInlineSnapshot(` Object { "app": Object { "exists": true, - "path": "__DYNAMIC__/graphql/graphql.ts", + "path": "__DYNAMIC__/app.ts", }, - "schemaModules": Array [ - "__DYNAMIC__/graphql/user.ts", + "nexusModules": Array [ + "__DYNAMIC__/graphql.ts", ], } `) diff --git a/src/lib/layout/index.ts b/src/lib/layout/index.ts index 1a2535d94..36545de52 100644 --- a/src/lib/layout/index.ts +++ b/src/lib/layout/index.ts @@ -9,16 +9,14 @@ export { loadDataFromParentProcess, saveDataForChildProcess, scanProjectType, + findNexusModules } from './layout' // todo refactor with TS 3.8 namespace re-export // once https://github.com/prettier/prettier/issues/7263 -import { CONVENTIONAL_SCHEMA_FILE_NAME, DIR_NAME, emptyExceptionMessage, MODULE_NAME } from './schema-modules' +import { emptyExceptionMessage } from './schema-modules' -export const schema = { +export const schemaModules = { emptyExceptionMessage, - DIR_NAME, - MODULE_NAME, - CONVENTIONAL_SCHEMA_FILE_NAME, } diff --git a/src/lib/layout/layout.ts b/src/lib/layout/layout.ts index f50ce173b..0b059267f 100644 --- a/src/lib/layout/layout.ts +++ b/src/lib/layout/layout.ts @@ -9,8 +9,8 @@ import { START_MODULE_NAME } from '../../runtime/start/start-module' import { rootLogger } from '../nexus-logger' import * as PackageManager from '../package-manager' import { fatal } from '../process' -import * as Schema from './schema-modules' import { readOrScaffoldTsconfig } from './tsconfig' +import * as ts from 'ts-morph' export const DEFAULT_BUILD_FOLDER_PATH_RELATIVE_TO_PROJECT_ROOT = '.nexus/build' /** @@ -47,7 +47,7 @@ export type ScanResult = { } sourceRoot: string projectRoot: string - schemaModules: string[] + nexusModules: string[] tsConfig: { content: ParsedCommandLine path: string @@ -102,9 +102,14 @@ export type Layout = Data & { projectPath(...subPaths: string[]): string sourceRelative(filePath: string): string sourcePath(...subPaths: string[]): string + update(options: UpdateableLayoutData): void packageManager: PackageManager.PackageManager } +interface UpdateableLayoutData { + nexusModules?: string[] +} + interface Options { /** * The place to output the build, relative to project root. @@ -162,7 +167,7 @@ export async function create(options?: Options): Promise { * data from another process that would be wasteful to re-calculate. */ export function createFromData(layoutData: Data): Layout { - return { + let layout: Layout = { ...layoutData, data: layoutData, projectRelative: Path.relative.bind(null, layoutData.projectRoot), @@ -176,7 +181,15 @@ export function createFromData(layoutData: Data): Layout { packageManager: PackageManager.createPackageManager(layoutData.packageManagerType, { projectRoot: layoutData.projectRoot, }), + update(options) { + if (options.nexusModules) { + layout.nexusModules = options.nexusModules + layout.data.nexusModules = options.nexusModules + } + }, } + + return layout } /** @@ -189,15 +202,10 @@ export async function scan(opts?: { cwd?: string; entrypointPath?: string }): Pr const packageManagerType = await PackageManager.detectProjectPackageManager({ projectRoot }) const maybePackageJson = findPackageJson({ projectRoot }) const maybeAppModule = opts?.entrypointPath ?? findAppModule({ projectRoot }) - let maybeSchemaModules = Schema.findDirOrModules({ projectRoot }) const tsConfig = await readOrScaffoldTsconfig({ projectRoot, }) - - // Remove entrypoint from schema modules (can happen if the entrypoint is named graphql.ts or inside a graphql/ folder) - if (maybeSchemaModules && maybeAppModule && maybeSchemaModules.includes(maybeAppModule)) { - maybeSchemaModules = maybeSchemaModules.filter((s) => s !== maybeAppModule) - } + const nexusModules = findNexusModules(tsConfig, maybeAppModule) const result: ScanResult = { app: @@ -206,7 +214,7 @@ export async function scan(opts?: { cwd?: string; entrypointPath?: string }): Pr : ({ exists: true, path: maybeAppModule } as const), projectRoot, sourceRoot: tsConfig.content.options.rootDir!, - schemaModules: maybeSchemaModules, + nexusModules, project: readProjectInfo(opts), tsConfig, packageManagerType, @@ -215,7 +223,7 @@ export async function scan(opts?: { cwd?: string; entrypointPath?: string }): Pr log.trace('completed scan', { result }) - if (result.app.exists === false && result.schemaModules.length === 0) { + if (result.app.exists === false && result.nexusModules.length === 0) { log.error(checks.no_app_or_schema_modules.explanations.problem) log.error(checks.no_app_or_schema_modules.explanations.solution) process.exit(1) @@ -233,13 +241,12 @@ const checks = { code: 'no_app_or_schema_modules', // prettier-ignore explanations: { - problem: `We could not find any ${Schema.MODULE_NAME} modules or app entrypoint`, + problem: `We could not find any modules that imports 'nexus' or ${CONVENTIONAL_ENTRYPOINT_FILE_NAME} entrypoint`, solution: stripIndent` Please do one of the following: - 1. Create a (${Chalk.yellow(Schema.CONVENTIONAL_SCHEMA_FILE_NAME)} file and write your GraphQL type definitions in it. - 2. Create a ${Chalk.yellow(Schema.DIR_NAME)} directory and write your GraphQL type definitions inside files there. - 3. Create an ${Chalk.yellow(CONVENTIONAL_ENTRYPOINT_FILE_NAME)} file. + 1. Create a file, import { schema } from 'nexus' and write your GraphQL type definitions in it. + 2. Create an ${Chalk.yellow(CONVENTIONAL_ENTRYPOINT_FILE_NAME)} file. `, } }, @@ -425,6 +432,36 @@ function getBuildOutput(buildOutput: string | undefined, scanResult: ScanResult) return Path.join(scanResult.projectRoot, output) } +export function findNexusModules(tsConfig: ScanResult['tsConfig'], maybeAppModule: string | null): string[] { + try { + log.trace('finding nexus modules') + const project = new ts.Project({ + addFilesFromTsConfig: false, // Prevent ts-morph from re-parsing the tsconfig + }) + + tsConfig.content.fileNames.forEach((f) => project.addSourceFileAtPath(f)) + + const modules = project + .getSourceFiles() + .filter((s) => { + // Do not add app module to nexus modules + if (s.getFilePath().toString() === maybeAppModule) { + return false + } + + return s.getImportDeclaration('nexus') !== undefined + }) + .map((s) => s.getFilePath().toString()) + + log.trace('done finding nexus modules', { modules }) + + return modules + } catch (error) { + log.error('We could not find your nexus modules', { error }) + return [] + } +} + function getBuildInfo( buildOutput: string | undefined, scanResult: ScanResult, diff --git a/src/lib/layout/schema-modules.ts b/src/lib/layout/schema-modules.ts index 494906131..58b0f7024 100644 --- a/src/lib/layout/schema-modules.ts +++ b/src/lib/layout/schema-modules.ts @@ -1,33 +1,7 @@ -import Chalk from 'chalk' -import * as fs from 'fs-jetpack' -import { baseIgnores } from '../../lib/fs' - -export const MODULE_NAME = 'graphql' -export const CONVENTIONAL_SCHEMA_FILE_NAME = MODULE_NAME + '.ts' -export const DIR_NAME = 'graphql' - export function emptyExceptionMessage() { // todo when the file is present but empty this error message is shown just // the same. That is poor user feedback because the instructions are wrong in // that case. The instructions in that case should be something like "you have // schema files setup correctly but they are empty" - return `Your GraphQL schema is empty. This is normal if you have not defined any GraphQL types yet. But if you did, check that the file name follows the convention: all ${Chalk.yellow( - CONVENTIONAL_SCHEMA_FILE_NAME - )} modules or direct child modules within a ${Chalk.yellow(DIR_NAME)} directory are automatically imported.` -} - -/** - * Find all modules called schema modules or directories having the trigger - * name. This does not grab the child modules of the directory instances! - */ -export function findDirOrModules(opts: { projectRoot: string }): string[] { - const localFS = fs.cwd(opts.projectRoot) - // TODO async - const files = localFS.find({ - files: true, - recursive: true, - matching: [CONVENTIONAL_SCHEMA_FILE_NAME, `**/${MODULE_NAME}/**/*.ts`, ...baseIgnores], - }) - - return files.map((f) => localFS.path(f)) + return `Your GraphQL schema is empty. This is normal if you have not defined any GraphQL types yet. If you did however, check that your files are contained in the same directory specified in the \`rootDir\` property of your tsconfig.json file.` } diff --git a/src/lib/tsc.ts b/src/lib/tsc.ts index d713ae6b4..21a47c380 100644 --- a/src/lib/tsc.ts +++ b/src/lib/tsc.ts @@ -29,7 +29,7 @@ export function createTSProgram( log.trace('Create TypeScript program') const builder = ts.createIncrementalProgram({ - rootNames: layout.schemaModules.concat(layout.app.exists ? [layout.app.path] : []), + rootNames: layout.nexusModules.concat(layout.app.exists ? [layout.app.path] : []), options: { ...compilerCacheOptions, ...layout.tsConfig.content.options, diff --git a/src/lib/watcher/link.ts b/src/lib/watcher/link.ts index 154b2f7b2..4c426afcf 100644 --- a/src/lib/watcher/link.ts +++ b/src/lib/watcher/link.ts @@ -7,6 +7,7 @@ const log = rootLogger.child('dev').child('link') interface ChangeableOptions { environmentAdditions?: Record + entrypointScript?: string } interface Options extends ChangeableOptions { diff --git a/src/lib/watcher/watcher.ts b/src/lib/watcher/watcher.ts index 32d9c52fd..b6df57313 100644 --- a/src/lib/watcher/watcher.ts +++ b/src/lib/watcher/watcher.ts @@ -27,6 +27,7 @@ export type PostInitChangeEvent = export interface RunnerOptions { environmentAdditions?: Record + entrypointScript?: string } export interface Watcher { diff --git a/src/runtime/start/start-module.ts b/src/runtime/start/start-module.ts index 65b18f207..a30651758 100644 --- a/src/runtime/start/start-module.ts +++ b/src/runtime/start/start-module.ts @@ -161,7 +161,7 @@ export function prepareStartModule( * in the source/build root. */ export function printStaticImports(layout: Layout.Layout, opts?: { absolutePaths?: boolean }): string { - return layout.schemaModules.reduce((script, modulePath) => { + return layout.nexusModules.reduce((script, modulePath) => { const path = opts?.absolutePaths ? stripExt(modulePath) : relativeTranspiledImportPath(layout, modulePath) return `${script}\n${printSideEffectsImport(path)}` }, '') diff --git a/test/__helpers/e2e/kitchen-sink.ts b/test/__helpers/e2e/kitchen-sink.ts index f2a391b2d..50f85d119 100644 --- a/test/__helpers/e2e/kitchen-sink.ts +++ b/test/__helpers/e2e/kitchen-sink.ts @@ -3,7 +3,6 @@ import { ConnectableObservable, Subscription } from 'rxjs' import { refCount } from 'rxjs/operators' import { createE2EContext, E2EContext } from '../../../src/lib/e2e-testing' import { DEFAULT_BUILD_FOLDER_PATH_RELATIVE_TO_PROJECT_ROOT } from '../../../src/lib/layout' -import { CONVENTIONAL_SCHEMA_FILE_NAME } from '../../../src/lib/layout/schema-modules' import { rootLogger } from '../../../src/lib/nexus-logger' import { bufferOutput, takeUntilServerListening } from './utils' @@ -41,7 +40,7 @@ export async function e2eKitchenSink(app: E2EContext) { // Cover addToContext feature await app.fs.writeAsync( - `./api/add-to-context/${CONVENTIONAL_SCHEMA_FILE_NAME}`, + `./api/add-to-context/graphql.ts`, ` import { schema } from 'nexus' @@ -68,7 +67,7 @@ export async function e2eKitchenSink(app: E2EContext) { // Cover backing-types feature await app.fs.writeAsync( - `./api/backing-types/${CONVENTIONAL_SCHEMA_FILE_NAME}`, + `./api/backing-types/graphql.ts`, ` import { schema } from 'nexus'