next.js : build a web filter registry

next.js is a framework that belongs to the page generator frameworks, it is getting more and more popular every day,
but it is also a server application framework, and can be used to orchestrate interactions.

If you are moving away from java and spring servers like me, you are certainly going to wonder how you can automate filters before each request, and the simple answer is “we don’t have that”.
Then you might want to build your own registry, by binding interceptors to your middleware / getServerSideProps function.

The good news is I have worked on this topic. I have been able to create a handcrafted tool to systematically use some filters, and I think the necessary overhead is acceptable.
In the end, your getServerSideProps will look like :

import { serverSideWrap } from 'my-middleware-lib';
import type { GetServerSideProps } from 'next';
import { interceptorList } from '../middleware.config';
export const getServerSideProps: GetServerSideProps<Props> = serverSideWrap(
    interceptorList,
    async ({ req: request }) => {
        // request contains the req object
        // + the things that you have bound to request
        request.myAddedFeature1();
        request.myAddedFeature2();
        return {
            props: {
                someProp: request.myAddedFeature3();
            }
        }
    })

And a middleware will look like :

import { _middleware } from 'my-middleware-lib';
import { interceptorList } from '../middleware.config';

export const middleware = _middleware(interceptorList);

How do you implement _middleware and serverSideWrap ?
Start by implementing an interceptor type :

import type { IncomingMessage } from "http"

export type Interceptor<T, U extends (Request | IncomingMessage)> = {
    name: string,
    beforeRequest: (request: U) => Promise<T & U>,
    beforeResponse?: (request: U, result: any) => Promise<void>
}

Add an Intercepted type definition to declare what features will be added to your initial request when running the wrapped function in getServerSideProps or middleware :


export type Intercepted<T extends (Request | IncomingMessage)> = T &
    Feature1 & Feature2 & Feature3 /* & ... */;
export type Feature1 = { myAddedFeature1: () => void }
export type Feature2 = { myAddedFeature2: (arg1: string) => string[] }
export type Feature3 = { myAddedFeature3: (arg1) => Promise<string> }

The last step consists in implementing the my-middleware-lib,
start by implementing the functions that will iterate over your interceptors and run them (you can use a nameFilter to only run some of the passed interceptors) :

export const doBeforeResponse = async <T extends (Request | IncomingMessage)>(
    request: Intercepted<T>,
    result: any,
    interceptors: Interceptor<Record<string, unknown>, T>[],
    nameFilter?: (name: string) => boolean,
): Promise<Intercepted<T>> =>
    ((interceptors ?? []).reduce((pipeline,
        { beforeResponse, name }) =>
        (!nameFilter || nameFilter?.(name)) && beforeResponse ? pipeline.then(() =>
            beforeResponse(request, result).catch((e) => {
                console.log(`beforeResponse : interception problem for ${name}`, e);
            })
        ) : pipeline,
        Promise.resolve(request))) as Promise<Intercepted<T>>;


export const doBeforeRequest = async <T extends (Request | IncomingMessage)>(
    request: T,
    interceptors: Interceptor<Record<string, unknown>, T>[],
    nameFilter?: (name: string) => boolean,
): Promise<Intercepted<T>> =>
    ((interceptors ?? []).reduce((pipeline,
        { beforeRequest, name }) =>
        !nameFilter || nameFilter?.(name) ? pipeline.then(() =>
            beforeRequest(request).catch((e) => {
                console.log(`request was rejected during ${name}`, e);
                throw e;
            })
        ) : pipeline,
        Promise.resolve(request))) as Promise<Intercepted<T>>;

Then implement a few facilitator methods to make the integration in your app code easier :


export const _middleware = <T extends (Request | IncomingMessage)>(
    interceptors: Interceptor<Record<string, unknown>, T>[],
    nameFilter?: (name: string) => boolean,
): (request: T) => Promise<Response | null> => interceptWith(
    interceptors,
    async () => NextResponse.next(),
    nameFilter
);

