Skip to content

Instantly share code, notes, and snippets.

@richardscarrott
Last active July 31, 2022 12:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save richardscarrott/cf314c28580e31099e3484ba6f950f2b to your computer and use it in GitHub Desktop.
Save richardscarrott/cf314c28580e31099e3484ba6f950f2b to your computer and use it in GitHub Desktop.
Simple middleware stack for next.js getServerSideProps (and eventually Api Routes)

Next.js use

Simple middleware stack for next.js getServerSideProps (and eventually Api Routes)

import { use, wrap, Middleware } from './use';

interface WithUserVars {
   users?: User;
}

const withUser: Middleware<WithUserVars> = async (ctx, vars, next) => {
  if (vars.user) {
    return next();
  }
  // Add properties to `vars` to expose to subsequent middleware (avoid mutating ctx)
  vars.user = await getUser(ctx.session.userId);
  // Continue to next middleware in stack
  return next();
}

const role = (role: string): Middleware =>  use(withUser, (ctx, vars, next) => {
  if (!vars.user) {
    // Short-circuit
    return { redirect: { destination: '/login', permanent: false } };
  }
  if (!vars.user.roles.includes(role)) {
    return { notFound: true };
  }
  return next();
});

const perf: Middleware = async (ctx, vars, next) => {
   const label = `getServerSideProps:${ctx.req.url}:${Date.now()}`;
   console.time(label);
   const result = await next();
   console.timeEnd(label);
   return result;
}

