Skip to content

server middleware #523

@jasonkuhrt

Description

@jasonkuhrt

Perceived Problem

  • users resort to server.custom in every app to add express middleware
  • server.custom should be a rare escape hatch
  • server.custom widespread use would make Nexus framework de-facto coupled to express
  • plugins need to be able to add middleware, but they cannot
  • no middleware that spans serverless and serverful

Idea

  • works like a FILO (first in last out) stack

  • beforeware can short-circuit, aftwares of beforewares that ran will run

  • await next to transition seemlessly from beforeware to afterware

  • return to short-citcuit

    • in afterware has no effect, Nexus will log warning
  • third parameter is ctx, intentionally mimicks position in resolver sig

  • mutations to ctx are visible in resolvers, super simple

    • no more schema.addToContext
  • plugins can extend req object for use at app level [1] via typegen [2]

  • plugins can extend res object for use at app level [1] via typegen [2]

  • plugins can extend ctx object [3]

  • everything you see here will work as well for a serverless deployment (lambda) as it will a serverful one (ec2)

  • req/res/ctx augmentation by plugins will be visible in app middleware types

  • app level augmentation of req/res/ctx in middleware will not be visible in later middleware since the type of middleware can be altered based on syntactic order.

    server.middleware((req, res, ctx) => { ctx.foo = 1 })
    server.middleware((req, res, ctx) => { ctx.foo }) // type check error, but actually correct
  • app level augmentation of ctx in middleware will be visible in resolver sigs

  • does not handle errors. Errors are fatal and handled by the framework. Approach akin to fastify

[1] "for use at app level" because we don't want plugins to start depending on features from other plugins, or do we?

[2] We cannot use global interface merging b/c we don't know if the plugin will be enabled or disabled. We resort to typegen.

[3] They already can, but ctx is only for resolvers right now. This point is saying that plugins will be able to modify context for middleware too.

Ideas from other middleware systems we align with

koa:

however a key design decision was made to provide high level "sugar" at the otherwise low-level middleware layer. This improves interoperability, robustness, and makes writing middleware much more enjoyable.

fastify

Uncaught errors are likely to cause memory leaks, file descriptor leaks and other major production issues. Domains were introduced to try fixing this issue, but they did not. Given the fact that it is not possible to process all uncaught errors sensibly, the best way to deal with them at the moment is to crash. In case of promises, make sure to handle errors correctly.

Fastify follows an all-or-nothing approach and aims to be lean and optimal as much as possible. Thus, the developer is responsible for making sure that the errors are handled properly. Most of the errors are usually a result of unexpected input data, so we recommend specifying a JSON.schema validation for your input data.

Note that Fastify doesn't catch uncaught errors within callback-based routes for you, so any uncaught errors will result in a crash. If routes are declared as async though - the error will safely be caught by the promise and routed to the default error handler of Fastify for a generic Internal Server Error response. For customizing this behaviour, you should use setErrorHandler.

Examples

Beforeware

server.middleware((req, res, ctx) => {
  const token = extractToken(req)
  ctx.user = {
    id: token.claims.userId
  }
})

Beforeware & Afterware

server.middleware(async (req, res, ctx, next) => {
  const start = Date.now()
  await next
  const end = Date.now()
  res.headers.set('x-response-time', end - start)
})

Beforeware short circuit

server.middleware((req, res, ctx) => {
  const token = extractToken(req)

  if (!token) {
    res.status = 401
	res.body = '...'
	return res
  }

  ctx.user = {
    id: token.claims.userId
  }
})

plugin augmnet req

module {
  interface NexusRequest {
    /* extend type here */
    foo: string
  }
}


export default NexusPlugin.create(project => {
  project.runtime((augment) => {
    augment.request({
      type: `
        foo?: string
      `,
      term(req) {
        if (math.random() > 0.5) {
          req.foo = 'bar'
        }
      }
    })
    augment.response({
      type: `
        foo?: string
      `,
      term(res) {
        if (math.random() > 0.5) {
          res.foo = 'bar'
        }
      }
    })
  })
Lifecycle

imagine three middlewares

normal

before:   middleware 1
before:   middleware 2
before:   middleware 3
graphql handler
after:    middleware 3
after:    middleware 2
after:    middleware 1

short-circuit ex 1

before:   middleware 1
before:   middleware 2 RETURN
after:    middleware 1

Notes

Alt: Dedicated before/afterware functions
  • pro: can statically type that Res is only for beforeware
  • con:
server.before((req, res, ctx) => {})
server.after((req, res, ctx) => {})
type Before = (req:Req, res:Res, ctx:Ctx) => Res
type After = (req:Req, res:Res, ctx:Ctx) => void
Plugin-level type extraction

We will run type-extraction at the app level. Why not the plugin level too then?

// before
export default NexusPlugin.create(project => {
  project.runtime((augment) => {
    augment.request({
      type: `
        foo?: string
      `,
      term(req) {
        if (math.random() > 0.5) {
          req.foo = 'bar'
        }
      }
    })
  })

// after
export default NexusPlugin.create(project => {
  project.runtime((hooks) => {
    hooks.onRequest(req => {
      if (math.random() > 0.5) {
        req.foo = 'bar'
      }
    })
  })

Why not use an existing system

  1. Most are not optimized for TypeScript (exception: https://github.com/gcanti/hyper-ts)
  2. None are not optimized for TypeGen
  3. None have the concept of context that spans server middleware + resolvers
  4. None have the concept of being deployed to serverful or serverless environments
  5. None have the higher-order concept of how to handle being within a plugin, which brings an ordering challenge.

Links

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions