跳到主要内容

16 篇博文 含有标签「Node.js」

查看所有标签

JWT 签名静默失败?检查 Node.js 环境变量的加载顺序

· 阅读需 3 分钟

在为客户构建 SaaS 认证系统时遇到此问题,记录根因与解法。

TL;DR

Node.js ES Module 中,import 语句在 dotenv.config() 之前执行。如果模块级代码读取 process.env.JWT_SECRET,拿到的是 undefined,导致 JWT 签名用 "undefined" 字符串作为密钥——不报错,但所有 token 校验都失败。解决方案:延迟初始化(lazy init)。

问题现象

JWT 登录接口返回 200,但后续请求全部 401。排查发现:

  1. 登录生成的 token 无法被 jwtVerify() 验证
  2. 每次重启服务,之前签发的 token 全部失效
  3. 打印 process.env.JWT_SECRET,结果是 undefined
// jwt.ts — 模块级代码
import crypto from 'crypto';

// ❌ 这行在 dotenv.config() 之前执行,JWT_SECRET 是 undefined
const SECRET = crypto.createSecretKey(
new TextEncoder().encode(process.env.JWT_SECRET)
);

最坑的是:不报错new TextEncoder().encode(undefined) 会把字符串 "undefined" 编码成字节,生成一个合法但错误的密钥。

根因

ES Module 的 import静态提升的:

// server.ts(入口文件)
import { router } from './routes/auth'; // ← 先执行
import { authenticateToken } from './middleware/auth'; // ← 先执行

dotenv.config(); // ← 后执行,但 import 链已经跑完了

执行顺序:

  1. Node.js 扫描所有 import,构建依赖图
  2. 深度优先执行所有被导入模块的顶层代码(jwt.tsconst SECRET = ... 在这里执行)
  3. 回到 server.ts,执行 dotenv.config()
  4. 此时 .env 才加载到 process.env

所以 jwt.ts 模块级代码读到的 process.env.JWT_SECRETundefined

解决方案

方案一:延迟初始化(推荐)

把密钥初始化从模块级移到函数内部,首次调用时才读取环境变量:

import crypto from 'crypto';

let _secret: crypto.KeyObject | null = null;

function getSecret(): crypto.KeyObject {
if (!_secret) {
const secretValue = process.env.JWT_SECRET;
if (!secretValue) {
throw new Error('JWT_SECRET 环境变量未设置');
}
_secret = crypto.createSecretKey(
new TextEncoder().encode(secretValue)
);
}
return _secret;
}

// 所有需要密钥的地方改用 getSecret()
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret()); // ← 延迟到运行时读取
}

优势:不依赖入口文件的 import 顺序,任何调用时机都安全。

方案二:入口文件顶部调用 dotenv

// server.ts — 确保这两行在所有 import 之前
import 'dotenv/config'; // 或 require('dotenv').config()
import express from 'express';
// ...其他 import

局限:如果有其他入口文件(如 cron job、worker)忘记加这行,问题复现。

注意事项

注意事项

  • 这个坑不只影响 JWT,所有模块级代码读取环境变量都有同样风险(数据库连接、API Key 等);ESM 模块解析还有另一个常见坑——动态 import 缺少 .js 扩展名会导致生产环境模块找不到
  • require('dotenv').config() 只在 CommonJS 中能保证顺序;ES Module 中 import 始终先于运行时代码
  • 延迟初始化模式适用于:密钥、数据库连接池、外部 API 客户端等一次性资源;如果你的 JWT 使用 jose 库且升级了 Node 24,也要注意 jose KeyObject 格式变化

Node.js ESM 动态 import 报模块找不到?检查文件扩展名

· 阅读需 4 分钟

在为客户构建 SaaS 数据分析平台时遇到此问题,记录根因与解法。

TL;DR

Node.js ESM 模式下,import('./path/to/module') 不会自动解析 ./path/to/module.js。TypeScript 编译的 dist 产物如果遗漏 .js 后缀,模块加载时抛出 ERR_MODULE_NOT_FOUND。如果这个 import 在延迟逻辑中(如定时器、条件分支),应用启动正常但运行一段时间后崩溃,PM2 表现为重启计数飙升。

解法:确保所有 ESM 动态 import 路径包含 .js 扩展名,并在 build 流程中自动修复。

问题现象

PM2 状态显示应用不断重启:

│ name             │ ↺    │ status │ uptime │
│ analytics-api │ 9 │ online │ 28m │

错误日志每几分钟重复:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

但文件实际存在:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

根因

ESM 不自动解析文件扩展名

Node.js 的 CommonJS (require()) 会自动尝试 .js.json 等后缀。ESM (import) 不会

// ❌ ESM 模式下找不到模块
import('./domains/video/cleanup')
// Node.js 查找: ./domains/video/cleanup (精确路径,无扩展名)
// 实际文件: ./domains/video/cleanup.js

// ✅ 必须带 .js 后缀
import('./domains/video/cleanup.js')

延迟 import 隐藏了问题

这个 import 在定时器中延迟执行:

// server.ts — 启动时不立即执行
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // 几秒后才触发
});

应用启动成功(DB 连接、端口监听都正常),定时器触发时 import 报错 → 进程崩溃 → PM2 重启 → 再次启动成功 → 定时器再次触发 → 再次崩溃。形成崩溃循环

为什么之前没问题?

旧版本的 dist 产物是通过手动 build 生成的,build 脚本包含 .js 后缀修复步骤。某次部署时跳过了 build 后的修复步骤,直接用 tsc 输出的产物部署,tsc 不修改 import 路径。

解决方案

1. TypeScript 源码中写 .js 后缀

TypeScript 官方推荐:即使在 .ts 文件中,也写 .js 后缀:

// ✅ TypeScript 源码中也写 .js
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Build 后自动修复(推荐)

在 build 流程中添加后缀修复脚本,自动为没有扩展名的 import 补上 .js

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

fix-imports.js 核心逻辑:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// 修复动态 import: from 'xxx' → from 'xxx.js'
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// 修复静态 import: from 'xxx' → from 'xxx.js'
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

build 流程自动为所有相对路径 import 补全 .js 后缀,TypeScript 源码保持无后缀写法,ESM 部署不再出现模块找不到的错误。

注意事项

  • 此问题只在 Node.js ESM 模式("type": "module".mjs 文件)下出现,CommonJS 不受影响
  • 静态 import(import ... from './foo')同样受此限制,不仅限于动态 import();ESM 的 import 提升还会导致另一个常见问题——dotenv 在 import 链之后才执行,环境变量读不到
  • 使用 tsxts-node 开发时不报错(它们会自动解析后缀),但 node dist/server.js 生产运行时出错
  • PM2 崩溃循环的典型特征:重启计数(↺)持续增长,每次 uptime 不超过几分钟