serve 源码学习

311 阅读5分钟

平时在本地调试项目打包生成的产物,你会用什么调试呢?遇到一些有 development 和 production 区别的项目,在构建后也会打包不同的代码,这个时候就可以用 serve 起个静态服务在本地快速验证

注:源码参考自v14.2.1,以下内容纯属个人探究和学习

使用

# 全局安装
npm i -g serve
# 进入指定目录,默认占用3000端口
# 默认会复制url(http://localhost:3000)到粘贴板上,可以直接粘贴到浏览器打开
serve
# 指定端口
serve . -l 3001

命令行详细参数如下:

-h, --help                          # Shows this help message
-v, --version                       # 展示版本
-l, --listen listen_uri             # Specify a URI endpoint on which to listen (see below) -
                                    # more than one may be specified to listen in multiple places
-p                                  # 指定端口
-s, --single                        # 单页面应用模式,重定向到index.html
-d, --debug                         # Show debugging information
-c, --config                        # 指定配置文件`serve.json`的文件位置
-L, --no-request-logging            # Do not log any request information to the console.
-C, --cors                          # CORS允许跨域
-n, --no-clipboard                  # 不将url复制到剪贴板
-u, --no-compression                # 不压缩文件,默认压缩
--no-etag                           # 用 `Last-Modified` 替代 `ETag`
-S, --symlinks                      # Resolve symlinks instead of showing 404 errors
--ssl-cert                          # ssl-cert文件地址
--ssl-key                           # ssl-key文件地址
--ssl-pass                          # Optional path to the SSL/TLS certificate's passphrase
--no-port-switching                 # 当端口被占用时,不要自动打开其他端口

源码探究

项目结构

github仓库 拷贝一份到本地看看,主要看 source 这个文件夹

source            
├─ utilities      
│  ├─ cli.ts      # 命令行处理
│  ├─ config.ts   # 读取配置文件的函数
│  ├─ http.ts     # http函数封装
│  ├─ logger.ts   # 日志处理
│  ├─ promise.ts  # promise工具函数
│  └─ server.ts   # 静态服务主体
├─ main.ts         # 主要代码
└─ types.ts        # 类型文件

解析参数

这部分逻辑的代码在cli.ts,新建映射对象 args,解析参数时借助了 arg 这个库处理命令行参数。如果命令行中存在对应参数,就会挂载到 args 上,后续可以通过 args[string](比如 args['--help'])来判断是否有对应参数

import parseArgv from 'arg';
// cli.ts
const options = {
  '--help': Boolean,
  '--version': Boolean,
  '--listen': [parseEndpoint] as [typeof parseEndpoint],
  '--single': Boolean,
  '--debug': Boolean,
  '--config': String,
  '--no-clipboard': Boolean,
  '--no-compression': Boolean,
  '--no-etag': Boolean,
  '--symlinks': Boolean,
  '--cors': Boolean,
  '--no-port-switching': Boolean,
  '--ssl-cert': String,
  '--ssl-key': String,
  '--ssl-pass': String,
  '--no-request-logging': Boolean,
  // 指定上述字段的别名
  '-h': '--help',
  '-v': '--version',
  '-l': '--listen',
  '-s': '--single',
  '-d': '--debug',
  '-c': '--config',
  '-n': '--no-clipboard',
  '-u': '--no-compression',
  '-S': '--symlinks',
  '-C': '--cors',
  '-L': '--no-request-logging',
  // deprecated
  '-p': '--listen',
};
export const parseArguments = (): Arguments => parseArgv(options);

入口

也就是main.ts,这里逻辑还是划分的比较清晰的,留意几个点:

  • 调用 process.exit() 将强制进程尽快退出,退出码为 0-255 的整数,0通常表示程序正常结束,而非零值则表示出现了某种错误
  • resolve函数。这里封装了 resolve 这个函数统一处理 promise 对象(非 promise 值参考 await 返回值),try/catch 集中处理异常,返回一个元组,第一个元素就是异常返回值。蛮不错的,平时项目中也可以用一下
