基于 Express + TypeScript + ESM可直接运行、直接打包成单文件可执行程序的 Node.js 后端框架骨架。
读完这篇教程,你将掌握:如何在 原生 ESM + TypeScript 项目里用
@yao-pkg/pkg打出单文件 exe,以及一套分层清晰、可直接复用的后端代码结构。
目录
- [1. 为什么需要这样打包](#1. 为什么需要这样打包)
- [2. 技术栈与前置条件](#2. 技术栈与前置条件)
- [3. 目录结构](#3. 目录结构)
- [4. 第一步:初始化项目与依赖](#4. 第一步:初始化项目与依赖)
- [5. 第二步:tsconfig(ESM + bundler 解析)](#5. 第二步:tsconfig(ESM + bundler 解析))
- [6. 第三步:esbuild 把 ESM 打成 CJS 单文件](#6. 第三步:esbuild 把 ESM 打成 CJS 单文件)
- [7. 第四步:编写框架源码(分层)](#7. 第四步:编写框架源码(分层))
- [7.1 配置加载 config/env.ts](#7.1 配置加载 config/env.ts)
- [7.2 统一响应 utils/response.ts](#7.2 统一响应 utils/response.ts)
- [7.3 日志 utils/logger.ts](#7.3 日志 utils/logger.ts)
- [7.4 CORS middleware/cors.ts](#7.4 CORS middleware/cors.ts)
- [7.5 统一错误处理 middleware/errorHandler.ts](#7.5 统一错误处理 middleware/errorHandler.ts)
- [7.6 业务分层 routes / controllers / services](#7.6 业务分层 routes / controllers / services)
- [7.7 应用装配 app.ts](#7.7 应用装配 app.ts)
- [7.8 启动入口 server.ts](#7.8 启动入口 server.ts)
- [7.9 打包入口 cli.ts](#7.9 打包入口 cli.ts)
- [8. 第五步:本地运行与验证](#8. 第五步:本地运行与验证)
- [9. 第六步:用 @yao-pkg/pkg 打包](#9. 第六步:用 @yao-pkg/pkg 打包)
- [9.1 打包后命令行启动验证(实战)](#9.1 打包后命令行启动验证(实战))
- [9.2 状态查看
status命令](#9.2 状态查看 status 命令)
- [10. 如何新增一个业务模块](#10. 如何新增一个业务模块)
- [11. 常见问题与坑](#11. 常见问题与坑)
1. 为什么需要这样打包
pkg 能把 Node 应用连同运行时打包成单个可执行文件,分发时对方无需安装 Node 。但 pkg 对原生 ESM(import/export)支持很差,直接打 ESM 入口几乎必然失败。
本项目的解法(也是参考项目的核心套路):
源码(ESM .ts)
⬇
tsc
⬇
dist/*.js(ESM, .js 指 .ts)
⬇
esbuild(打包成单个 CJS 文件)
⬇
dist/bundle.cjs (CommonJS 单文件)
⬇
@yao-pkg/pkg(封装 Node 运行时)
⬇
dist/pkg/express-ts-pkg.exe // 单文件可执行程序
把 ESM 先用 esbuild 转成 单个 CJS 文件 ,再交给 pkg。CJS 单文件是 pkg 的舒适区,绝大多数坑都被绕开了。
2. 技术栈与前置条件
- Node.js ≥ 22 (本示例使用 Node 22,
pkg目标也选node22) - Express 5 + TypeScript 5
- esbuild:把源码打包成 CJS 单文件
- @yao-pkg/pkg:把 CJS 单文件 + Node 运行时打包成 exe
- dotenv / winston / cors / helmet / morgan / zod:配置、日志、安全头、CORS、HTTP 日志、参数校验
安装依赖(项目根目录执行):
bash
npm install
依赖声明见 package.json:
json
{
"name": "express-ts-pkg",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon -q -L -c nodemon.json",
"build": "tsc",
"start": "node dist/server.js",
"bundle": "node esbuild.config.mjs",
"pkg:win": "cross-env PKG_CACHE_PATH=.pkg-cache pkg dist/bundle.cjs --target node22-win-x64 --output dist/pkg/express-ts-pkg.exe --config package.json",
"pkg:linux": "cross-env PKG_CACHE_PATH=.pkg-cache pkg dist/bundle.cjs --target node22-linux-x64 --output dist/pkg/express-ts-pkg --config package.json",
"pkg:all": "npm run build && npm run bundle && npm run pkg:win && npm run pkg:linux",
"dist": "npm run build && npm run bundle && npm run pkg:win"
},
"pkg": {
"scripts": ["dist/bundle.cjs"],
"targets": ["node22-win-x64", "node22-linux-x64"],
"outputPath": "dist/pkg",
"assets": []
}
}
注意
"type": "module":整个项目是 ESM ,所以import用from './xxx.js'(.js指向同名的.ts文件),这是 Node ESM 的硬性要求。
3. 目录结构
node-express-ts-pkg/
├── package.json # type:module + pkg 配置 + 脚本
├── tsconfig.json # ESNext / moduleResolution: bundler
├── esbuild.config.mjs # 入口 src/cli.ts → dist/bundle.cjs (cjs)
├── nodemon.json # dev 热重载
├── .env / .env.dev / .env.prod
└── src/
├── cli.ts # pkg 打包入口(start / status / --help / --config / --port / --node-env)
├── server.ts # 导入即启动监听 + 优雅退出
├── app.ts # Express 装配
├── config/env.ts # 环境变量加载 + 配置对象
├── utils/ # logger / response / envParser / validation
├── middleware/ # cors / errorHandler
├── routes/ # index.ts(注册表) + health.ts + echo.ts
├── controllers/ # healthController / echoController
└── services/ # healthService / echoService
4. 第一步:初始化项目与依赖
package.json 关键点:
"type": "module"------ 项目使用 ESM。pkg字段 ------ 告诉 pkg 入口脚本、目标平台、产物目录。scripts: ["dist/bundle.cjs"]:pkg 要打包的文件(esbuild 的产物)。targets:同时支持 win/linux 的 node22。assets: []:本项目无静态资源;若以后要打包public/**,在此声明。
5. 第二步:tsconfig(ESM + bundler 解析)
json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
要点:
module: ESNext+moduleResolution: bundler:允许在.ts里写import x from './x.js'(用.js扩展名指向.ts),符合 Node ESM 规范,且esbuild能正确解析。isolatedModules: true:配合esbuild单文件转译所必需。outDir: ./dist:tsc编译产物在这里,运行时可用node dist/server.js直接起服务。
6. 第三步:esbuild 把 ESM 打成 CJS 单文件
esbuild.config.mjs:
js
import { build } from 'esbuild';
await build({
entryPoints: ['src/cli.ts'], // 以 cli.ts 为入口
bundle: true, // 把所有依赖内联进一个文件
outfile: 'dist/bundle.cjs', // 输出为 CommonJS
format: 'cjs',
platform: 'node',
target: 'node22',
banner: { js: '// express-ts-pkg bundled by esbuild\n' },
logLevel: 'info',
});
console.log('esbuild bundle complete -> dist/bundle.cjs');
为什么是 cli.ts 而不是 server.ts 当入口?因为 cli.ts 才是 pkg 打包入口(见 7.9)。打包命令:
bash
npm run bundle
产物:dist/bundle.cjs(约 1.7MB,已内联全部依赖)。
若以后引入原生
.node插件 (如sqlite3),请在external里排除它,并在package.json的pkg.assets加入对应的.node/ 资源,pkg会自动把它们打进 exe。
7. 第四步:编写框架源码(分层)
分层职责:
| 层 | 目录 | 职责 |
|---|---|---|
| 配置 | config/ |
读环境变量,导出强类型配置对象 |
| 工具 | utils/ |
日志、统一响应、参数校验、env 解析 |
| 中间件 | middleware/ |
CORS、统一错误处理 |
| 路由 | routes/ |
路由注册表 + 各模块路由 |
| 控制器 | controllers/ |
解析请求、调用 service、返回响应 |
| 服务 | services/ |
业务逻辑 |
| 装配 | app.ts |
把中间件、路由组装成 Express 应用 |
| 启动 | server.ts |
监听端口 + 优雅退出 |
| 入口 | cli.ts |
pkg 打包入口 + CLI 参数 |
7.1 配置加载 config/env.ts
按 NODE_ENV 加载 .env 与对应环境文件,并解析成配置对象:
ts
import dotenv from 'dotenv';
import path from 'node:path';
import { readFileSync } from 'node:fs';
const nodeEnv = process.env.NODE_ENV || 'development';
// 保存 CLI / 系统环境变量已设置的值,dotenv 加载后恢复,
// 以保证优先级:CLI/系统 > .env.prod > .env > 默认值
const PRESET_KEYS = [
'PORT',
'NODE_ENV',
'APP_NAME',
'ALLOWED_ORIGINS',
'LOG_LEVEL',
];
const presets: Record<string, string> = {};
for (const k of PRESET_KEYS) {
if (process.env[k] !== undefined) presets[k] = process.env[k];
}
// 1) 先加载基础 .env(不覆盖已存在的变量)
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
// 2) 再按 NODE_ENV 覆盖对应环境文件(.env.prod / .env.dev ...)
if (nodeEnv !== 'development') {
const envFile = nodeEnv === 'production' ? '.env.prod' : `.env.${nodeEnv}`;
dotenv.config({ path: path.resolve(process.cwd(), envFile), override: true });
}
// 3) 恢复 CLI/系统预设(最高优先)
for (const [k, v] of Object.entries(presets)) {
process.env[k] = v;
}
function parseEnvArray(value?: string, fallback: string[] = []): string[] {
if (!value) return fallback;
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
export const config = {
env: nodeEnv,
port: Number(process.env.PORT) || 3000,
appName: process.env.APP_NAME || 'express-ts-pkg',
allowedOrigins: parseEnvArray(process.env.ALLOWED_ORIGINS),
logLevel:
process.env.LOG_LEVEL || (nodeEnv === 'development' ? 'debug' : 'info'),
version: readVersion(),
} as const;
为什么第 3 步要「恢复」?因为
.env.prod用override:true会覆盖同名变量,若不恢复,CLI 的--port 9093就会被.env.prod里的PORT=8080冲掉。恢复后 CLI 参数具有最高优先级(详见 9.1 实战验证)。
7.2 统一响应 utils/response.ts
所有接口都经由 ResponseUtil 返回,前端拿到一致结构 { code, message, data, success, timestamp }:
ts
export class ResponseUtil {
static success<T>(res: Response, data?: T, message = '操作成功', code = 200) {
return res
.status(200)
.json({ code, message, data, success: true, timestamp: Date.now() });
}
static badRequest(res: Response, message = '参数错误', data?: unknown) {
return this.error(res, message, 400, data, 400);
}
static notFound(res: Response, message = '资源不存在') {
return this.error(res, message, 404, null, 404);
}
static internalError(res: Response, message = '服务器内部错误') {
return this.error(res, message, 500, null, 500);
}
// ...error(...) 等
}
7.3 日志 utils/logger.ts
基于 winston。日志输出策略:
- 开发模式 :控制台(带颜色)+
logs/文件(error.log/all.log)。 - 生产模式(普通
node运行) :仅logs/文件(JSON 格式),不污染 stdout。 - pkg 打包运行时 :代码位于只读快照文件系统 ,写本地日志文件不可靠,因此日志统一输出到 stdout (生产模式为 JSON 格式)。这是单文件 / 容器部署的标准做法,也便于被
docker logs、systemd-journald 等采集。
同时设置了 exitOnError: false,避免某个传输层(如文件写入失败)导致整个进程退出。
已知现象:pkg 打包时会有一条
Cannot resolve 'mod'的动态require警告(来自 winston 内部),运行期无影响,已实测正常。
7.4 CORS middleware/cors.ts
- 开发环境:
*宽松放行,方便本地联调。 - 生产环境:按
ALLOWED_ORIGINS白名单严格校验,支持精确匹配、通配符*、子域名*.example.com。
7.5 统一错误处理 middleware/errorHandler.ts
注册在所有路由之后的 4 参数中间件。业务代码只需 throw 一个带 status 的 Error,即可映射为标准响应:
ts
// 业务里这样用:
throw Object.assign(new Error('用户不存在'), { status: 404 });
7.6 业务分层 routes / controllers / services
以 echo 为例,三层各自只做一件事:
services/echoService.ts ------ 纯业务逻辑:
ts
import { EchoInput } from '../utils/validation.js';
class EchoService {
echo(input: EchoInput) {
const count = input.count ?? 1;
return {
message: input.message,
repeated: Array.from({ length: count }, () => input.message),
echoedAt: new Date().toISOString(),
};
}
}
export default new EchoService();
controllers/echoController.ts ------ 校验入参 + 调 service + 返回:
ts
import { Request, Response } from 'express';
import { echoSchema } from '../utils/validation.js';
import echoService from '../services/echoService.js';
import { ResponseUtil } from '../utils/response.js';
class EchoController {
echo(req: Request, res: Response) {
const parsed = echoSchema.safeParse(req.body);
if (!parsed.success) {
return ResponseUtil.badRequest(
res,
'参数校验失败',
parsed.error.flatten(),
);
}
return ResponseUtil.success(
res,
echoService.echo(parsed.data),
'echo 成功',
);
}
}
export default new EchoController();
routes/echo.ts ------ 只描述路由:
ts
import { Router } from 'express';
import echoController from '../controllers/echoController.js';
const router = Router();
router.post('/echo', echoController.echo);
export default router;
routes/index.ts ------ 路由注册表,新增模块在此追加一项:
ts
import healthRoutes from './health.js';
import echoRoutes from './echo.js';
const routes = [
{ path: '/health', router: healthRoutes },
{ path: '/demo', router: echoRoutes },
];
export default routes;
7.7 应用装配 app.ts
把安全头、CORS、请求体解析、HTTP 日志、路由、404、错误处理按顺序组装:
ts
const app: Application = express();
app.use(helmet({ /* CSP */ }));
app.use(config.env === 'development' ? devCorsMiddleware : corsMiddleware);
app.use(express.json({ limit: '10mb' }));
app.use(morgan('...', { stream: { write: (m) => logger.http(m.trim()) } }));
app.get('/', (_req, res) => res.json({ name: config.appName, version: config.version, endpoints: {...} }));
app.get('/health', healthController.get);
routes.forEach((route) => app.use('/api' + route.path, route.router));
app.use((req, res) => ResponseUtil.notFound(res, `路由 ${req.originalUrl} 不存在`));
app.use(errorHandler); // 必须最后
7.8 启动入口 server.ts
导入 app 即启动监听(这样 cli.ts 只需 import('./server.js') 就能拉起服务),并注册优雅退出:
ts
function startServer() {
const server = app.listen(config.port, () =>
logger.info(`服务已启动: http://localhost:${config.port}`),
);
const shutdown = (signal: string) => {
logger.info(`收到 ${signal},开始优雅关闭...`);
server.close(() => {
logger.info('HTTP 服务器已关闭');
process.exit(0);
});
setTimeout(() => process.exit(1), 30000); // 兜底强退
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('uncaughtException', (e) => {
logger.error(e);
shutdown('uncaughtException');
});
process.on('unhandledRejection', (r) => {
logger.error(r);
shutdown('unhandledRejection');
});
}
startServer();
7.9 打包入口 cli.ts
这是 pkg 的入口 (esbuild 的 entryPoints)。它解析 CLI 参数,可选读取 config.json,设置环境变量(必须在导入 server 之前),再动态导入 server 拉起服务:
ts
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
const flags = parseFlags(argv.slice(1));
if (command === '--help' || command === '-h') {
showHelp();
return;
}
if (command === undefined || command === 'start') {
const cfg = loadConfig(flags.config);
applyConfig(cfg); // 把 config.json 映射到环境变量
if (flags.port) process.env.PORT = flags.port;
if (flags['node-env']) process.env.NODE_ENV = flags['node-env'];
const isPkg = Boolean((process as any).pkg);
console.log(isPkg ? '[pkg] 启动打包后的服务...' : '启动服务...');
await import('./server.js'); // 拉起服务(导入即启动监听)
return;
}
if (command === 'status') {
await statusCommand(flags); // 仅探测 /health,不启动服务
return;
}
// 未知命令 -> 报错 + 帮助
}
main().catch((err) => {
console.error('致命错误:', err);
process.exit(1);
});
status 命令(节选):
ts
async function statusCommand(flags: Record<string, string>) {
const cfg = loadConfig(flags.config);
applyConfig(cfg);
if (flags.port) process.env.PORT = flags.port;
if (flags['node-env']) process.env.NODE_ENV = flags['node-env'];
// 动态导入以复用与 start 完全一致的端口解析(且不触发 server 启动)
const { config } = await import('./config/env.js');
const url = `http://localhost:${config.port}/health`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (!res.ok) { /* 端口有响应但不健康 */ process.exit(1); }
const body = (await res.json()) as { data?: Record<string, any> };
const d = body.data ?? {};
console.log(`● 运行中 (RUNNING)`);
console.log(` 服务名: ${d.app ?? config.appName}`);
console.log(` 端口: ${config.port}`);
console.log(` 环境: ${d.env ?? config.env}`);
console.log(` 版本: ${d.version ?? config.version}`);
console.log(` 健康状态: ${d.status ?? 'unknown'}`);
console.log(` 运行时长: ${formatUptime(Number(d.uptimeSeconds) || 0)}`);
process.exit(0);
} catch {
console.log(`○ 未运行 (NOT RUNNING)`); // 端口无监听 / 请求超时
process.exit(1);
}
}
关键:
process.pkg在打包后的 exe 里为true,可用来判断当前是否运行在打包环境中。status复用loadConfig/applyConfig与config的端口解析逻辑,与start使用完全相同的端口优先级 ,因此探测的就是start将要(或正在)监听的端口。
8. 第五步:本地运行与验证
开发(热重载):
bash
npm run dev
或编译后直接跑:
bash
npm run build
npm start
验证接口(默认 3000 端口):
bash
# 健康检查
curl http://localhost:3000/health
# 业务示例:POST 带校验
curl -X POST http://localhost:3000/api/demo/echo \
-H 'Content-Type: application/json' \
-d '{"message":"hi","count":2}'
# 404
curl http://localhost:3000/nope
# 参数校验失败(缺 message)-> 400
curl -X POST http://localhost:3000/api/demo/echo \
-H 'Content-Type: application/json' -d '{"count":2}'
预期:/health 返回 {code:200, data:{status:"ok",...}};echo 返回 repeated:["hi","hi"];404 返回 code:404;缺参返回 code:400 并带 zod 错误明细。
服务跑起来后,也可以用内置命令快速看状态(不启动新进程,仅探测 /health):
bash
node dist/cli.js status # 探测默认/配置端口
node dist/cli.js status --port 8080 # 指定端口
9. 第六步:用 @yao-pkg/pkg 打包
一条命令完成「编译 + 打包 + 出 exe」:
bash
npm run dist # = build + bundle + pkg:win
# 或跨平台:
npm run pkg:all # = build + bundle + pkg:win + pkg:linux
pkg:win 实际命令:
bash
PKG_CACHE_PATH=.pkg-cache pkg dist/bundle.cjs --target node22-win-x64 --output dist/pkg/express-ts-pkg.exe --config package.json
--target node22-win-x64:目标平台(首次会下载 Node 22 运行时,约几十 MB)。PKG_CACHE_PATH:指向已缓存的 Node 运行时 ,避免重复下载(本示例复用参考项目的.pkg-cache;换机器打包时删掉这个环境变量即可,pkg 会自动联网下载)。- 产物:
dist/pkg/express-ts-pkg.exe(约 67MB,已内联 Node 运行时)。
运行打包后的程序:
bash
./dist/pkg/express-ts-pkg.exe
./dist/pkg/express-ts-pkg.exe start --port 8080 --node-env production
./dist/pkg/express-ts-pkg.exe --help
运行后会打印 [pkg] 启动打包后的服务...,接口行为与开发模式完全一致。
9.1 打包后命令行启动验证(实战)
下面用真实编译打包出的单文件 exe 实测各种命令行启动方式(命令均在项目根目录执行)。
说明:本机演示时,因上一次运行残留的进程仍占用默认产物名
express-ts-pkg.exe,临时改用app.exe演示------命令与输出完全一致,仅文件名不同 ,把app.exe换成你打包得到的express-ts-pkg.exe即可。另外本机3000端口已被占用,故以下用7072 / 8082 / 9093演示默认/自定义/生产三种端口。
1) 查看帮助 --help
bash
./dist/pkg/express-ts-pkg.exe --help
express-ts-pkg v1.0.0
Usage:
express-ts-pkg [start] [--config <path>] [--port <port>] [--node-env <env>]
express-ts-pkg status [--port <port>] [--node-env <env>]
express-ts-pkg --help
Commands:
start 前台启动服务(默认)
status 查看服务运行状态(探测 /health)
--help,-h 显示本帮助
Options:
--config <path> 读取 JSON 配置文件(默认: ./config.json)
--port <port> 覆盖端口
--node-env <env> 覆盖 NODE_ENV(development/production)
示例配置文件 config.json:
{
"port": 8080,
"nodeEnv": "production",
"appName": "my-service",
"allowedOrigins": "https://a.com,https://b.com",
"logLevel": "info"
}
2) 默认前台启动(开发模式)
bash
./dist/pkg/express-ts-pkg.exe # 默认 3000,本机演示用 7072
./dist/pkg/express-ts-pkg.exe start --port 7072
启动日志(开发模式,控制台带颜色、CORS 宽松、监听端口正确):
[pkg] 启动打包后的服务...
info: CORS 配置验证: {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:25"}
info: - 允许的来源: http://localhost:3000, http://127.0.0.1:3000 {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:25"}
info: ✅ 开发环境 CORS(宽松模式)已启用 {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:25"}
info: 服务已启动: http://localhost:7072 (env=development) {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:25"}
http: GET /health 200 235 - 3.983 ms {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:25"}
接口验证(统一响应体一致):
bash
curl http://localhost:7072/health
# => {"code":200,"message":"服务健康","data":{"status":"ok","uptimeSeconds":2,"memory":{...},"app":"express-ts-pkg","env":"development","version":"1.0.0"},"success":true,"timestamp":...}
curl -X POST http://localhost:7072/api/demo/echo -H 'Content-Type: application/json' -d '{"message":"pkg-cli","count":2}'
# => {"code":200,"message":"echo 成功","data":{"message":"pkg-cli","repeated":["pkg-cli","pkg-cli"],"echoedAt":"..."},"success":true,...}
curl -o /dev/null -w "%{http_code}" http://localhost:7072/nope
# => 404
curl -o /dev/null -w "%{http_code}" -X POST http://localhost:7072/api/demo/echo -H 'Content-Type: application/json' -d '{"count":2}'
# => 400
3) 自定义端口 --port
bash
./dist/pkg/express-ts-pkg.exe start --port 8082
启动日志确认监听端口被改写:
info: 服务已启动: http://localhost:8082 (env=development) {"service":"express-ts-pkg","timestamp":"2026-08-06 14:52:30"}
接口行为与默认启动完全一致(把上面的 7072 换成 8082 即可)。
4) 生产模式 --node-env production(含 --port 优先级验证)
bash
./dist/pkg/express-ts-pkg.exe start --port 9093 --node-env production
启动日志(生产模式为 JSON 格式,CORS 走严格白名单):
[pkg] 启动打包后的服务...
{"level":"info","message":"CORS 配置验证:","service":"express-ts-pkg","timestamp":"2026-08-06 14:52:33"}
{"level":"info","message":"- 允许的来源: https://your-domain.com","service":"express-ts-pkg","timestamp":"2026-08-06 14:52:33"}
{"level":"info","message":"✅ 生产环境 CORS(严格模式)已启用","service":"express-ts-pkg","timestamp":"2026-08-06 14:52:33"}
{"level":"info","message":"服务已启动: http://localhost:9093 (env=production)","service":"express-ts-pkg","timestamp":"2026-08-06 14:52:33"}
注意最后一行 服务已启动: http://localhost:9093 ------ 说明 CLI 的 --port 9093 正确覆盖了 .env.prod 里的 PORT=8080 (CLI 参数优先级最高)。生产模式接口返回 env=production,其余行为与开发模式无差异:
bash
curl http://localhost:9093/health
# => {"code":200,"message":"服务健康","data":{"status":"ok",...,"env":"production"},"success":true,...}
curl -o /dev/null -w "%{http_code}" http://localhost:9093/nope # => 404
curl -o /dev/null -w "%{http_code}" -X POST http://localhost:9093/api/demo/echo -H 'Content-Type: application/json' -d '{"count":2}' # => 400
5) 普通 Node 运行生产模式(非 pkg,对照)
直接 node dist/server.js 也能以 production 启动,日志落入 logs/:
bash
NODE_ENV=production PORT=9094 node dist/server.js
启动后项目根目录的 logs/ 自动创建,写入 all.log(含本次全部日志)与 error.log(仅错误级):
logs/
├── all.log # 本次运行全部日志(JSON 行)
└── error.log # 空(无错误级日志)
这一步验证过早期一个坑:未修复前,winston 默认
exitOnError:true,生产模式首次写日志时若logs/不存在/不可写会直接process.exit(1)静默退出。现已通过「exitOnError:false+ 启动时mkdir确保目录 + pkg 下日志改走 stdout」修复。
验证结论汇总
| 启动方式 | 端口 | /health | /echo | 404/400 | 说明 |
|---|---|---|---|---|---|
start --port 7072(默认开发) |
7072 | 200 | 200 | 404/400 | 开发 CORS 宽松,控制台带颜色 |
start --port 8082(自定义端口) |
8082 | 200 | 200 | 404/400 | 监听端口随 --port 改变 |
start --port 9093 --node-env production |
9093 | 200 | 200 | 404/400 | CORS 严格、--port 覆盖 .env.prod |
NODE_ENV=production PORT=9094 node ... |
9094 | 200 | 200 | 404/400 | 日志落 logs/,非打包运行对照 |
所有方式均端到端可用,统一响应体一致。
9.2 状态查看 status 命令
status 用来快速确认「服务现在跑没跑、跑得健不健康」,它不会启动服务 ,而是按与 start 完全相同的端口优先级解析出端口,然后用内置 fetch 探测 http://localhost:<port>/health(2 秒超时)。
bash
./dist/pkg/express-ts-pkg.exe status # 探测默认/配置端口
./dist/pkg/express-ts-pkg.exe status --port 8080 # 指定端口
./dist/pkg/express-ts-pkg.exe status --node-env production --port 9093
退出码(便于脚本判断):
0------ 运行中且健康(HTTP 200)。1------ 未运行(端口无监听 / 请求超时)或异常(端口有响应但非 200)。
说明:本框架是单进程前台模式 ------
start在终端前台常驻,没有守护进程 / PID 文件。status的本质是「探测该端口上的/health接口」,因此它能正确反映start启动的那个实例(前提是二者端口一致)。若要支持「status跨终端管理后台守护进程」,需要引入 PID 文件与daemon模式,属于另一套设计,本示例未包含。
验证:服务未运行时
bash
./dist/pkg/express-ts-pkg.exe status --port 7101
正在检查 http://localhost:7101/health ...
○ 未运行 (NOT RUNNING)
端口 7101 无监听: fetch failed
退出码 1。
验证:服务运行时
先启动,再查看状态(命令均在项目根目录执行):
bash
./dist/pkg/express-ts-pkg.exe start --port 7101 > /tmp/srv.log 2>&1 &
./dist/pkg/express-ts-pkg.exe status --port 7101
正在检查 http://localhost:7101/health ...
● 运行中 (RUNNING)
服务名: express-ts-pkg
端口: 7101
环境: development
版本: 1.0.0
健康状态: ok
运行时长: 1s
内存: RSS 49 MB (heap 12/15 MB)
检查时间: 2026-08-06 15:04:27
原始响应: {"code":200,"message":"服务健康","data":{"status":"ok","uptimeSeconds":1,"memory":{"rssMb":49,"heapTotalMb":15,"heapUsedMb":12},"app":"express-ts-pkg","env":"development","version":"1.0.0"},"success":true,"timestamp":1785999867280}
退出码 0。
上述两段为真实输出(node 与打包 exe 两种模式一致)。
内存一项来自/health的data.memory,运行时长由process.uptime()经formatUptime格式化。
10. 如何新增一个业务模块
假设要加一个 user 模块:
services/userService.ts:写业务逻辑。controllers/userController.ts:校验入参、调 service、用ResponseUtil返回。routes/user.ts:router.get('/list', userController.list)等。routes/index.ts:在routes数组追加{ path: '/user', router: userRoutes }。- (可选)在
utils/validation.ts加对应 zod schema。
完成。无需改动 app.ts、日志、CORS、错误处理------基础设施全部复用。
11. 常见问题与坑
| 现象 | 原因 / 解法 |
|---|---|
pkg 直接打 ESM 入口失败 |
pkg 对原生 ESM 支持差。务必先经 esbuild 转成 CJS 单文件再打。 |
import 提示找不到模块 |
ESM 下 import 必须写扩展名:from './x.js'(对应 x.ts)。 |
打包后动态 require('mod') 警告 |
来自 winston 内部,运行期无影响,可忽略。 |
首次 pkg 很慢 / 报错 |
在下载 Node 运行时;用 PKG_CACHE_PATH 复用缓存或检查网络。 |
引入原生 .node 模块报错 |
在 esbuild.config.mjs 的 external 排除它,并在 pkg.assets 加入对应资源。 |
| 生产环境 CORS 被拦 | 把前端域名加入 .env.prod 的 ALLOWED_ORIGINS(逗号分隔)。 |
| 打包后/生产模式服务静默退出 | winston 默认 exitOnError:true,首次写日志若 logs/ 不可写会 process.exit。已修复:exitOnError:false,且 pkg 运行时日志改走 stdout;普通环境启动时 mkdir 确保 logs/ 存在(见 7.3、9.1)。 |
CLI --port 不生效 |
.env.prod 的 override:true 会覆盖 CLI 变量。已修复:config/env.ts 在 dotenv 加载前保存 CLI/系统变量,加载后恢复,保证 CLI 最高优先(见 7.1、9.1)。 |
总结
这套模板的核心价值就一句话:ESM + TypeScript 源码 → esbuild 转 CJS 单文件 → pkg 出单文件 exe 。
配合分层结构(config / utils / middleware / routes / controllers / services)和统一响应、统一错误处理、优雅退出等基础设施,你可以把它当作任何 Node 后端服务的起点,专注于写 service 和 controller 即可。