2022年10月1日

next.js 源码解析 - API 路由篇

文章中包含大量源码排查路径相关内容,不感兴趣可直接跳到最后。

首先我们得确认下源码的位置, next.jspackages 中包括了很多包,我们先要确认和 API 路由相关的源码位置。

排查 CLI 源码

本章是排查 API 路由相关的源码的步骤,不感兴趣的可以跳过。

next 会启动 API 路由的命令包括:startdev,我们首先从这两个命令开始排查:

首先 next start 这些命令调用的是项目 node_modules/bin 下的 next 文件,我们找到文件后看一下:

#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")

case `uname` in
    *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac

if [ -z "$NODE_PATH" ]; then
  export NODE_PATH="/xxxx"
else
  export NODE_PATH="$NODE_PATH:xxxx"
fi
if [ -x "$basedir/node" ]; then
  exec "$basedir/node"  "$basedir/../next/dist/bin/next" "[email protected]"
else
  exec node  "$basedir/../next/dist/bin/next" "[email protected]"
fi

NODE_PATH 里的代码被我省略,可以看出,该文件指向的是 next/dist/bin/next,所以可以看出包名就是 next,而 cli 入口文件为 next 下的 dist/bin/next

我们到 next 项目下确认下:

{
    "bin": {
        "next": "./dist/bin/next"
    }
}

确认 next 命令确实是在 next 包下,且文件为 dist/bin/next,从文件结构不难猜到,这个文件的源码就是 bin/next 文件。进入这个文件我们可以看到,调用命令的源码为:

commands[command]()
    .then(exec => exec(forwardedArgs))
    .then(() => {
        if (command === 'build') {
            // ensure process exits after build completes so open handles/connections
            // don't cause process to hang
            process.exit(0);
        }
    });

点击 commands 进去跳到 lib/commands.ts 文件:

export const commands: { [command: string]: () => Promise<cliCommand> } = {
    build: () => Promise.resolve(require('../cli/next-build').nextBuild),
    start: () => Promise.resolve(require('../cli/next-start').nextStart),
    export: () => Promise.resolve(require('../cli/next-export').nextExport),
    dev: () => Promise.resolve(require('../cli/next-dev').nextDev),
    lint: () => Promise.resolve(require('../cli/next-lint').nextLint),
    telemetry: () => Promise.resolve(require('../cli/next-telemetry').nextTelemetry),
    info: () => Promise.resolve(require('../cli/next-info').nextInfo)
};

可以看出 startdev,最终指向的是 cli 下的 next-devnext-start 文件。我们直接看 next-start 文件:

startServer({
    dir,
    hostname: host,
    port,
    keepAliveTimeout
})
    .then(async app => {
        const appUrl = `http://${app.hostname}:${app.port}`;
        Log.ready(`started server on ${host}:${app.port}, url: ${appUrl}`);
        await app.prepare();
    })
    .catch(err => {
        console.error(err);
        process.exit(1);
    });

前面是一些参数的 parse、校验,这里直接跳过,看下面主体部分,调用的是 lib/start-server 中的 startServer,其实 next-dev 最终也会调用 startServer,只是参数的不同。

我们再看下 startServer,抽离其中关键代码瞧瞧:

let requestHandler: RequestHandler;

const server = http.createServer((req, res) => {
    return requestHandler(req, res);
});

return new Promise<NextServer>((resolve, reject) => {
    server.on('listening', () => {
        const addr = server.address();
        const hostname = !opts.hostname || opts.hostname === '0.0.0.0' ? 'localhost' : opts.hostname;

        const app = next({
            ...opts,
            hostname,
            customServer: false,
            httpServer: server,
            port: addr && typeof addr === 'object' ? addr.port : port
        });

        requestHandler = app.getRequestHandler();
        upgradeHandler = app.getUpgradeHandler();
        resolve(app);
    });

    server.listen(port, opts.hostname);
});

可以看到此处就是调用 node 中的 http 模块创建 server,然而 handler 是从 next 函数创建的 app 中通过 getRequestHandler 获取的。在其中经过无数次跳转最终来到 server/next.ts 中的 createServer 方法:

export class NextServer {
    private async createServer(options: DevServerOptions): Promise<Server> {
        if (options.dev) {
            const DevServer = require('./dev/next-dev-server').default;
            return new DevServer(options);
        }
        const ServerImplementation = await getServerImpl();
        return new ServerImplementation(options);
    }
}