const stack = use(perf, role('ADMIN'), withUser, (ctx, vars) => ({
  props: { username: vars.user.username }
});

export const getServerSideProps = wrap(stack);
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
export type Ctx = GetServerSidePropsContext;
export type Result = GetServerSidePropsResult<any>;
export type NextFn = () => Result | Promise<Result>;
export type Middleware<Vars extends {} = {}> = (
ctx: Ctx,
vars: Partial<Vars>,
next: NextFn
) => Promise<Result> | Result;
// Like `Middleware` but allows call site to decide which vars have already been applied
type AppliedMiddleware<Vars extends {}> = (
ctx: Ctx,
vars: Vars,
next: NextFn
) => Promise<Result> | Result;
interface Use {
<Fn1Vars>(fn1: Middleware<Fn1Vars>): Middleware<Partial<Fn1Vars>>;
<Fn1Vars, Fn2Vars>(
fn1: Middleware<Fn1Vars>,
fn2: AppliedMiddleware<Fn1Vars & Partial<Fn2Vars>>
): Middleware<Fn1Vars & Fn2Vars>;
<Fn1Vars, Fn2Vars, Fn3Vars>(
fn1: Middleware<Fn1Vars>,
fn2: AppliedMiddleware<Fn1Vars & Partial<Fn2Vars>>,
fn3: AppliedMiddleware<Fn1Vars & Fn2Vars & Partial<Fn3Vars>>
): Middleware<Fn1Vars & Fn2Vars & Fn3Vars>;
<Fn1Vars, Fn2Vars, Fn3Vars, Fn4Vars>(
fn1: Middleware<Fn1Vars>,
fn2: AppliedMiddleware<Fn1Vars & Partial<Fn2Vars>>,
fn3: AppliedMiddleware<Fn1Vars & Fn2Vars & Partial<Fn3Vars>>,
fn4: AppliedMiddleware<Fn1Vars & Fn2Vars & Fn3Vars & Partial<Fn4Vars>>
): Middleware<Fn1Vars & Fn2Vars & Fn3Vars & Fn4Vars>;
<Fn1Vars, Fn2Vars, Fn3Vars, Fn4Vars, Fn5Vars>(
fn1: Middleware<Fn1Vars>,
fn2: AppliedMiddleware<Fn1Vars & Partial<Fn2Vars>>,
fn3: AppliedMiddleware<Fn1Vars & Fn2Vars & Partial<Fn3Vars>>,
fn4: AppliedMiddleware<Fn1Vars & Fn2Vars & Fn3Vars & Partial<Fn4Vars>>,
fn5: AppliedMiddleware<
Fn1Vars & Fn2Vars & Fn3Vars & Fn4Vars & Partial<Fn5Vars>
>
): Middleware<Fn1Vars & Fn2Vars & Fn3Vars & Fn4Vars & Fn5Vars>;
}
export const use: Use =
(...middleware: Middleware<any>[]) =>
async (ctx: Ctx, vars: any, next: NextFn) => {
let idx = 0;
const _next = () => {
const fn = middleware[idx++];
if (!fn) {
return next();
}
return fn(ctx, vars, _next);
};
return _next();
};
export const wrap =
(middleware: Middleware<any>) =>
async (ctx: Ctx): Promise<Result> => {
const result = await middleware(ctx, {}, () => {
// TODO: Can this be caught with TS; e.g. FinalMiddleware?
throw new Error("Final middleware cannot call next()");
});
if (!result) {
// TODO: Can this be caught with TS; e.g. FinalMiddleware?
throw new Error("Final middleware must return a `Result`");
}
return result;
};
import { use, wrap, Ctx, Middleware } from "./use";
const call = (middleware: Middleware) =>
wrap(middleware)({
query: {},
req: {},
res: {},
} as Ctx);
test("calls middleware in order", async () => {
const middleware = use(
(ctx, vars: any, next) => {
vars.foo = true;
return next();
},
(ctx, vars: any) => {
return { props: { foo: vars.foo } };
}
);
expect(await call(middleware)).toEqual({
props: { foo: true },
});
});
test("middleware stack can be short-circuited", async () => {
const middleware = use(
(ctx, vars: any, next) => {
return { props: { short: "circuit" } };
},
(ctx, vars: any) => {
expect("this not").toBe("called");
return { props: { foo: vars.foo } };
}
);
expect(await call(middleware)).toEqual({
props: { short: "circuit" },
});
});
test("middleware chains behave like a stack", async () => {
expect.assertions(4);
let i = 0;
const middleware = use(
async (ctx, vars: any, next) => {
vars.foo = true;
expect(++i).toBe(1);
const ret = await next();
expect(++i).toBe(3);
return ret;
},
(ctx, vars: any) => {
expect(++i).toBe(2);
return { props: { foo: vars.foo } };
}
);
expect(await call(middleware)).toEqual({
props: { foo: true },
});
});
test("final middleware errors when calling next", async () => {
const middleware = use(
(ctx, vars: any, next) => {
vars.foo = true;
return next();
},
(ctx, vars: any, next) => {
return next();
}
);
expect.assertions(1);
return expect(call(middleware)).rejects.toEqual(
new Error("Final middleware cannot call next()")
);
});
test("final middleware errors if no result is returned", async () => {
const middleware = use(
(ctx, vars: any, next) => {
vars.foo = true;
return next();
},
// @ts-ignore
(ctx, vars: any, next) => {
return;
}
);
expect.assertions(1);
return expect(call(middleware)).rejects.toEqual(
new Error("Final middleware must return a `Result`")
);
});
@richardscarrott
Copy link
Author

richardscarrott commented Jul 31, 2022

Improvements

  1. Improve types
    • Infer return props type (currently props are any)
    • Create FinalMiddleware which doesn't accept next and can't return undefined?
      • Although composed use calls won't be expected to have a final middleware 🤔
  2. Support parallel execution, e.g. use(parallel(withUser, withSomeOtherAsyncData), withVersion, () => {});
  3. Consider passing vars into next so that you're not forced to mutate
    • Although mutating is prob wiser for performance here
  4. Create API routes equivalent e.g.
    use(
      method("GET"),
      role("ADMIN"),
      (req, res, vars, next) => {
        vars.foo = true;
        return next();
      },
      (req, res, vars) => {
        res.status(200).send({ user: vars.foo });
      }
    );
  5. Create express fn which interoperates with express-style middleware
    • e.g. use(express((req, res, next) => { req.foo = true; next(new Error('nope')) }), (ctx) => { props: { foo: ctx.req.foo } })
  6. Consider moving vars to ctx.vars so middleware signature is just (ctx, next) => Promise<Result> / (req, res, next) => Promise<void>
  7. Finish unit tests
  8. Publish to npm as nextjs-use

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment