Skip to main content

serve

平时在本地调试项目打包生成的产物,你会用什么调试呢?遇到一些有 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