此处会根据 dev 参数出现分支,也就是 next dev 会调用 server/dev/next-dev-server 中的 DevServer,而 next start 则会调用 getServerImpl 其中调的是 server/next-server 中的 NextNodeServer,而 DevServer 继承自 NextNodeServer,上面用到的 NextNodeServergetRequestHandler 又继承自 server/base-server 中的 Server

差不多算是找到正主了,好了,此文终结。🤦‍♂️ 还好代码还算清晰,虽然路径长了些,找起来还算方便。

getRequestHandler

代码篇幅过长,因为本文目的是找到 API 路由相关的代码,其余包含的很多 i18ncookieurl parse 之类的代码直接跳过。进过无数次调用,getRequestHandler 最终会调用 this.run

export default abstract class Server<ServerOptions extends Options = Options> {
    protected async run(req: BaseNextRequest, res: BaseNextResponse, parsedUrl: UrlWithParsedQuery): Promise<void> {
        this.handleCompression(req, res);

        try {
            const matched = await this.router.execute(req, res, parsedUrl);
            if (matched) {
                return;
            }
        } catch (err) {
            if (err instanceof DecodeError || err instanceof NormalizeError) {
                res.statusCode = 400;
                return this.renderError(null, req, res, '/_error', {});
            }
            throw err;
        }

        await this.render404(req, res, parsedUrl);
    }
}

可以看出此处会调用 this.router.execute 来进行路由匹配,不匹配会进入 render404,报错会 renderErrorthis.router 是在 constructor 中通过 new Router(this.generateRoutes()) 来生成,而 generateRoutes 的实现则是在 NextServer 中,其中大部分都是处理 pages 相关的请求,此处不做讨论,找到 API 相关的代码:

if (pathname === '/api' || pathname.startsWith('/api/')) {
    delete query._nextBubbleNoFallback;

    const handled = await this.handleApiRequest(req, res, pathname, query);
    if (handled) {
        return { finished: true };
    }
}

此处调用 this.handleApiRequest 来处理发送到 /api/ 下的 API 请求,DevServer 也复写了 generateRoutes 方法,不过其中只是变动了 fsServer

export default class DevServer extends Server {
    generateRoutes() {
        const { fsRoutes, ...otherRoutes } = super.generateRoutes();
    }
}

所以最终 API 的处理都集中在 handleApiRequest 中:

export default class NextNodeServer extends BaseServer {
    protected async handleApiRequest(
        req: BaseNextRequest,
        res: BaseNextResponse,
        pathname: string,
        query: ParsedUrlQuery
    ): Promise<boolean> {
        let page = pathname;
        let params: Params | undefined = undefined;
        let pageFound = !isDynamicRoute(page) && (await this.hasPage(page));

        if (!pageFound && this.dynamicRoutes) {
            for (const dynamicRoute of this.dynamicRoutes) {
                params = dynamicRoute.match(pathname) || undefined;
                if (dynamicRoute.page.startsWith('/api') && params) {
                    page = dynamicRoute.page;
                    pageFound = true;
                    break;
                }
            }
        }

        if (!pageFound) {
            return false;
        }
        // Make sure the page is built before getting the path
        // or else it won't be in the manifest yet
        await this.ensureApiPage(page);

        let builtPagePath;
        try {
            builtPagePath = this.getPagePath(page);
        } catch (err) {
            if (isError(err) && err.code === 'ENOENT') {
                return false;
            }
            throw err;
        }

        return this.runApi(req, res, query, params, page, builtPagePath);
    }
}

其中上方做路由匹配,校验路由是否存在,然后调用 runApi

export default class NextNodeServer extends BaseServer {
    protected async runApi(
        req: BaseNextRequest | NodeNextRequest,
        res: BaseNextResponse | NodeNextResponse,
        query: ParsedUrlQuery,
        params: Params | undefined,
        page: string,
        builtPagePath: string
    ): Promise<boolean> {
        const edgeFunctions = this.getEdgeFunctions();

        for (const item of edgeFunctions) {
            if (item.page === page) {
                const handledAsEdgeFunction = await this.runEdgeFunction({
                    req,
                    res,
                    query,
                    params,
                    page,
                    appPaths: null
                });

                if (handledAsEdgeFunction) {
                    return true;
                }
            }
        }

        const pageModule = await require(builtPagePath);
        query = { ...query, ...params };

        delete query.__nextLocale;
        delete query.__nextDefaultLocale;

        if (!this.renderOpts.dev && this._isLikeServerless) {
            if (typeof pageModule.default === 'function') {
                prepareServerlessUrl(req, query);
                await pageModule.default(req, res);
                return true;
            }
        }

        await apiResolver(
            (req as NodeNextRequest).originalRequest,
            (res as NodeNextResponse).originalResponse,
            query,
            pageModule,
            {
                ...this.renderOpts.previewProps,
                revalidate: (newReq: IncomingMessage, newRes: ServerResponse) =>
                    this.getRequestHandler()(new NodeNextRequest(newReq), new NodeNextResponse(newRes)),
                // internal config so is not typed
                trustHostHeader: (this.nextConfig.experimental as any).trustHostHeader
            },
            this.minimalMode,
            this.renderOpts.dev,
            page
        );
        return true;
    }
}

