feat(阶段5): 让 bun build --compile 的产物真正自足
编译产物拿到没有 node_modules 的目录里跑,原来是一路崩的 —— 而且**在仓库
目录里跑时全都正常**,因为它顺着 cwd 找到了 node_modules,假装没事。
这类问题只会在服务器上第一次启动时暴露。逐个堵掉:
- `import.meta.dir` 在二进制里恒为 `/$bunfs/root`,往上三级就是文件系统根:
`data/test_case` 悄悄变成 `/data/test_case`,`.env` 去读 `/.env`。
新增 runtime.ts 显式分叉:编译后按 cwd,开发时按仓库根(后者不能改,
compose 挂给判题沙箱的是仓库根的 data/test_case)。
- sql.js / tree-sitter 的 wasm、jieba 的 .node 和 4.8MB 词典,
原来都靠运行时 require.resolve / Bun.resolveSync / __dirname 去 node_modules 里找。
全部改成 `with { type: "file" }` 内嵌成资源。jieba 尤其绕:它的 index.js
运行时探测平台再 require 子包,dict.js 用 __dirname 读 dict.txt,两条都
依赖磁盘布局,所以单独包了 vendor/jieba.ts 直接 require 内嵌的 .node。
- SQL 判题要 spawn 一个能被 SIGKILL 的子进程,原来 spawn 的是 child.ts 的路径,
编译后那个文件不存在。改成二进制自己按 argv 分发:新增 main.ts 作为唯一入口,
serve / worker / sql-child 三个子命令,镜像里只需要一份运行时。
## 顺带修掉一个能拖垮生产的坑
「spawn 自己」意味着只要 argv 分发这一环出问题(子命令改名没同步、compose 里
command 写错、拿别的入口编了二进制),「起自己」就变成「把整个程序再跑一遍」,
而那一遍又会 spawn 一个自己 —— 指数增长。
这不是假想:开发时用一个没有分发器的临时入口编了个二进制,一跑就递归 fork
105MB 的进程,几秒触发 global OOM,内核把开发机的终端杀了
(`selftest invoked oom-killer ... Killed process ... Alacritty`)。
同样的错误发生在服务器上就是判题机连同数据库一起拖死。
加了递归闸:父进程 spawn 时打 OJ2_SQL_CHILD 标记,带标记的进程一律拒绝再 spawn,
最多一层就停在一条明确的 SYSTEM_ERROR 上。
## 验证
产物拷进只有它自己一个文件的目录,2G 内存上限下跑,7 项全过:
jieba 分词(自定义词「循环结构」「死循环」命中)、tree-sitter Python3/C 各两条
(含一条**预期失败**的规则做对照,否则「语言没加载成功」和「规则通过」返回值
一模一样,分不出来)、SQL 判题 AC、SQL 只读防护仍拦住 PRAGMA。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --parallel dev:http dev:worker",
|
||||
"dev:http": "bun --watch src/index.ts",
|
||||
"dev:worker": "bun --watch src/worker.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"worker": "bun src/worker.ts",
|
||||
"dev:http": "bun --watch src/main.ts serve",
|
||||
"dev:worker": "bun --watch src/main.ts worker",
|
||||
"start": "bun src/main.ts serve",
|
||||
"worker": "bun src/main.ts worker",
|
||||
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
|
||||
"seed:dev": "bun src/scripts/seed-dev.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:pull": "drizzle-kit pull"
|
||||
|
||||
24
apps/api/src/assets.d.ts
vendored
Normal file
24
apps/api/src/assets.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* `import path from "./x.wasm" with { type: "file" }` 的类型声明。
|
||||
*
|
||||
* 这个写法让 `bun build --compile` 把文件内嵌进单二进制,运行时拿到的是
|
||||
* `/$bunfs/root/...` 下的可读路径;不编译时拿到的是磁盘上的真实路径。
|
||||
* 二进制必须自足,不能在运行时去 node_modules 里找 —— 详见 vendor/jieba.ts 的注释。
|
||||
*
|
||||
* Bun 的类型里没覆盖这些扩展名,这里补上。值都是**文件路径字符串**。
|
||||
*/
|
||||
|
||||
declare module "*.wasm" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
|
||||
declare module "*.node" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
|
||||
declare module "*.txt" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
@@ -2,16 +2,22 @@ import { randomBytes } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
|
||||
import { isCompiled, pathBase } from "./runtime"
|
||||
|
||||
/**
|
||||
* Bun 只自动加载「当前工作目录」下的 .env。而本应用的启动方式(`bun run --filter '@oj2/api' dev`)
|
||||
* 会把 cwd 切到 apps/api/,于是仓库根的 .env 读不到 —— 而 .env.example 恰恰教人写在根目录。
|
||||
* 这里显式补读仓库根的 .env,让文档指引真正生效,且不管从哪个目录启动都一致。
|
||||
*
|
||||
* 只填充尚未设置的键:真实环境变量与 cwd 下的 .env 优先级更高,不被覆盖。
|
||||
*
|
||||
* 编译成单二进制后不做这件事:生产靠 compose 注入环境变量,而 `import.meta.dir` 在
|
||||
* 二进制里是 `/$bunfs/root`,往上三级会去读 `/.env` —— 读到什么都是意外。
|
||||
*/
|
||||
function loadRepoRootEnv() {
|
||||
if (isCompiled) return
|
||||
try {
|
||||
const text = readFileSync(resolve(import.meta.dir, "../../../.env"), "utf8")
|
||||
const text = readFileSync(resolve(pathBase, ".env"), "utf8")
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
@@ -35,15 +41,12 @@ loadRepoRootEnv()
|
||||
* 也不要在仓库里写死一个人人都知道的弱默认值。
|
||||
*/
|
||||
/**
|
||||
* 相对路径一律按**仓库根**解析,而不是进程 cwd。
|
||||
* 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。
|
||||
*
|
||||
* 起服务的方式(`bun run --filter '@oj2/api' dev`)会把 cwd 切到 apps/api/,
|
||||
* 于是 "data/test_case" 落在 apps/api/data/ 下 —— 而 docker/compose.dev.yml 把
|
||||
* 仓库根的 data/test_case 挂进判题沙箱。两边不是同一个目录,新传的测试点判题时
|
||||
* 会「找不到测试数据」,而且只在真正判题时才暴露。
|
||||
* 生产环境**应当**用绝对路径的环境变量把这些目录显式指定掉,相对路径只是开发便利。
|
||||
*/
|
||||
function repoPath(value: string) {
|
||||
return isAbsolute(value) ? value : resolve(import.meta.dir, "../../..", value)
|
||||
return isAbsolute(value) ? value : resolve(pathBase, value)
|
||||
}
|
||||
|
||||
function judgeServerToken() {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Language, Parser, type Node } from "web-tree-sitter"
|
||||
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
||||
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
||||
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" }
|
||||
import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
|
||||
// web-tree-sitter 自己的运行时 wasm,Parser.init() 要用
|
||||
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
|
||||
|
||||
export interface AstRule {
|
||||
engine?: string
|
||||
@@ -66,16 +72,15 @@ const languages = new Map<string, Language>()
|
||||
|
||||
async function loadLanguage(language: string) {
|
||||
if (!(language in mappings)) return null
|
||||
if (!initPromise) initPromise = Parser.init()
|
||||
// locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找,
|
||||
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
|
||||
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
|
||||
await initPromise
|
||||
|
||||
const cached = languages.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const packageName = language === "C" ? "tree-sitter-c" : "tree-sitter-python"
|
||||
const wasmName = language === "C" ? "tree-sitter-c.wasm" : "tree-sitter-python.wasm"
|
||||
const wasmPath = Bun.resolveSync(`${packageName}/${wasmName}`, import.meta.dir)
|
||||
const loaded = await Language.load(wasmPath)
|
||||
const loaded = await Language.load(language === "C" ? cWasmPath : pythonWasmPath)
|
||||
languages.set(language, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
* 父进程见 `./index.ts`。
|
||||
*
|
||||
* 协议:stdin 读一段 JSON 作业,stdout 写一段 JSON 结果;
|
||||
* 阶段标记写 stderr,父进程在超时杀掉本进程后据此判断卡在哪一阶段。
|
||||
* 阶段标记写 stderr,父进程据此收紧兜底时限、并判断超时该算谁的。
|
||||
*
|
||||
* 这个文件**不是**独立入口,而是由 `src/main.ts` 的 `sql-child` 子命令调用。
|
||||
* 原因:`bun build --compile` 之后磁盘上没有 child.ts 可以让父进程去 spawn,
|
||||
* 只能让二进制自己按 argv 分发到这里。
|
||||
*/
|
||||
|
||||
import { writeSync } from "node:fs"
|
||||
@@ -60,7 +64,8 @@ function finish(payload: unknown): never {
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
/** 子进程入口。由 `src/main.ts` 的 `sql-child` 子命令调用,不在导入时自动执行 */
|
||||
export async function runSqlChild() {
|
||||
const raw = await new Response(Bun.stdin.stream()).text()
|
||||
const job = JSON.parse(raw) as SqlJob
|
||||
try {
|
||||
@@ -93,5 +98,3 @@ async function main() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
|
||||
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
|
||||
import sqlWasmPath from "sql.js/dist/sql-wasm.wasm" with { type: "file" }
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||
@@ -56,7 +57,9 @@ let cached: SqlJsStatic | null = null
|
||||
|
||||
export async function sqlEngine() {
|
||||
if (cached) return cached
|
||||
const binary = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm"))
|
||||
// 内嵌成资源而非运行时 require.resolve —— 后者在 `bun build --compile` 之后
|
||||
// 只有在仓库目录里才碰巧能解析出来,换个目录就 Cannot find module。见 vendor/jieba.ts
|
||||
const binary = readFileSync(sqlWasmPath)
|
||||
// @types/sql.js 把 wasmBinary 标成 ArrayBuffer,实际 emscripten 接受 TypedArray;
|
||||
// 这里传 Uint8Array 是运行时正确的写法,类型上断言掉
|
||||
cached = await initSqlJs({ wasmBinary: binary as unknown as ArrayBuffer })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolve } from "node:path"
|
||||
|
||||
import { selfCommand } from "../../runtime"
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
|
||||
import type { SqlJob } from "./child"
|
||||
@@ -33,6 +32,24 @@ import type { SqlJob } from "./child"
|
||||
* 卡在 student 才是学生超时(TLE)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 递归闸的标记环境变量。
|
||||
*
|
||||
* ## 为什么必须有这道闸
|
||||
*
|
||||
* 这里 spawn 的是**进程自己**(`process.execPath` + 子命令)。正常情况下入口的
|
||||
* argv 分发会把它引到 `runSqlChild()`,跑完就退出。但只要分发这一环出问题 ——
|
||||
* 子命令改了名没同步、compose 里 command 写错、有人拿别的入口编了个二进制 ——
|
||||
* 「起自己」就变成「把整个程序再跑一遍」,而那一遍又会 spawn 一个自己,指数增长。
|
||||
*
|
||||
* 这不是假想:开发阶段用一个没有 argv 分发的临时入口编了个二进制,一跑就是
|
||||
* 递归 fork 105MB 的进程,几秒内触发 global OOM,内核把开发机的终端杀了。
|
||||
* 同样的错误发生在服务器上就是判题机连同数据库一起被拖死。
|
||||
*
|
||||
* 闸的逻辑很简单:父进程 spawn 时打上这个标记,带标记的进程一律拒绝再 spawn。
|
||||
* 递归最多一层就停在一条明确的 SYSTEM_ERROR 上,而不是吃光机器。
|
||||
*/
|
||||
const CHILD_MARKER = "OJ2_SQL_CHILD"
|
||||
/** 子进程数据段上限(KB)。低于 512MB Bun 自己起不来 */
|
||||
const CHILD_DATA_LIMIT_KB = 512 * 1024
|
||||
/** 进程启动 + WASM 初始化 + JSON 收发的余量 */
|
||||
@@ -70,11 +87,28 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
|
||||
}
|
||||
|
||||
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
|
||||
const entry = resolve(import.meta.dir, "child.ts")
|
||||
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
|
||||
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
|
||||
if (process.env[CHILD_MARKER]) {
|
||||
return {
|
||||
ok: false,
|
||||
result: JudgeStatus.SYSTEM_ERROR,
|
||||
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
|
||||
}
|
||||
}
|
||||
|
||||
// 起的是「自己」:开发时是 bun + main.ts,编译后就是二进制自身,见 runtime.ts。
|
||||
// 不能写死 child.ts 的路径 —— 单二进制里那个文件根本不存在。
|
||||
const self = selfCommand("sql-child")
|
||||
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口。
|
||||
// "$@" 原样透传,免得路径里有空格时被词法拆开。
|
||||
const child = Bun.spawn(
|
||||
["sh", "-c", `ulimit -d ${CHILD_DATA_LIMIT_KB}; exec "$0" run "$1"`, process.execPath, entry],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
["sh", "-c", `ulimit -d ${CHILD_DATA_LIMIT_KB}; exec "$@"`, "sh", ...self],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, [CHILD_MARKER]: "1" },
|
||||
},
|
||||
)
|
||||
child.stdin.write(JSON.stringify(job))
|
||||
await child.stdin.end()
|
||||
|
||||
37
apps/api/src/main.ts
Normal file
37
apps/api/src/main.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 唯一入口。所有角色都从这里按子命令分叉。
|
||||
*
|
||||
* 为什么不保留三个独立入口文件:`bun build --compile` 一次只产出一个二进制,
|
||||
* 而部署要跑 HTTP 服务、判题 worker,SQL 判题还要 fork 一个能被 SIGKILL 的子进程
|
||||
* (见 judge/sql/index.ts)。三个入口就得编三个二进制、镜像里塞三份运行时。
|
||||
* 一个二进制 + 子命令,镜像里只有一份,compose 里改 command 就能换角色。
|
||||
*
|
||||
* oj2-api # 等同 serve
|
||||
* oj2-api serve # HTTP + WebSocket
|
||||
* oj2-api worker # BullMQ 判题消费者
|
||||
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
||||
*
|
||||
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
|
||||
* (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
|
||||
*/
|
||||
|
||||
export {} // 只有动态 import 的话 TS 不认这是模块,顶层 await 会报错
|
||||
|
||||
const command = process.argv[2] ?? "serve"
|
||||
|
||||
switch (command) {
|
||||
case "serve":
|
||||
await import("./index")
|
||||
break
|
||||
case "worker":
|
||||
await import("./worker")
|
||||
break
|
||||
case "sql-child": {
|
||||
const { runSqlChild } = await import("./judge/sql/child")
|
||||
await runSqlChild()
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`未知子命令:${command}\n可用:serve | worker | sql-child`)
|
||||
process.exit(2)
|
||||
}
|
||||
34
apps/api/src/runtime.ts
Normal file
34
apps/api/src/runtime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { resolve } from "node:path"
|
||||
|
||||
/**
|
||||
* 运行形态的判定:开发时是 `bun src/main.ts`,生产是 `bun build --compile` 出来的单二进制。
|
||||
*
|
||||
* 两者最要命的差别是**路径**。编译产物里 `import.meta.dir` 恒为 `/$bunfs/root`
|
||||
* (Bun 把内嵌文件摊在那个虚拟目录下),于是任何 `resolve(import.meta.dir, "../..")`
|
||||
* 都会指到文件系统根:`data/test_case` 变成 `/data/test_case`。这种错误不会报错,
|
||||
* 只会安静地读写错地方,所以必须显式分叉,不能靠"相对路径反正差不多"。
|
||||
*/
|
||||
export const isCompiled = import.meta.dir.startsWith("/$bunfs")
|
||||
|
||||
/**
|
||||
* 起一个「自己」的子进程时该用的命令。SQL 判题要 fork 一个可被 SIGKILL 的子进程,
|
||||
* 见 judge/sql/index.ts。
|
||||
*
|
||||
* - 编译后:二进制自己就是入口,`[binary, "sql-child"]`
|
||||
* - 开发时:`process.execPath` 是 bun,得把入口脚本一起带上,`[bun, main.ts, "sql-child"]`
|
||||
*/
|
||||
export function selfCommand(subcommand: string): string[] {
|
||||
if (isCompiled) return [process.execPath, subcommand]
|
||||
return [process.execPath, resolve(import.meta.dir, "main.ts"), subcommand]
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对路径的解析基准。
|
||||
*
|
||||
* - 编译后:按进程 cwd 解析。容器里 workdir 固定,且这些目录本来就该由环境变量显式给出,
|
||||
* cwd 只是最后的兜底。
|
||||
* - 开发时:按仓库根解析。因为 `bun run --filter '@oj2/api' dev` 会把 cwd 切到 apps/api/,
|
||||
* 而 docker/compose.dev.yml 挂给判题沙箱的是**仓库根**的 data/test_case —— 按 cwd 解析
|
||||
* 就会落到 apps/api/data/ 下,两边不是同一个目录,新传的测试点判题时报「找不到测试数据」。
|
||||
*/
|
||||
export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, "../../..")
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Jieba } from "@node-rs/jieba"
|
||||
import { dict } from "@node-rs/jieba/dict"
|
||||
// 不直接 import @node-rs/jieba:它的运行时平台探测和 __dirname 词典加载
|
||||
// 在 `bun build --compile` 之后都失效,原因见 vendor/jieba.ts
|
||||
import { withBuiltinDict, type JiebaInstance } from "../vendor/jieba"
|
||||
|
||||
/**
|
||||
* 流程图评语词云的分词。对齐旧后端 `flowchart/views/admin.py` 的
|
||||
@@ -35,11 +36,11 @@ const CUSTOM_WORDS = [
|
||||
* 词典加载有一次性开销(约 100ms),放在模块级会拖慢 API 冷启动,
|
||||
* 而词云只有教师偶尔点一次。改成首次调用时才建。
|
||||
*/
|
||||
let instance: Jieba | null = null
|
||||
let instance: JiebaInstance | null = null
|
||||
|
||||
function jieba() {
|
||||
if (instance) return instance
|
||||
const built = Jieba.withDict(dict)
|
||||
const built = withBuiltinDict()
|
||||
// 对应旧后端的 jieba.add_word(w, freq=9999)。
|
||||
// @node-rs/jieba@2 没有导出 insertWord/addWord,改用用户词典缓冲区,格式为「词 词频」。
|
||||
built.loadDict(
|
||||
|
||||
55
apps/api/src/vendor/jieba.ts
vendored
Normal file
55
apps/api/src/vendor/jieba.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 直接加载 @node-rs/jieba 的原生模块和词典,绕开这个包自己的加载逻辑。
|
||||
*
|
||||
* 为什么不老老实实 `import { Jieba } from "@node-rs/jieba"`:
|
||||
*
|
||||
* - 它的 `index.js` 在**运行时**探测平台再 `require` 对应的子包;
|
||||
* - 它的 `dict.js` 用 `__dirname` 去读同目录的 `dict.txt`。
|
||||
*
|
||||
* 这两件事在 `bun build --compile` 之后都不成立 —— 编译产物里 `__dirname` 和模块
|
||||
* 解析根都是 `/$bunfs/root`,子包和 dict.txt 都不在那儿。实测编译后直接报
|
||||
* `Failed to load native binding`,而且**只在离开仓库目录后才报**(在仓库里跑时
|
||||
* 它顺着 cwd 找到了 node_modules,假装没事),是那种在服务器上才炸的坑。
|
||||
*
|
||||
* 改成把 `.node` 和 `dict.txt` 用 `with { type: "file" }` 内嵌成资源:编译时它们被
|
||||
* 塞进二进制,运行时 Bun 把它们摊到 `/$bunfs/root/` 下,`require()` 和 `readFileSync`
|
||||
* 都能正常拿到。开发模式(不编译)下这个写法拿到的就是 node_modules 里的真实路径,
|
||||
* 两种模式同一份代码。
|
||||
*
|
||||
* 平台写死 linux-x64-gnu:部署目标是 debian 基底的容器,本机开发也是 x64 glibc。
|
||||
* 换平台(比如改用 alpine/musl 基底镜像)必须同步改这里的 import,否则编译能过、
|
||||
* 启动就崩 —— 所以下面加了显式的错误提示。
|
||||
*/
|
||||
|
||||
import addonPath from "@node-rs/jieba-linux-x64-gnu/jieba.linux-x64-gnu.node" with { type: "file" }
|
||||
import dictPath from "@node-rs/jieba/dict.txt" with { type: "file" }
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
export interface JiebaInstance {
|
||||
cut(text: string, hmm?: boolean): string[]
|
||||
loadDict(dict: Buffer): void
|
||||
}
|
||||
|
||||
interface JiebaAddon {
|
||||
Jieba: { withDict(dict: Buffer): JiebaInstance }
|
||||
}
|
||||
|
||||
let addon: JiebaAddon | null = null
|
||||
|
||||
function loadAddon(): JiebaAddon {
|
||||
if (addon) return addon
|
||||
try {
|
||||
addon = require(addonPath as unknown as string) as JiebaAddon
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`加载 jieba 原生模块失败(${addonPath})。若已更换容器基底或 CPU 架构,` +
|
||||
`需同步修改 src/vendor/jieba.ts 里写死的 linux-x64-gnu 导入。原始错误:${String(error)}`,
|
||||
)
|
||||
}
|
||||
return addon
|
||||
}
|
||||
|
||||
/** 内置词典(dict.txt,约 4.8MB)。idf.txt 用不到,不内嵌,省二进制体积 */
|
||||
export function withBuiltinDict(): JiebaInstance {
|
||||
return loadAddon().Jieba.withDict(readFileSync(dictPath as unknown as string))
|
||||
}
|
||||
Reference in New Issue
Block a user