Open jasonkuhrt opened 4 years ago
About the relationship between @nexus/schema
and plugins.
@nexus/schema
@nexus/schema
would be an application (aka. project) dep (along with plugin itself)nexus-prisma
@nexus/schema
will now NOT be depended upon by the applicationnexus-prisma
as the example...)Example of it causing problems is with system tests:
Here are some options
Generally, simply: Make Nexus framework plugins supply all the peer dependencies that their deps expect.
@nexus/schema
not being hoisted. Here is the next build for that PR where @nexus/schema
has been added to the deps of of the framework plugin: https://github.com/graphql-nexus/nexus-future/runs/537610244#step:7:1282. It no longer fails on that.Ramifications:
graphql
... ?Give schema plugins what they will need via args, so that schema plugins do not have to import their deps.
Ramifications:
Plugin needs to rely on type imports to get autocomplete etc. (@nexus/schema
becomes a devDep)
import { Plugin } from '@nexus/schema'
const plugin: Plugin = () => {}
export default plugin
@tgriesser Any thoughts you have about the above comment are welcome!
Took a deep dive with @Weakky about https://github.com/graphql-nexus/nexus-future/issues/514#issuecomment-604668904, and got aligned;
This is what we think we should do.
MODULDE_NOT_FOUND
error for graphql
or @nexus/schema
caused by hoisting not "kicking in"{
"dependencies": {
"@nexus/schema": "...",
"graphql": "...",
"nexus-schema-plugin-foo": "..."
}
}
import { makeSchema } from '@nexus/schema'
import nexusSchemaPluginFoo from 'nexus-schema-plugin-foo'
const schema = makeSchema({
plugins: [
nexusSchemaPluginFoo()
],
// ...
})
// ...
{
"name": "nexus-schema-plugin-foo",
"devDependencies": {
"@nexus/schema": "...",
}
}
import type { Plugin } from '@nexus/schema'
interface Options {
// ...
}
const plugin: Plugin = (options: Options) => ({ lens, graphql, nexusSchema }) => {
// ...
};
export { Options }
export default plugin
{
"dependencies": {
"nexus": "...",
"nexus-plugin-foo": "..."
}
}
{
"name": "nexus-plugin-foo",
"dependencies": {
"nexus-schema-plugin-foo": "...",
},
"devDependencies": {
"nexus": "...",
}
}
import type { Plugin } from 'nexus/plugins'
interface Options {
// ...
}
const plugin: Plugin = (options: Options) => ({ lens, graphql, nexusSchema }) => {
// ...
};
export { Options }
export default plugin
graphql
& @nexus/schema
...
{
"dependencies": {
"nexus": "...",
"@nexus/schema": "...",
"graphql": "...",
"nexus-plugin-foo": "..."
}
}
{
"name": "nexus-plugin-foo",
"dependencies": {
"nexus-schema-plugin-foo": "...",
},
"devDependencies": {
"nexus": "...",
}
}
import { createPlugin } from 'nexus/plugins'
import graphql from 'graphql'
import nexusSchema from '@nexus/schema'
export interface Options {
// ...
}
export default createPlugin((options: Options) => ({ lens }) => {
// ...
})
graphql
& @nexus/schema
graphql
can block user
Separate topic from previous comment but in that same deep dive with @Weakky.
Design around how the framework plugins can express their dep on the framework.
The following is not exact API but the gist of what we want to do.
Framework plugins will use their devDep pin on the framework to supply at runtime their dep. fwiw this is akin to how the Fastify plugin system works. This is needed in lieu of not pinning in the dep graph https://github.com/graphql-nexus/nexus-future/issues/514#issuecomment-605105455 but also because it permits to provide a lot better feedback anyways for the user about the incompatibility––compared to if we relied solely on the package manager feedback (e.g. peer dep warnings are largely ignored by users etc.).
import package from "../package.json";
// ...
// devDependencies: {
// "nexus": "^1.2.3",
// ...
// }
// ...
const plugin = () => {
// ...
return {
supports: package.devDependencies["@nexus/schema"]
};
};
If this system works out well, we'll go further and automate this so plugin authors 1) cannot screw it up 2) don't have to do it, think about it.
Notes from call today with @Weakky
The heart of the plugin interface could be a manifest that provides some basic metadata and location information about where modules can be found for each respective dimension.
The reason for the module paths is that the runtime dimension of a plugin must be kept in separate from the others to facilitate tree shaking production builds.
type Plugin = <Settings>(settings: Settings) => {
name: string
frameworkVersion: string, // valid npm version expression
settings: Settings
settingsType?: {
module: string // a path to TS module
export: string // a name of an export in the TS module for the Settings type
},
runtime?: {
module: string // a path to a compiled JS module
export: string // a name of an export in the JS module for the plugin dimension
},
worktime?: {
module: string // ^
export: string // ^
},
testtime?: {
module: string // ^
export: string // ^
},
}
The signatures of the various dimensions are thus:
ThisPluginSettings => RuntimeLens => RuntimeContributions
ThisPluginSettings => WorktimeLens => WorktimeContributions
ThisPluginSettings => TesttimeLens => TesttimeContributions
Note that the dimensions share a common settings data. A plugin feature might require collaboration between runtime and worktime, or testtime and worktime, etc. From a plugin consumer perspective, its one feature, they don't want to manage plugin settings across different dimensions. We think this is a hard usability requirement. The consequence of this is that the type of ThisPluginSettings
is not owned by any one dimension. Consequently it needs its own peer module. A plugin author will import the settings type into their respective dimension modules for type safety. The framework will look at this module to provide typesafety/autocomplete for users of the plugin when they configure it.
The settings must be returned to the framework. This may seem odd. The reason is that the framework takes responsibility for feeding settings to plugins. The reason for that is settings, better known as "configuration management" can get complex in real-world deployment scenarios; different stages of deployment will require different settings, and getting those settings actually set will have different ideal mechanisms, such as config files or environment variables. We want the framework to support a configuration cascade where users can count on e.g. being able to set via an env var any plugin option in a consistent and safely parsed way. There should be zero effort to "rig" this up by users and zero effort for plugin authors too. By everyone just following a few minimal conventions, things will Just Work (tm).
Another reason we need settings returned is that at worktime and testtime we need to read the settings in from the user's source code. Having plugins return the settings is our only way to do that, assuming we want settings to be entered directly into plugin functions (e.g. we reject this: use(prisma, {...})
).
There is very little value for most plugin authors to manage the above manifest manually. Instead the Nexus CLI should have a build step that provides the following benefits:
The conventional layout could be:
plugin/
settings.ts
runtime.ts
worktime.ts
testtime.ts
The conventional export name would be plugin
and for settings type Settings
If the plugin author insists on using another layout for some reason, we could support that via config in the package json, e.g.:
nexusPlugin: {
layout: {
runtimeModule: {
module: "..."
}
}
}
Note that this starts to look like the API all over again but the important difference is that it is for generating API boilerplate. And the important thing about having an API is that we don't lock out other ideas in the future or other tooling ideas from other users, etc. API has benefits of being an... API!
Using and configuring a plugin programatically at its base could look like this:
import { use } from 'nexus'
import { prisma } from 'nexus-plugin-prisma'
use(prisma({
// ...
}))
The framework could support auto-discovery of plugins that follow conventions. It would allow the above to be refactored to this:
import { use } from 'nexus'
use.prisma({
// ...
})
The cool thing about this is that a user can type use.<tab>
to get autocomplete for all the plugins they have installed.
Furthermore, we could enhance the autocomplete to have a list of all published plugins. Upon selecting one it would be auto-installed into their project. Codelens could sit above this lines of code to offer install/uninstall option too. This would bring plugin discoverability, consumption, and usage to an absurdly convenient level.
use.<tab> -- ✔ prisma
⬇ script-hooks
⬇ react-admin
⬇ query-complexity
⬇ knex
⬇ now
⬇ netlify
If centralized configuration is important for a user, they could enable centralize mode, allowing the above to be refactored like so:
import { settings } from 'nexus'
settings.change({
framework: {
centralizedPluginSettings: true
},
plugins: {
prisma: {
enabled: true
// ...
}
}
})
If the user is bothered by having to manually use every plugin they install, they could enable auto-use mode, which would make plugins opt-out instead of opt-in. In a proper dev mode ui (terminal, web, desktop, whatever) they would get all the feedback they need about what is going on, and more.
Users will be able to use a configuration cascade to flexible control their app's settings, including all the plugins it uses. Example (rough, sketch):
NEXUS_CONFIG='{ "plugins": { "prisma": { "foo": true }}}' node node_modules/.build
Being able to write a plugin inline inside an app is important. The constraints are different than with packages plugins:
First of all, a user can of course just do this:
import { use } from 'nexus'
use({
name: "...",
frameworkVersion: "..."
// ...
})
But this is pretty verbose and the paths part quickly turns this into a nightmare that is too taxing for the quick "I'm curious" moments.
A simpler API should be made availble:
import { inlinePlugin } from 'nexus/plugins'
use(inlinePlugin({
runtime(lens) { /*...*/ },
worktimetime(lens) { /*...*/ },
testtime(lens) { /*...*/ },
}))
This pattern must never ever be used by packaged plugins. The framework stance should be that measures, at any time, will be taken to actively block packaged plugins from using this pattern. There are various static and runtime tricks we could deploy to enforce it. Ideally though the benefits of following the conventions and general ecosystem cohesion will be good enough to make this a non-issue. None the less it should be clear that any packaged plugin trying to export itself using inline
can expect to be broken by the framework at some point.
Sometimes a user might want to go a bit futher with their plugin, building what is effectively a packaged plugin yet also still local to their app.
The farmework can support this via a special convention:
plugins/
<name>/
settings.ts
runtime.ts
testtime.ts
worktime.ts
With this convention then, imagine this setup and example:
api/
app.ts
plugins/
foo/
settings.ts
runtime.ts
//app.ts
import { use } from 'nexus'
import { localPlugin } from 'nexus/plugins'
use(localPlugin('foo', {
// ..
}))
Maybe we could use proxies instead:
//app.ts
import { use } from 'nexus'
import { localPlugin } from 'nexus/plugins'
use(localPlugin.foo({
// ..
}))
But actually, it seems the auto-discovery feature would be almost the same then, and already exists. Name collisions are possible (between their local and packaged plugins) but the user can always change the name of their local easily if needed.
//app.ts
import { use } from 'nexus'
use.foo({
// ..
})
components that are pluggable could accept plugins over a use
API
import { schema, log, server } from 'nexus'
import { superCoolScalar } from 'qux'
import { nyanCatTheme } from 'foo'
import { cors } from 'bar'
schema.use(superCoolScalar({
// ...
}))
log.use(nyanCatTheme({
// ...
}))
server.use(cors({
// ...
}))
Component plugins lose the following benefits over framework plugins:
Because the plugin entrypoint is a set of paths using a custom/dynamic loader system, it means tree-shaking will not work by default. Specifically, it will think that the only thing a used by the app is the plugin manifest.
// user's app.ts
import { prisma } from 'nexus-plugin-prisma'
import { use } from 'nexus'
use(prisma())
What tree shaking would think it needs to bring in (roughly):
// node_modules/nexus-plugin-prisma/dist/index.js
export const prisma = {
name: '...',
// ...
}
To solve this the nexus start module could do this:
import 'nexus-plugin-prisma/dist/runtime'
This information will come from plugin.runtime.module
https://github.com/graphql-nexus/nexus-plugin-prisma/runs/571935272
This error occurred b/c of mismatch of nexus versions between plugin and framework.
I still don't understand the actual issue (config protected class blah blah) I think it has to do with the mix of nominally different classes, "same class" but different packages.
We're lucky we can fix this as we own the framework and plugin, but this is a high bar to climb from a plugin ecosystem perspective.
Noticed in #633 that another Nexus Schema plugin api part aside from plugin wrapper is NexusSchema.plugin.completeValue
. As stated in https://github.com/graphql-nexus/nexus/issues/514#issuecomment-605105455 we need to get away from static Nexus Schema term level access in Nexus Schema plugins.
Things are going well, plugin system continues to progress albeit in different corners of the project.
For example this sprint we tackled tree-shaking #119 and had to adapt it to support our plugin system as well.
For reference: The rollup plugin ecosystem has some similarities with ours. They asked/ask for prefix of rollup-plugin
but then also adopted @rollup/plugin-
for first-party supported ones. They ask for default imports so that plugins are usable on the rollup CLI.
We have also thought about @nexus/plugin-*
for first party Nexus plugins.
We also added implementing manifest defaults to this sprint https://github.com/graphql-nexus/nexus/issues/858.
This issue is a WIP. The content here will be refined over time.
We've had feedback in the Nexus transition issue (1, 2), and committed to, keeping schema component usable on its own, and schema component plugins usable on their own, too.
This issue is a place to at least start design/discussion of the system we'll need to realize the commitment. Centralizing all the considerations into one place will help us design the system. It is very complex and tradeoffs are hard to analyze. It is easy to over-optimize a side at the expense of others, without noticing until it is too late, maybe a lot of work done that could have been avoided.
Capabilities
WIP - incomplete, sometimes ambiguous
makeSchema
of@nexus/schema
Questions
Social
Versioning
Worst: runtime crash and burn only under a set of production circumstances; Best: clear up front feedback while in dev mode?
Schema Component vs Framework
Integration
It should be practically impossible to have silently incompatible plugins. This means a case where framework user installs two plugins and their app stops working because of an integration problem caused by the combination of the two plugins. How?
We want the api to be built on top of the same internals that plugins are. This means anything the api let's users do is something that plugins can do, automate. That means a strong separation needs to exist in the API between escape hatches and official API surface that plugins rely on. This means for example that if a plugin
A
does (effectively, psudeo code):and plugin
B
does (effectively, psudeo code):And
req.foobar()
is broken bycreateSomeCustomServer
, then, bad.The features in the non-escape-hatch API need to be guaranteed compatible. Any features in
schema.addToContext
that rely on server need to be abstracted from the particulars of the actual server, andcreateSomeCustomServer
must be required to fulfill all the framework features that are touching the server.This is one example but the principal is far reaching, general.
Any plugin can augment the CLI. This means any CLI Invocation begins by loading the plugins that tap into it. How will we do that?
Plugins will be configurable by users. This means:
User could configure cli plugins in dedicate config file
We could have the singular system, configuration in the app
Should users have to enable the plugins explicitly after installing them?
Should plugins be imported or appear configurable via typegen?
If we go the auto-use way, how does that square with schema level plugins?
nexus-schema-plugin-*
?Do we add a new API like:
If we had such an API, would one not expect too then (this is compatible with the above framework
use
above, for framework):