上方 edgeFunctions 可以看出是用来做边缘计算的,由于目前暂时对边缘计算相关内容了解不多,这里不做深入,继续向下看。下面会直接将对应的 API 路由文件通过 require 引用,中间一段是处理 serverless,会直接调用路由模块的默认方法来处理 reqresserverless 支持通过配置中的 target 进行配置。

export async function apiResolver(
    req: IncomingMessage,
    res: ServerResponse,
    query: any,
    resolverModule: any,
    apiContext: ApiContext,
    propagateError: boolean,
    dev?: boolean,
    page?: string
): Promise<void> {
    const apiReq = req as NextApiRequest;
    const apiRes = res as NextApiResponse;

    if (!resolverModule) {
        res.statusCode = 404;
        res.end('Not Found');
        return;
    }
    const config: PageConfig = resolverModule.config || {};
    const bodyParser = config.api?.bodyParser !== false;
    const responseLimit = config.api?.responseLimit ?? true;
    const externalResolver = config.api?.externalResolver || false;
}

apiResolver 中首先检查代码模块检查,然后获取模块中的 config,及其中具体的 bodyParser 参数等。

// Parsing of cookies
setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req.headers));
// Parsing query string
apiReq.query = query;
// Parsing preview data
setLazyProp({ req: apiReq }, 'previewData', () => tryGetPreviewData(req, res, apiContext));
// Checking if preview mode is enabled
setLazyProp({ req: apiReq }, 'preview', () => (apiReq.previewData !== false ? true : undefined));

// Parsing of body
if (bodyParser && !apiReq.body) {
    apiReq.body = await parseBody(
        apiReq,
        config.api && config.api.bodyParser && config.api.bodyParser.sizeLimit ? config.api.bodyParser.sizeLimit : '1mb'
    );
}

然后是进行 cookiequerypreviewDatapreviewbodyParserbody 等获取和处理。此处还设置了 previewDatapreview,这两个属性在页面中有使用,但是在 API 路由中的用途在官网没有介绍,我暂时也没遇到使用场景,也不多说了。代码中还用到一段 setLazyProp

export function setLazyProp<T>({ req }: LazyProps, prop: string, getter: () => T): void {
    const opts = { configurable: true, enumerable: true };
    const optsReset = { ...opts, writable: true };

    Object.defineProperty(req, prop, {
        ...opts,
        get: () => {
            const value = getter();
            // we set the property on the object to avoid recalculating it
            Object.defineProperty(req, prop, { ...optsReset, value });
            return value;
        },
        set: value => {
            Object.defineProperty(req, prop, { ...optsReset, value });
        }
    });
}

用途是将传入的 req,上面挂载 gettersetter,这样在第一次获取上面的属性时,会走到 get 定义,此时才会执行传入的 getter 函数,并覆盖 getter setter 定义,这样就可以做到只有当我们去使用 req 上的对应属性时,才回去调用 setter,并且覆盖后不会再重复调用 setter,算是一个常见的懒加载性能优化。

然后下面的大部分代码都是扩展 resreq 上的属性,如 statussendjson 等,后面还用到了 interopDefault 来获取路由模块中的处理函数:

export function interopDefault(mod: any) {
    return mod.default || mod;
}

所以 API 路由模块文件中的默认处理函数可以使用 esmexport default 也可以使用 cjs 中的 module.exports

总结

从源码解析我们可以看到文档中没有描绘出来的一些点:

  • next.js 中的 /api 下的请求和 ssrssg 是同一个 server 处理,只是 ssrssg 的处理由 next.js 内部处理,而 api 请求会转交给 API 路由文件中的默认函数来处理。
  • API 路由文件可以使用 esm 模式导出处理函数,也可以使用 cjs 导出。
  • API 路由下也可以访问到 previewDatapreview,虽然暂不清楚用途是什么。
  • cookiespreviewDatapreview 结尾 lazyProp,只有在访问该属性时才会去挂载。