export function interceptWith<T extends (Request | IncomingMessage)>(
    interceptors: Interceptor<Record<string, unknown>, T>[],
    handler: (request: Intercepted<T>) => Promise<Response>,
    nameFilter?: (name: string) => boolean,
): (request: T) => Promise<Response | null> {
    return async (pureRequest) => {
        try {
            const request = await doBeforeRequest(pureRequest, interceptors, nameFilter);
            const result = await handler(request!);
            await doBeforeResponse(request, result, interceptors, nameFilter);
            return result;
        } catch (e) {
            await doBeforeResponse(pureRequest as Intercepted<T>, e, interceptors, nameFilter);
            if (e instanceof Response) return e;
            return new Response(JSON.stringify({
                error: 'INTERNAL_ERROR',
                description: 'An internal error has happened'
            }), {
                headers: {
                    "Content-Type": "application/json; charset=utf-8"
                }
            })
        }
    }
};

export const noOpServerSideIntercept: <T extends (Request | IncomingMessage) >(
    interceptors: Interceptor<Record<string, unknown>, T>[]
) => GetServerSideProps<{}> =
    interceptors => serverSideWrap(interceptors, async () => ({ props: {} }))

export const serverSideWrap: <T extends (Request | IncomingMessage), P>(
    interceptors: Interceptor<Record<string, unknown>, T>[],
    handler: (getServerSidePropsContext: Omit<GetServerSidePropsContext, 'req'> & { req: Intercepted<T> }) => Promise<GetServerSidePropsResult<P>>,
    nameFilter?: (name: string) => boolean,
) => GetServerSideProps<P> =
    (interceptors, handler, nameFilter) => async (getServerSidePropsContext: GetServerSidePropsContext) => {
        const { req } = getServerSidePropsContext;
        const [interceptedRequest, redirect] = await serverSideIntercept(
            req as IncomingMessage,
            interceptors as Interceptor<Record<string, unknown>, any>[],
            nameFilter
        );
        if (redirect) { return redirect; }

        const result = await handler({ ...getServerSidePropsContext, req: interceptedRequest });
        await doBeforeResponse(
            interceptedRequest,
            result,
            interceptors,
            nameFilter
        );
        return result;
    };

export async function serverSideIntercept<T extends (Request | IncomingMessage)>(
    request: T,
    interceptors: Interceptor<Record<string, unknown>, T>[],
    nameFilter?: (name: string) => boolean,
): Promise<[Intercepted<T> | null, { redirect: Redirect } | null]> {
    try {
        return [await doBeforeRequest(request, interceptors, nameFilter), null];
    } catch (e) {
        await doBeforeResponse(request as Intercepted<T>, e, interceptors, nameFilter);
        if (e instanceof Response && e.status >= 300 && e.status < 400) {
            return [null, {
                redirect: {
                    destination: e.headers.get('location')!,
                    statusCode: e.status as 301 | 302 | 303 | 307 | 308,
                }
            }]
        }
        throw e;
    }
};

Now you can start declaring your filters to bind your feature to the request :

export const createAddedFeature1Interceptor =
    <U extends (NextRequest | IncomingMessage)>(): Interceptor<Feature1, U> => ({
        name: 'feature1-interceptor',
        beforeRequest: async (inboundRequest) => {
            console.log("binding feature1 to the request");
            const requestWithFeature1: U & Feature1 = inboundRequest as U & Feature1;
            // you can bind a different function in case of middleware registry:
            if ((process as { browser?: boolean }).browser) {
              requestWithFeature1.feature1 = () => console.log("Hello from middleware");
              return requestWithFeature1;
            }
            requestWithFeature1.feature1 = () => console.log("Hello from getServerSideProps");
            return requestWithFeature1;
        },
        beforeResponse: async (request, result) => {
            console.log("feature1-interceptor: request was successful");
            console.log(`will return ${result}`);
        }
    })

In the middleware config file, just instantiate the new Interceptor and export it in an interceptor list

const addedFeature1Interceptor = createAddedFeature1Interceptor();
const addedFeature2Interceptor = createAddedFeature2Interceptor(/* some settings */);
const addedFeature3Interceptor = createAddedFeature3Interceptor(/* some settings */);

export const interceptorList = [
    addedFeature1Interceptor,
    addedFeature2Interceptor,
    addedFeature3Interceptor,
];

And… that’s it. You can bind systematically the features you need before using them with this filter registry, and NO typescript error with that.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.