// promise.ts
export const resolve = async <T = unknown, E = Error>(
  promiseLike: Promise<T> | T,
): Promise<[E, undefined] | [undefined, T]> => {
  try {
    const data = await promiseLike;
    return [undefined, data];
  } catch (error: unknown) {
    return [error as E, undefined];
  }
};
  • 检测版本函数 checkForUpdates,用到了 update-check这个库。自己有开发库的话,版本检测可以参考下面这种方法
// cli.ts
import checkForUpdate from 'update-check';
// ...
// manifest 指的是 package.json 转换的js对象,import导入json会自动转成js对象
export const checkForUpdates = async (manifest: object): Promise<void> => {
  if (env.NO_UPDATE_CHECK) return;
  const [error, update] = await resolve(checkForUpdate(manifest));
  if (error) throw error;
  if (!update) return;
  // 提示有新版本
  logger.log(
    chalk.bgRed.white(' UPDATE '),
    `The latest version of `serve` is ${update.latest}`,
  );
};

其他内容可以参考下注释:

import { cwd as getPwd, exit, env, stdout } from 'node:process';
import { resolve } from './utilities/promise.js';
import { startServer } from './utilities/server.js';
// ...
// 获取参数
const [parseError, args] = await resolve(parseArguments());
if (parseError || !args) {
  exit(1);
}
// 检查版本
const [updateError] = await resolve(checkForUpdates(manifest));
if (updateError) {
  // ...检查版本时出现异常
}
// 查看版本,不需要继续处理
if (args['--version']) {
  exit(0);
}
// 查看帮助文档
if (args['--help']) {
  exit(0);
}
// 确定ip和端口,默认是localhost:3000
if (!args['--listen'])
  args['--listen'] = [{ port: parseInt(env.PORT ?? '3000', 10) }];
// 解析配置文件 serve.json,将相关配置挂载到 args 对象上
const presentDirectory = getPwd();
const directoryToServe = args._[0] ? path.resolve(args._[0]) : presentDirectory;
const [configError, config] = await resolve(
  loadConfiguration(presentDirectory, directoryToServe, args),
);
if (configError || !config) {
  exit(1);
}
// 启动单页面应用模式
if (args['--single']) {
  const { rewrites } = config;
  const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
  // 追加配置,后续 server-handler 会用到这里的重定向规则
  config.rewrites = [
    {
      source: '**',
      destination: '/index.html',
    },
    ...existingRewrites,
  ];
}
// 启动服务,可能有多个服务
for (const endpoint of args['--listen']) {
  const { local, network, previous } = await startServer(
    endpoint,
    config,
    args,
  );
  // ...
}

监听服务退出

// main.ts
import { registerCloseListener } from './utilities/http.js';
// ...
// 停止服务
registerCloseListener(() => {
  // ...
  // 二次触发 ctrl+c 事件强制退出
  process.on('SIGINT', () => {
    exit(0);
  });
});
// http.ts
export const registerCloseListener = (fn: () => void): void => {
  let run = false;
  // 类似once,只执行一次
  const wrapper = () => {
    if (!run) {
      run = true;
      fn();
    }
  };
  // 监听 ctrl+c 事件,表示服务停止执行并退出
  process.on('SIGINT', wrapper);
  // 是一个终止进程的信号,通常由系统发送来请求程序优雅地关闭
  process.on('SIGTERM', wrapper);
  // 当Node.js进程即将退出时触发
  process.on('exit', wrapper);
};

剪贴板

这里用到 clipboardy 这个库来实现复制内容到剪贴板

for (const endpoint of args['--listen']) {
  // ...
 // 拷贝到剪贴板上
  const copyAddress = !args['--no-clipboard'];
  if (copyAddress && local) {
    try {
      await clipboard.write(local);
    } catch (error: unknown) {
      // ...
    }
  }
}

静态服务

主要逻辑在 server.tsserverHandler 函数

