Express + TypeScript + ESM 后端框架示例(@yao-pkg/pkg 打包)

基于 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 ,所以 importfrom './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 关键点:

  1. "type": "module" ------ 项目使用 ESM。
  2. 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: ./disttsc 编译产物在这里,运行时可用 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.jsonpkg.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.prodoverride: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 一个带 statusError,即可映射为标准响应:

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 的入口esbuildentryPoints)。它解析 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/applyConfigconfig 的端口解析逻辑,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 两种模式一致)。内存 一项来自 /healthdata.memory运行时长process.uptime()formatUptime 格式化。


10. 如何新增一个业务模块

假设要加一个 user 模块:

  1. services/userService.ts:写业务逻辑。
  2. controllers/userController.ts:校验入参、调 service、用 ResponseUtil 返回。
  3. routes/user.tsrouter.get('/list', userController.list) 等。
  4. routes/index.ts:在 routes 数组追加 { path: '/user', router: userRoutes }
  5. (可选)在 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.mjsexternal 排除它,并在 pkg.assets 加入对应资源。
生产环境 CORS 被拦 把前端域名加入 .env.prodALLOWED_ORIGINS(逗号分隔)。
打包后/生产模式服务静默退出 winston 默认 exitOnError:true,首次写日志若 logs/ 不可写会 process.exit。已修复:exitOnError:false,且 pkg 运行时日志改走 stdout;普通环境启动时 mkdir 确保 logs/ 存在(见 7.3、9.1)。
CLI --port 不生效 .env.prodoverride: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 后端服务的起点,专注于写 servicecontroller 即可。

相关推荐
苏灿烤鱼3 小时前
今日 GitHub 热门|Agent 记忆重回榜首,+2,690 项目却只排第三
typescript·agent·资讯
凌云拓界1 天前
NodeVerdict:Node.js 原生诊断数据可视化工具
信息可视化·架构·typescript·开源·node.js·github·bug
苏灿烤鱼1 天前
GitHub Trending 榜首|GitHub #1 拆解|为什么「持久工作台」比临时沙箱更值得关注?(08.06)技术拆解
人工智能·typescript·agent
用户938515635072 天前
从"坐电梯"到"前端路由"——深入理解 Hash 路由原理
前端·typescript·全栈
苏灿烤鱼2 天前
GitHub Trending 榜首|腾讯 Agent 记忆库技术拆解:分层记忆 vs 向量堆,让 AI 不再反复问
typescript·开源·agent
用户938515635072 天前
React 组件设计的三个层次:从类型约束到状态归属,再到纯展示
typescript·全栈
A24207349302 天前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
水獭比特2 天前
AI 视频生成不是一次 HTTP 请求:先把长任务状态机补齐
人工智能·typescript
kyriewen3 天前
别再这样写TypeScript了——Code Review中最常见的8个反模式
前端·javascript·typescript