ehbrowser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +531 -0
  3. package/dist/api/contract.js +7 -0
  4. package/dist/api/dto/auth.js +5 -0
  5. package/dist/api/dto/common.js +6 -0
  6. package/dist/api/dto/download.js +5 -0
  7. package/dist/api/dto/favorite.js +5 -0
  8. package/dist/api/dto/gallery.js +43 -0
  9. package/dist/api/dto/index.js +6 -0
  10. package/dist/api/dto/library.js +5 -0
  11. package/dist/api/dto/log.js +5 -0
  12. package/dist/api/dto/playlist.js +8 -0
  13. package/dist/api/dto/settings.js +5 -0
  14. package/dist/api/dto/storage.js +5 -0
  15. package/dist/api/dto/translate.js +6 -0
  16. package/dist/api/envelope.js +41 -0
  17. package/dist/api/events.js +20 -0
  18. package/dist/api/index.js +12 -0
  19. package/dist/api/routes.js +516 -0
  20. package/dist/config/atomic.js +85 -0
  21. package/dist/config/auth-setting.js +97 -0
  22. package/dist/config/cli.js +19 -0
  23. package/dist/config/db.js +168 -0
  24. package/dist/config/index.js +48 -0
  25. package/dist/config/json-store.js +219 -0
  26. package/dist/config/migrations.js +100 -0
  27. package/dist/config/schema.js +108 -0
  28. package/dist/config/user-setting.js +21 -0
  29. package/dist/config/validate.js +173 -0
  30. package/dist/eh/api.js +106 -0
  31. package/dist/eh/archives.js +100 -0
  32. package/dist/eh/auth.js +89 -0
  33. package/dist/eh/cookies.js +65 -0
  34. package/dist/eh/favorites.js +94 -0
  35. package/dist/eh/html.js +158 -0
  36. package/dist/eh/http.js +214 -0
  37. package/dist/eh/index.js +13 -0
  38. package/dist/eh/types.js +11 -0
  39. package/dist/eh/urls.js +81 -0
  40. package/dist/main.js +170 -0
  41. package/dist/platform/errors.js +76 -0
  42. package/dist/platform/open-external.js +90 -0
  43. package/dist/platform/os.js +28 -0
  44. package/dist/platform/paths.js +140 -0
  45. package/dist/server.js +1074 -0
  46. package/dist/services/auth-service.js +276 -0
  47. package/dist/services/config-service.js +227 -0
  48. package/dist/services/detail-cache.js +193 -0
  49. package/dist/services/detail-store.js +208 -0
  50. package/dist/services/download-file.js +127 -0
  51. package/dist/services/download-service.js +586 -0
  52. package/dist/services/favorite-service.js +237 -0
  53. package/dist/services/gallery-service.js +403 -0
  54. package/dist/services/local-library.js +471 -0
  55. package/dist/services/log-service.js +139 -0
  56. package/dist/services/playlist-service.js +85 -0
  57. package/dist/services/search-cache.js +78 -0
  58. package/dist/services/storage-service.js +27 -0
  59. package/dist/services/translate-service.js +208 -0
  60. package/dist/services/update-service.js +268 -0
  61. package/dist/services/upstream-service.js +64 -0
  62. package/dist/services/zip.js +210 -0
  63. package/dist/web/assets/index-C0tAHpmx.css +1 -0
  64. package/dist/web/assets/index-myYEnJ1q.js +4 -0
  65. package/dist/web/index.html +13 -0
  66. package/package.json +63 -0