import handler from 'serve-handler';
import compression from 'compression';
// ...
export const startServer = async (
  endpoint: ParsedEndpoint,
  config: Partial<Configuration>,
  args: Partial<Options>,
  previous?: Port,
): Promise<ServerAddress> => {
  const serverHandler = (
    request: IncomingMessage,
    response: ServerResponse,
  ): void => {
    const run = async () => {
      const requestTime = new Date();
      const formattedTime = `${requestTime.toLocaleDateString()} ${requestTime.toLocaleTimeString()}`;
      const ipAddress =
        request.socket.remoteAddress?.replace('::ffff:', '') ?? 'unknown';
      const requestUrl = `${request.method ?? 'GET'} ${request.url ?? '/'}`;
      // 允许跨域
      if (args['--cors']) {
        response.setHeader('Access-Control-Allow-Origin', '*');
        response.setHeader('Access-Control-Allow-Headers', '*');
        response.setHeader('Access-Control-Allow-Credentials', 'true');
        response.setHeader('Access-Control-Allow-Private-Network', 'true');
      }
      // 压缩资源
      if (!args['--no-compression']) {
        await compress(request, response);
      }
      // server-handler 增强服务
      await handler(request, response, config);
      // ...
    };
    run().catch((error: Error) => {
      throw error;
    });
  };
  // ...
  // 创建一个新的 HTTP 服务器
  // 当服务器接收到一个 HTTP 请求时,它会调用 serverHandler 回调函数来处理这个请求
  const server = http.createServer(serverHandler);
  server.on('error', (error) => {
    throw new Error(
      `Failed to serve: ${error.stack?.toString() ?? error.message}`,
    );
  });
}

server-handler

这里用到了 server-handler 这个库,集成了很多便捷的功能,可以帮助我们增强静态服务,比如配置单页面应用的重定向、设置自定义响应头、设置etag等。可以创建 serve.json来定制对应的功能,通过 -c 指定该配置文件的路径

单页面应用

怎么解决单页面应用的路由重定向问题?可以在命令行中使用 --single-s 选项来启用单页面应用模式

serve -s

这里就用到了server-handler 这个库,通过传入 rewrites 的配置,来启用重定向到index.html的功能。原理其实就是当请求的路径不存在时,服务还是返回 index.html 文件,由前端路由库接管并渲染对应的组件

跨域处理

这个时候我们用 localhost 访问没啥问题,但如果换了个设备,需要用ip访问,这个时候就要报跨域的错误了,可以通过以下命令解决

serve -s -C

主要是将返回资源的响应头的 Access-Control-Allow-Origin 设置为 *

端口占用

serve 用了 is-port-reachable 这个库来检测端口是否可用,如果被占用就换其他的端口

server.on('error', (error) => {
    throw new Error(
      `Failed to serve: ${error.stack?.toString() ?? error.message}`,
    );
});
if (
    typeof endpoint.port === 'number' &&
    !isNaN(endpoint.port) &&
    endpoint.port !== 0
) {
    const port = endpoint.port;
    const isClosed = await isPortReachable(port, {
      host: endpoint.host ?? 'localhost',
    });
    // isClosed 说明被占用或关闭
    // port: 0 表示让系统选择一个可用的端口
    if (isClosed) return startServer({ port: 0 }, config, args, port);
}

https 服务

需要提供证书和公钥,然后基于 https 这个库开启一个 https 服务

// ...
  const sslCert = args['--ssl-cert'];
  const sslKey = args['--ssl-key'];
  const sslPass = args['--ssl-pass'];
  const isPFXFormat =
    sslCert && /[.](?<extension>pfx|p12)$/.exec(sslCert) !== null;
  // 判断是否需要启动https服务
  const useSsl = sslCert && (sslKey || sslPass || isPFXFormat);
  // https服务的配置
  let serverConfig: http.ServerOptions | https.ServerOptions = {};
  // 使用PEM格式的证书和密钥
  if (useSsl && sslCert && sslKey) {
    serverConfig = {
      key: await readFile(sslKey),
      cert: await readFile(sslCert),
      // 如果提供了SSL密码会优先读取
      passphrase: sslPass ? await readFile(sslPass, 'utf8') : '',
    };
  // 使用PFX证书
  } else if (useSsl && sslCert && isPFXFormat) {
    serverConfig = {
      pfx: await readFile(sslCert),
      passphrase: sslPass ? await readFile(sslPass, 'utf8') : '',
    };
  }
  const server = useSsl
    ? https.createServer(serverConfig, serverHandler)
    : http.createServer(serverHandler);
// ...

其他话

所幸代码不多,剔除一些干扰代码后(比如日志代码等),完整地梳理了一遍主体功能,学到了一些Node.js服务器以及进程相关的处理,然后也发现了一些好用的三方库,比如 clipboardyarg