@@ -0,0 +1,85 @@
1
+ /*
2
+ * 落盘。
3
+ * 流程:临时文件 -> fsync -> rename -> chmod -> fsync 目录。
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
7
+ import { basename, dirname, join } from "node:path";
8
+ export async function ensureDir(dir, mode = 0o700) {
9
+ await mkdir(dir, { recursive: true, mode });
10
+ }
11
+ /** 文件不存在时返回 null */
12
+ export async function readTextIfExists(file) {
13
+ try {
14
+ return await readFile(file, "utf8");
15
+ }
16
+ catch (error) {
17
+ if (isNotFound(error)) {
18
+ return null;
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ export function isNotFound(error) {
24
+ return (typeof error === "object" &&
25
+ error !== null &&
26
+ error.code === "ENOENT");
27
+ }
28
+ /**
29
+ * 原子写。临时文件必须与目标文件同目录,跨分区 rename 不再具有原子性。
30
+ * mode 未传入时使用 0o600。
31
+ */
32
+ export async function writeFileAtomic(file, text, options = {}) {
33
+ const dir = dirname(file);
34
+ const mode = options.mode ?? 0o600;
35
+ await ensureDir(dir);
36
+ const tmp = join(dir, `.${basename(file)}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`);
37
+ const handle = await open(tmp, "w", mode);
38
+ try {
39
+ await handle.writeFile(text, "utf8");
40
+ // 写入的内容先落盘,随后执行 sync
41
+ await handle.sync();
42
+ }
43
+ catch (error) {
44
+ await handle.close().catch(() => undefined);
45
+ await unlink(tmp).catch(() => undefined);
46
+ throw error;
47
+ }
48
+ await handle.close();
49
+ try {
50
+ await rename(tmp, file);
51
+ }
52
+ catch (error) {
53
+ await unlink(tmp).catch(() => undefined);
54
+ throw error;
55
+ }
56
+ // 部分文件系统在 rename 后权限会跟随临时文件,这里显式重设权限
57
+ await chmod(file, mode);
58
+ await syncDir(dir);
59
+ }
60
+ /** 将损坏文件移到一边,返回移动后的位置 */
61
+ export async function moveAside(file, suffix) {
62
+ const target = `${file}.${suffix}`;
63
+ await rename(file, target);
64
+ return target;
65
+ }
66
+ /** 备份与损坏文件用的时间戳,形如 20260919-013045 */
67
+ export function timeStamp(date = new Date()) {
68
+ const pad = (n) => String(n).padStart(2, "0");
69
+ return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
70
+ `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
71
+ }
72
+ async function syncDir(dir) {
73
+ try {
74
+ const handle = await open(dir, "r");
75
+ try {
76
+ await handle.sync();
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ }
82
+ catch {
83
+ // 目录 fsync 失败时忽略该错误
84
+ }
85
+ }
@@ -0,0 +1,97 @@
1
+ /*
2
+ * auth_setting.json 的接线与账号数据操作。
3
+ * 文件包含 cookie:权限 0600、不进入日志、对外输出前脱敏。
4
+ * 本层仅做数据操作(增删账号、切换激活);业务规则属于 services。
5
+ */
6
+ import { join } from "node:path";
7
+ import { getPaths } from "../platform/paths.js";
8
+ import { createJsonStore } from "./json-store.js";
9
+ import { AUTH_SETTING_MIGRATIONS } from "./migrations.js";
10
+ import { AUTH_SETTING_DEFAULTS, AUTH_SETTING_FIELDS, } from "./schema.js";
11
+ export const AUTH_SETTING_FILENAME = "auth_setting.json";
12
+ export function authSettingFile(configDir = getPaths().configDir) {
13
+ return join(configDir, AUTH_SETTING_FILENAME);
14
+ }
15
+ export function createAuthSettingStore(options = {}) {
16
+ return createJsonStore({
17
+ file: options.file ?? authSettingFile(),
18
+ defaults: AUTH_SETTING_DEFAULTS,
19
+ fields: AUTH_SETTING_FIELDS,
20
+ migrations: AUTH_SETTING_MIGRATIONS,
21
+ mode: 0o600,
22
+ logger: options.logger,
23
+ });
24
+ }
25
+ /** cookie 是否完整。结构校验属于 schema,此处仅检查取值 */
26
+ export function isCompleteCookies(cookies) {
27
+ return (cookies.ipbMemberId.trim() !== "" &&
28
+ cookies.ipbPassHash.trim() !== "" &&
29
+ cookies.igneous.trim() !== "");
30
+ }
31
+ /** igneous 有效期;超过一半视为临近过期 */
32
+ export const IGNEOUS_TTL_SECONDS = 30 * 24 * 60 * 60;
33
+ /** 已存在的天数,无记录返回 null */
34
+ export function igneousAgeDays(account, now = Date.now()) {
35
+ if (account === null || account.igneousUpdatedAt === null) {
36
+ return null;
37
+ }
38
+ return Math.floor((now / 1000 - account.igneousUpdatedAt) / 86400);
39
+ }
40
+ /** 超过半个有效期即视为临近过期 */
41
+ export function isIgneousStale(account, now = Date.now()) {
42
+ const age = igneousAgeDays(account, now);
43
+ if (age === null) {
44
+ return false;
45
+ }
46
+ return age * 86400 > IGNEOUS_TTL_SECONDS * 0.5;
47
+ }
48
+ /** 未指定时取第一个账号 */
49
+ export function activeAccount(auth) {
50
+ if (auth.activeAccountId === null) {
51
+ return auth.accounts[0] ?? null;
52
+ }
53
+ return auth.accounts.find((item) => item.id === auth.activeAccountId) ?? null;
54
+ }
55
+ /** 对外摘要;输出到服务端的字段仅限这些 */
56
+ export function summarizeAccount(auth, account) {
57
+ return {
58
+ id: account.id,
59
+ label: account.label,
60
+ site: account.site,
61
+ active: activeAccount(auth)?.id === account.id,
62
+ igneousUpdatedAt: account.igneousUpdatedAt,
63
+ hasApiKey: account.apiKey !== undefined,
64
+ };
65
+ }
66
+ /** 日志用;cookie 仅保留长度 */
67
+ export function redactAuth(auth) {
68
+ return {
69
+ schemaVersion: auth.schemaVersion,
70
+ activeAccountId: auth.activeAccountId,
71
+ accounts: auth.accounts.map((account) => ({
72
+ id: account.id,
73
+ label: account.label,
74
+ site: account.site,
75
+ igneousLength: account.cookies.igneous.length,
76
+ igneousUpdatedAt: account.igneousUpdatedAt,
77
+ })),
78
+ proxyAuth: auth.proxyAuth.username === "" ? "(空)" : "(有)",
79
+ };
80
+ }
81
+ /** 已存在时替换,否则追加 */
82
+ export function upsertAccount(accounts, account) {
83
+ const index = accounts.findIndex((item) => item.id === account.id);
84
+ if (index === -1) {
85
+ return [...accounts, account];
86
+ }
87
+ const next = [...accounts];
88
+ next[index] = account;
89
+ return next;
90
+ }
91
+ export function removeAccount(accounts, id) {
92
+ return accounts.filter((item) => item.id !== id);
93
+ }
94
+ /** 随机标识;用户名可重复且可变更,不适合作为 id */
95
+ export function newAccountId() {
96
+ return `acc_${Math.random().toString(36).slice(2, 10)}`;
97
+ }
@@ -0,0 +1,19 @@
1
+ /* npm run config:path:打印生效的三条目录与文件状态 */
2
+ import { existsSync } from "node:fs";
3
+ import { databaseFile } from "./db.js";
4
+ import { describePaths, initPaths } from "../platform/paths.js";
5
+ import { authSettingFile } from "./auth-setting.js";
6
+ import { userSettingFile } from "./user-setting.js";
7
+ const paths = initPaths();
8
+ console.log(describePaths(paths));
9
+ console.log();
10
+ const files = [
11
+ userSettingFile(paths.configDir),
12
+ authSettingFile(paths.configDir),
13
+ databaseFile(paths.dataDir),
14
+ ];
15
+ for (const file of files) {
16
+ console.log(`${existsSync(file) ? "有" : "无"} ${file}`);
17
+ }
18
+ console.log();
19
+ console.log("优先级:EHBROWSER_CONFIG_DIR 等 > EHBROWSER_HOME > 系统默认");
@@ -0,0 +1,168 @@
1
+ /*
2
+ * 机器写数据库:程序标记、画廊缓存、下载任务、每画廊读到第几页。
3
+ * 每次开库设置三个 pragma:WAL、busy_timeout、foreign_keys(默认关闭)。
4
+ * 表结构版本由 PRAGMA user_version 记录。
5
+ */
6
+ import { join } from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ export const DB_FILENAME = "ehbrowser.db";
9
+ export const DB_VERSION = 4;
10
+ export function databaseFile(dataDir) {
11
+ return join(dataDir, DB_FILENAME);
12
+ }
13
+ // 每版一段,新版本追加,历史段落不变
14
+ const MIGRATIONS = [
15
+ `
16
+ create table if not exists meta (
17
+ key text primary key,
18
+ value text not null
19
+ );
20
+
21
+ create table if not exists galleries (
22
+ gid integer primary key,
23
+ token text not null,
24
+ title text not null default '',
25
+ title_jpn text not null default '',
26
+ category text not null default '',
27
+ thumb_url text not null default '',
28
+ uploader text not null default '',
29
+ posted_at integer,
30
+ page_count integer,
31
+ size_bytes integer,
32
+ rating real,
33
+ torrent_count integer,
34
+ expunged integer not null default 0,
35
+ language text,
36
+ tags_json text not null default '[]',
37
+ fetched_at integer not null
38
+ );
39
+
40
+ create table if not exists downloads (
41
+ id text primary key,
42
+ gid integer not null,
43
+ token text not null,
44
+ title text not null default '',
45
+ resolution text not null default 'org',
46
+ status text not null default 'queued',
47
+ bytes_done integer not null default 0,
48
+ bytes_total integer,
49
+ output_path text,
50
+ error text,
51
+ created_at integer not null,
52
+ updated_at integer not null
53
+ );
54
+
55
+ -- 每画廊读到第几页
56
+ create table if not exists reading_progress (
57
+ gid integer primary key,
58
+ page integer not null default 1,
59
+ updated_at integer not null
60
+ );
61
+
62
+ create index if not exists idx_downloads_status on downloads (status, created_at);
63
+ create index if not exists idx_galleries_fetched on galleries (fetched_at);
64
+ `,
65
+ // v2:用户播放列表。需要持久化,因此存放在数据库中,而不是内存或配置文件里
66
+ `
67
+ create table if not exists playlist_items (
68
+ key text primary key,
69
+ position integer not null,
70
+ gid integer not null,
71
+ token text not null,
72
+ title text not null default '',
73
+ thumb_url text not null default '',
74
+ page_count integer not null default 0,
75
+ resolution text,
76
+ page integer not null default 1,
77
+ bytes_per_page integer not null default 0,
78
+ added_at integer not null,
79
+ updated_at integer not null
80
+ );
81
+
82
+ create index if not exists idx_playlist_position on playlist_items (position);
83
+ `,
84
+ // v3:本地收藏夹与收藏项。云端分类只是槽位号,条目本身保存在本地
85
+ `
86
+ create table if not exists favorite_folders (
87
+ id text primary key,
88
+ name text not null,
89
+ position integer not null default 0,
90
+ created_at integer not null
91
+ );
92
+
93
+ create table if not exists favorite_items (
94
+ folder_id text not null,
95
+ gid integer not null,
96
+ token text not null,
97
+ title text not null default '',
98
+ thumb_url text not null default '',
99
+ page_count integer not null default 0,
100
+ -- 云端分类槽位;-1 表示只在本地、没有对应的云端收藏
101
+ slot integer not null default -1,
102
+ added_at integer not null,
103
+ primary key (folder_id, gid)
104
+ );
105
+
106
+ create index if not exists idx_favorite_items_folder on favorite_items (folder_id, added_at);
107
+ `,
108
+ // v4:逐页下载(游客也可下载)需要记录「第几页 / 共几页」,归档任务这两列为空
109
+ `
110
+ alter table downloads add column pages_done integer not null default 0;
111
+ alter table downloads add column page_count integer;
112
+ `,
113
+ ];
114
+ export function openDatabase(file) {
115
+ const db = new DatabaseSync(file);
116
+ db.exec("pragma journal_mode = WAL");
117
+ db.exec("pragma busy_timeout = 5000");
118
+ db.exec("pragma foreign_keys = ON");
119
+ migrate(db);
120
+ return {
121
+ file,
122
+ raw: db,
123
+ getMeta(key) {
124
+ const row = db.prepare("select value from meta where key = ?").get(key);
125
+ return row?.value ?? null;
126
+ },
127
+ setMeta(key, value) {
128
+ db.prepare("insert into meta (key, value) values (?, ?) on conflict(key) do update set value = excluded.value").run(key, value);
129
+ },
130
+ deleteMeta(key) {
131
+ db.prepare("delete from meta where key = ?").run(key);
132
+ },
133
+ allMeta() {
134
+ const rows = db.prepare("select key, value from meta").all();
135
+ const out = {};
136
+ for (const row of rows) {
137
+ out[row.key] = row.value;
138
+ }
139
+ return out;
140
+ },
141
+ close() {
142
+ try {
143
+ db.exec("pragma wal_checkpoint(truncate)");
144
+ }
145
+ catch {
146
+ // 合并失败时忽略,下次开库会再次合并
147
+ }
148
+ db.close();
149
+ },
150
+ };
151
+ }
152
+ function migrate(db) {
153
+ const row = db.prepare("pragma user_version").get();
154
+ let version = row?.user_version ?? 0;
155
+ if (version > MIGRATIONS.length) {
156
+ // 数据库版本高于程序支持的版本:读操作正常,写操作存在风险
157
+ throw new Error(`数据库版本 v${version} 高于程序支持的 v${MIGRATIONS.length}`);
158
+ }
159
+ while (version < MIGRATIONS.length) {
160
+ const sql = MIGRATIONS[version];
161
+ if (sql === undefined) {
162
+ break;
163
+ }
164
+ db.exec(sql);
165
+ version += 1;
166
+ db.exec(`pragma user_version = ${version}`);
167
+ }
168
+ }
@@ -0,0 +1,48 @@
1
+ /*
2
+ * config 层的门面。
3
+ */
4
+ import { ensureDirs, getPaths } from "../platform/paths.js";
5
+ import { authSettingFile, createAuthSettingStore } from "./auth-setting.js";
6
+ import { databaseFile, openDatabase } from "./db.js";
7
+ import { createUserSettingStore, userSettingFile } from "./user-setting.js";
8
+ export async function initConfig(options = {}) {
9
+ const paths = options.paths ?? getPaths();
10
+ const logger = options.logger ?? consoleLogger;
11
+ await ensureDirs(paths);
12
+ const user = createUserSettingStore({ file: userSettingFile(paths.configDir), logger });
13
+ const auth = createAuthSettingStore({ file: authSettingFile(paths.configDir), logger });
14
+ // 并行加载;文件损坏时回退默认值,不阻塞启动。
15
+ await Promise.all([user.load(), auth.load()]);
16
+ const db = openDatabase(databaseFile(paths.dataDir));
17
+ db.setMeta("last_started_at", String(Math.floor(Date.now() / 1000)));
18
+ db.setMeta("user_setting_version", String(user.get().schemaVersion));
19
+ return {
20
+ paths,
21
+ user,
22
+ auth,
23
+ db,
24
+ async close() {
25
+ await Promise.all([user.flush(), auth.flush()]);
26
+ db.close();
27
+ },
28
+ };
29
+ }
30
+ const consoleLogger = (level, message, meta) => {
31
+ const line = `[config] ${message}`;
32
+ if (level === "error") {
33
+ console.error(line, meta ?? "");
34
+ }
35
+ else if (level === "warn") {
36
+ console.warn(line, meta ?? "");
37
+ }
38
+ else {
39
+ console.info(line, meta ?? "");
40
+ }
41
+ };
42
+ export { createJsonStore, ConfigInvalidError } from "./json-store.js";
43
+ export { describePaths, getPaths, initPaths, resetPaths } from "../platform/paths.js";
44
+ export * from "./schema.js";
45
+ export { activeAccount, igneousAgeDays, isCompleteCookies, isIgneousStale, newAccountId, redactAuth, removeAccount, summarizeAccount, upsertAccount, } from "./auth-setting.js";
46
+ export { databaseFile, DB_FILENAME, openDatabase } from "./db.js";
47
+ export { createUserSettingStore, USER_SETTING_FILENAME, userSettingFile } from "./user-setting.js";
48
+ export { authSettingFile, AUTH_SETTING_FILENAME, createAuthSettingStore } from "./auth-setting.js";
@@ -0,0 +1,219 @@
1
+ /*
2
+ * 通用 JSON 存储管线:读取、迁移、校验、修复与原子写入
3
+ */
4
+ import { readTextIfExists, moveAside, timeStamp, writeFileAtomic } from "./atomic.js";
5
+ import { readVersion, runMigrations } from "./migrations.js";
6
+ import { collectUnknownPaths, deepMerge, isPlainObject, repairFields, validateFields, } from "./validate.js";
7
+ /** 字段校验失败时抛出,携带字段级问题列表。 */
8
+ export class ConfigInvalidError extends Error {
9
+ issues;
10
+ constructor(issues) {
11
+ super(`配置校验失败:${issues.map((i) => i.path).join("、")}`);
12
+ this.name = "ConfigInvalidError";
13
+ this.issues = issues;
14
+ }
15
+ }
16
+ export function createJsonStore(options) {
17
+ const log = options.logger ?? (() => undefined);
18
+ const listeners = new Set();
19
+ let snapshot = null;
20
+ let queue = Promise.resolve();
21
+ /** 写操作串行队列,避免并发 patch 相互覆盖。 */
22
+ function serialize(task) {
23
+ const next = queue.then(task, task);
24
+ queue = next.then(() => undefined, () => undefined);
25
+ return next;
26
+ }
27
+ async function load() {
28
+ if (snapshot !== null) {
29
+ return snapshot.value;
30
+ }
31
+ snapshot = await serialize(readFromDisk);
32
+ return snapshot.value;
33
+ }
34
+ async function readFromDisk() {
35
+ const text = await readTextIfExists(options.file);
36
+ // 文件不存在:写入默认值,供用户查看与编辑。
37
+ if (text === null) {
38
+ const result = finish(clone(options.defaults), true);
39
+ await write(result.raw);
40
+ return result;
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ }
46
+ catch (error) {
47
+ const moved = await moveAside(options.file, `corrupt-${timeStamp()}`);
48
+ log("warn", `无法读取配置文件。源文件已移动到 ${moved},本次使用默认值`, { error });
49
+ const result = finish(clone(options.defaults), true);
50
+ await write(result.raw);
51
+ return result;
52
+ }
53
+ if (!isPlainObject(parsed)) {
54
+ const moved = await moveAside(options.file, `corrupt-${timeStamp()}`);
55
+ log("warn", `配置根节点不是对象。源文件已移动到 ${moved},本次使用默认值`);
56
+ const result = finish(clone(options.defaults), true);
57
+ await write(result.raw);
58
+ return result;
59
+ }
60
+ const version = readVersion(parsed);
61
+ const target = options.defaults.schemaVersion;
62
+ // 版本高于程序:仅读取,不写回。
63
+ if (version > target) {
64
+ log("warn", `文件版本 v${version} 高于程序支持的 v${target}。已进入只读模式`);
65
+ const merged = deepMerge(clone(options.defaults), parsed);
66
+ return { value: deepFreeze(merged), raw: clone(parsed), writable: false };
67
+ }
68
+ // 版本低于程序:执行迁移
69
+ let raw = parsed;
70
+ let migrated = false;
71
+ if (version < target) {
72
+ const outcome = runMigrations(raw, target, options.migrations ?? []);
73
+ if (outcome.applied.length > 0) {
74
+ await backup();
75
+ raw = outcome.data;
76
+ migrated = true;
77
+ log("info", `配置已从 v${version} 迁移到 v${target}`);
78
+ }
79
+ }
80
+ // 默认值打底、文件内容覆盖。未声明字段由 deepMerge 保留。
81
+ const withDefaults = deepMerge(clone(options.defaults), raw);
82
+ const issues = repairFields(withDefaults, options.defaults, options.fields);
83
+ if (issues.length > 0) {
84
+ log("warn", `配置存在 ${issues.length} 处非法值。已替换为默认值`, issues);
85
+ }
86
+ const result = finish(withDefaults, true);
87
+ // 迁移或修复后写回磁盘。
88
+ if (migrated || issues.length > 0) {
89
+ await write(result.raw);
90
+ }
91
+ return result;
92
+ }
93
+ function finish(raw, writable) {
94
+ const merged = deepMerge(clone(options.defaults), raw);
95
+ return { value: deepFreeze(merged), raw, writable };
96
+ }
97
+ async function write(raw) {
98
+ const text = `${JSON.stringify(raw, null, 4)}\n`;
99
+ await writeFileAtomic(options.file, text, { mode: options.mode });
100
+ }
101
+ async function backup() {
102
+ try {
103
+ await moveAside(options.file, `bak-${timeStamp()}`);
104
+ }
105
+ catch (error) {
106
+ log("warn", "迁移前备份失败,继续执行", { error });
107
+ }
108
+ }
109
+ function current() {
110
+ if (snapshot === null) {
111
+ throw new Error(`配置文件尚未加载:${options.file}`);
112
+ }
113
+ return snapshot;
114
+ }
115
+ function broadcast(value) {
116
+ for (const listener of listeners) {
117
+ try {
118
+ listener(value);
119
+ }
120
+ catch (error) {
121
+ log("warn", "onChange 回调执行异常", { error });
122
+ }
123
+ }
124
+ }
125
+ async function commit(next) {
126
+ const state = current();
127
+ // 只读模式:拒绝写入。
128
+ if (!state.writable) {
129
+ throw new Error(`只读模式:${options.file} 的版本高于当前程序,禁止写入`);
130
+ }
131
+ const issues = validateFields(next, options.fields);
132
+ if (!issues.ok) {
133
+ throw new ConfigInvalidError(issues.issues);
134
+ }
135
+ await write(next);
136
+ snapshot = {
137
+ value: deepFreeze(deepMerge(clone(options.defaults), next)),
138
+ raw: next,
139
+ writable: true,
140
+ };
141
+ broadcast(snapshot.value);
142
+ return snapshot.value;
143
+ }
144
+ return {
145
+ file: options.file,
146
+ async load() {
147
+ return load();
148
+ },
149
+ isLoaded() {
150
+ return snapshot !== null;
151
+ },
152
+ get() {
153
+ return current().value;
154
+ },
155
+ raw() {
156
+ return current().raw;
157
+ },
158
+ async patch(patch) {
159
+ await load();
160
+ return serialize(async () => {
161
+ const unknown = collectUnknownPaths(patch, options.defaults);
162
+ if (unknown.length > 0) {
163
+ throw new ConfigInvalidError(unknown.map((path) => ({
164
+ path,
165
+ code: "conflict",
166
+ message: "未声明字段",
167
+ })));
168
+ }
169
+ const state = current();
170
+ // 以磁盘内容为基准打补丁,未声明字段一并保留
171
+ const draft = clone(state.raw);
172
+ const merged = deepMerge(draft, patch);
173
+ return commit(merged);
174
+ });
175
+ },
176
+ async replace(next) {
177
+ await load();
178
+ return serialize(async () => {
179
+ const issues = validateFields(next, options.fields);
180
+ if (!issues.ok) {
181
+ throw new ConfigInvalidError(issues.issues);
182
+ }
183
+ return commit(clone(next));
184
+ });
185
+ },
186
+ async reload() {
187
+ // 此处不可调用 load():load() 会再次进入 serialize,
188
+ // 内层排在外层之后、外层等待内层,形成死锁。
189
+ return serialize(async () => {
190
+ snapshot = await readFromDisk();
191
+ return snapshot.value;
192
+ });
193
+ },
194
+ async flush() {
195
+ await queue;
196
+ },
197
+ onChange(listener) {
198
+ listeners.add(listener);
199
+ return () => {
200
+ listeners.delete(listener);
201
+ };
202
+ },
203
+ isWritable() {
204
+ return snapshot?.writable ?? true;
205
+ },
206
+ };
207
+ }
208
+ function clone(value) {
209
+ return structuredClone(value);
210
+ }
211
+ function deepFreeze(value) {
212
+ if (value !== null && typeof value === "object") {
213
+ for (const item of Object.values(value)) {
214
+ deepFreeze(item);
215
+ }
216
+ Object.freeze(value);
217
+ }
218
+ return value;
219
+ }