@mearl/daemon-core 2.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.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @mearl/daemon-core
2
+
3
+ Mearl 各 Node 包共享的守护进程生命周期原语:进程存活探测、JSON 记录注册表、日志滚动,以及一个 detached 守护进程管理器。
4
+
5
+ 把 `cloud-server`、`cloud-connector`、`native-host` 里原本各写一份、容易彼此漂移的 daemon 逻辑(`isAlive`、原子读写注册表、日志滚动、按 key 派生路径)收敛到这一处,确保「写方」和「读方」对**记录位置与格式**始终一致。
6
+
7
+ > 仅供 monorepo 内部使用(Node-only)。`cloud-server` / `cloud-connector` 以普通依赖引入(运行时从 npm 解析),`native-host` 在打包时直接内联。
8
+
9
+ ## API
10
+
11
+ ### 进程存活
12
+
13
+ ```typescript
14
+ isAlive(pid: number | undefined | null): boolean
15
+ ```
16
+
17
+ 用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。
18
+
19
+ ### 路径派生(多实例)
20
+
21
+ ```typescript
22
+ DAEMON_HOME // ~/.mearl
23
+ hashKey(key: string): string // sha256(key) 前 16 位十六进制
24
+ keyedDir(subdir: string): string // ~/.mearl/<subdir>
25
+ keyedConfigFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.json
26
+ keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
27
+ ```
28
+
29
+ 多实例子系统(如按 `serverUrl` 区分的连接器)用这些 helper 派生每实例的文件路径。**所有读写方都必须经由它们派生路径**,这样彼此才不会对记录位置产生分歧。
30
+
31
+ ### 记录注册表
32
+
33
+ ```typescript
34
+ interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
35
+
36
+ ensureDir(dir: string): void
37
+ writeRecord(file: string, record: DaemonRecord): void // 临时文件 + rename 原子写
38
+ readRecord<T>(file: string): T | null
39
+ removeRecord(file: string): void
40
+ listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
41
+ readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
42
+ ```
43
+
44
+ ### 守护进程管理器
45
+
46
+ ```typescript
47
+ createDaemon(opts: CreateDaemonOptions): Daemon
48
+ ```
49
+
50
+ 围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。模型:`start()` 以 `--foreground` detached 方式重新拉起消费方自己的 CLI;子进程运行实际服务,就绪后把自己的 pid 写入 `configFile`,父进程轮询 `record.pid === child.pid` 确认其已起来(而非死亡或卡住)。多实例子系统按实例 key 派生 `configFile`/`logFile`,为每个实例各创建一个管理器。
51
+
52
+ ```typescript
53
+ interface CreateDaemonOptions {
54
+ name: string; // 日志标签,如 'CloudServer' / 'CloudConnector'
55
+ scriptPath: string; // 可重新拉起的 CLI 脚本绝对路径(消费方打包后的 cli.js)
56
+ configFile: string; // 本实例的 JSON 记录文件(由前台子进程写入)
57
+ logFile: string; // 本实例的日志文件
58
+ foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
59
+ readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
60
+ formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
61
+ }
62
+
63
+ interface Daemon {
64
+ start(): Promise<void>;
65
+ stop(): Promise<void>;
66
+ restart(): Promise<void>;
67
+ status(): void;
68
+ logs(lines?: number): void;
69
+ running(): DaemonRecord | null;
70
+ }
71
+ ```
72
+
73
+ > `scriptPath` 必须是消费方自己的 `cli.js`:用打包进 cli 入口的某个模块里的 `fileURLToPath(import.meta.url)` 计算。daemon-core 自身被独立解析时,它的 `import.meta.url` 指向 daemon-core,而不是 cli。
74
+
75
+ ### 日志
76
+
77
+ ```typescript
78
+ rotateLogIfNeeded(logFile: string, maxBytes?: number): void // 超限则滚动为 <log>.1,默认 5MB
79
+ tailFile(logFile: string, lines: number): string
80
+ ```
81
+
82
+ ## 使用方
83
+
84
+ - **cloud-server** —— `createDaemon`(单实例,`~/.mearl/cloud-server.json`)
85
+ - **cloud-connector** —— `createDaemon`(多实例,按 `serverUrl` 区分,`~/.mearl/connectors/<hash>.json`)+ `readLiveRecords` 列举
86
+ - **native-host** —— 仅用原语(`isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` 等)读取连接器共享注册表,以发现/接管/列举连接器
@@ -0,0 +1,42 @@
1
+ import { type DaemonRecord } from './registry.js';
2
+ export interface CreateDaemonOptions {
3
+ /** Label used in log lines, e.g. `CloudServer` / `CloudConnector`. */
4
+ name: string;
5
+ /**
6
+ * Absolute path to the re-spawnable CLI script (the consumer's bundled
7
+ * `cli.js`). The daemon re-launches it as `node <scriptPath> --foreground …`,
8
+ * so the consumer's CLI must run the instance in-process when it sees
9
+ * `--foreground`. Compute it with `fileURLToPath(import.meta.url)` from a
10
+ * module that is bundled INTO the cli entry.
11
+ */
12
+ scriptPath: string;
13
+ /** Path to this instance's JSON record file (written by the foreground child). */
14
+ configFile: string;
15
+ /** Path to this instance's log file (the detached child's stdout/stderr). */
16
+ logFile: string;
17
+ /** argv appended after `--foreground` when spawning the detached child. */
18
+ foregroundArgs?: string[];
19
+ /** How long to wait for the child to write its record. Default 15000ms. */
20
+ readyTimeoutMs?: number;
21
+ /** Extra lines to print once the instance is up/queried (e.g. a connect hint). */
22
+ formatInfo?: (record: DaemonRecord) => string | null;
23
+ }
24
+ export interface Daemon {
25
+ start(): Promise<void>;
26
+ stop(): Promise<void>;
27
+ restart(): Promise<void>;
28
+ status(): void;
29
+ logs(lines?: number): void;
30
+ /** Current record if a live instance exists, else null. */
31
+ running(): DaemonRecord | null;
32
+ }
33
+ /**
34
+ * Build a single-instance daemon manager around a `(configFile, logFile)` pair.
35
+ *
36
+ * The model: `start()` spawns the consumer's CLI detached with `--foreground`;
37
+ * that child runs the actual service and, once ready, writes its own pid into
38
+ * `configFile`. The parent polls for `record.pid === child.pid` to confirm the
39
+ * child came up (vs. died or hung). Multi-instance subsystems create one manager
40
+ * per instance, deriving `configFile`/`logFile` from the instance key.
41
+ */
42
+ export declare function createDaemon(opts: CreateDaemonOptions): Daemon;
package/dist/daemon.js ADDED
@@ -0,0 +1,131 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { openSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { isAlive } from './process.js';
5
+ import { ensureDir, readRecord, removeRecord } from './registry.js';
6
+ import { rotateLogIfNeeded, tailFile } from './log.js';
7
+ function sleep(ms) {
8
+ return new Promise(resolve => setTimeout(resolve, ms));
9
+ }
10
+ /**
11
+ * Build a single-instance daemon manager around a `(configFile, logFile)` pair.
12
+ *
13
+ * The model: `start()` spawns the consumer's CLI detached with `--foreground`;
14
+ * that child runs the actual service and, once ready, writes its own pid into
15
+ * `configFile`. The parent polls for `record.pid === child.pid` to confirm the
16
+ * child came up (vs. died or hung). Multi-instance subsystems create one manager
17
+ * per instance, deriving `configFile`/`logFile` from the instance key.
18
+ */
19
+ export function createDaemon(opts) {
20
+ const { name, scriptPath, configFile, logFile, foregroundArgs = [], readyTimeoutMs = 15_000, formatInfo, } = opts;
21
+ const tag = `[${name}]`;
22
+ function running() {
23
+ const rec = readRecord(configFile);
24
+ return rec && isAlive(rec.pid) ? rec : null;
25
+ }
26
+ function printInfo(rec) {
27
+ const extra = formatInfo?.(rec);
28
+ if (extra)
29
+ console.log(extra);
30
+ }
31
+ async function start() {
32
+ const live = running();
33
+ if (live) {
34
+ console.log(`${tag} Already running (pid ${live.pid})`);
35
+ printInfo(live);
36
+ return;
37
+ }
38
+ // Clear any stale record left by a crashed run before spawning a new child.
39
+ removeRecord(configFile);
40
+ ensureDir(dirname(configFile));
41
+ ensureDir(dirname(logFile));
42
+ rotateLogIfNeeded(logFile);
43
+ const logFd = openSync(logFile, 'a');
44
+ const child = spawn(process.execPath, [scriptPath, '--foreground', ...foregroundArgs], {
45
+ detached: true,
46
+ stdio: ['ignore', logFd, logFd],
47
+ env: process.env,
48
+ windowsHide: true,
49
+ });
50
+ child.unref();
51
+ // Wait until the child writes its own pid into the record (i.e. it is ready),
52
+ // or until it dies / we time out.
53
+ const deadline = Date.now() + readyTimeoutMs;
54
+ while (Date.now() < deadline) {
55
+ if (!isAlive(child.pid)) {
56
+ console.error(`${tag} Failed to start. Recent log:`);
57
+ const log = tailFile(logFile, 20);
58
+ if (log)
59
+ console.error(log);
60
+ console.error(`(full log: ${logFile})`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ const rec = readRecord(configFile);
65
+ if (rec && rec.pid === child.pid) {
66
+ console.log(`${tag} Started in background (pid ${child.pid})`);
67
+ console.log(`${tag} Logs: ${logFile}`);
68
+ printInfo(rec);
69
+ return;
70
+ }
71
+ await sleep(200);
72
+ }
73
+ console.error(`${tag} Start timed out after ${Math.round(readyTimeoutMs / 1000)}s. Check the log: ${logFile}`);
74
+ process.exitCode = 1;
75
+ }
76
+ async function stop() {
77
+ const rec = readRecord(configFile);
78
+ if (!rec || !isAlive(rec.pid)) {
79
+ console.log(`${tag} Not running`);
80
+ removeRecord(configFile);
81
+ return;
82
+ }
83
+ const pid = rec.pid;
84
+ try {
85
+ process.kill(pid, 'SIGTERM');
86
+ }
87
+ catch (error) {
88
+ console.error(`${tag} Failed to stop pid ${pid}:`, error.message);
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ const deadline = Date.now() + 5_000;
93
+ while (Date.now() < deadline && isAlive(pid)) {
94
+ await sleep(150);
95
+ }
96
+ if (isAlive(pid)) {
97
+ try {
98
+ process.kill(pid, 'SIGKILL');
99
+ }
100
+ catch {
101
+ // ignore
102
+ }
103
+ }
104
+ removeRecord(configFile);
105
+ console.log(`${tag} Stopped (pid ${pid})`);
106
+ }
107
+ async function restart() {
108
+ await stop();
109
+ await start();
110
+ }
111
+ function status() {
112
+ const rec = running();
113
+ if (!rec) {
114
+ console.log(`${tag} Status: stopped`);
115
+ return;
116
+ }
117
+ console.log(`${tag} Status: running`);
118
+ console.log(` pid: ${rec.pid}`);
119
+ if (rec.startedAt)
120
+ console.log(` started at: ${rec.startedAt}`);
121
+ console.log(` log: ${logFile}`);
122
+ printInfo(rec);
123
+ }
124
+ function logs(lines = 50) {
125
+ const log = tailFile(logFile, lines);
126
+ if (log)
127
+ console.log(log);
128
+ console.log(`\n(log file: ${logFile} — follow with: tail -f "${logFile}")`);
129
+ }
130
+ return { start, stop, restart, status, logs, running };
131
+ }
@@ -0,0 +1,5 @@
1
+ export * from './process.js';
2
+ export * from './paths.js';
3
+ export * from './registry.js';
4
+ export * from './log.js';
5
+ export * from './daemon.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './process.js';
2
+ export * from './paths.js';
3
+ export * from './registry.js';
4
+ export * from './log.js';
5
+ export * from './daemon.js';
package/dist/log.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Rotate `<log>` to `<log>.1` if it has grown past the limit. Meant to be called
3
+ * before each (re)start, since a detached child appends to an inherited fd and
4
+ * can't rotate itself mid-run. Best-effort: a failure here must not block startup.
5
+ */
6
+ export declare function rotateLogIfNeeded(logFile: string, maxBytes?: number): void;
7
+ /** Return the trailing `lines` lines of a log file (empty string if absent). */
8
+ export declare function tailFile(logFile: string, lines: number): string;
package/dist/log.js ADDED
@@ -0,0 +1,32 @@
1
+ import { existsSync, statSync, renameSync, readFileSync } from 'node:fs';
2
+ /** Rotate the log when it grows past this size (keeps one `.1` backup). */
3
+ const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
4
+ /**
5
+ * Rotate `<log>` to `<log>.1` if it has grown past the limit. Meant to be called
6
+ * before each (re)start, since a detached child appends to an inherited fd and
7
+ * can't rotate itself mid-run. Best-effort: a failure here must not block startup.
8
+ */
9
+ export function rotateLogIfNeeded(logFile, maxBytes = DEFAULT_MAX_LOG_BYTES) {
10
+ try {
11
+ if (!existsSync(logFile))
12
+ return;
13
+ if (statSync(logFile).size < maxBytes)
14
+ return;
15
+ renameSync(logFile, `${logFile}.1`);
16
+ }
17
+ catch {
18
+ // ignore
19
+ }
20
+ }
21
+ /** Return the trailing `lines` lines of a log file (empty string if absent). */
22
+ export function tailFile(logFile, lines) {
23
+ try {
24
+ if (!existsSync(logFile))
25
+ return '';
26
+ const content = readFileSync(logFile, 'utf-8');
27
+ return content.split('\n').slice(-lines).join('\n').trim();
28
+ }
29
+ catch {
30
+ return '';
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ /** Root directory for all mearl daemon runtime files (configs / logs). */
2
+ export declare const DAEMON_HOME: string;
3
+ /**
4
+ * Stable short hash of an arbitrary instance key (e.g. a server URL). Used to
5
+ * derive per-instance file names for multi-instance subsystems. Both the writer
6
+ * (the daemon process) and any reader (another manager) MUST derive the path
7
+ * through these helpers so the two never disagree on where a record lives.
8
+ */
9
+ export declare function hashKey(key: string): string;
10
+ /** Directory holding per-instance files for a subsystem, e.g. `connectors`. */
11
+ export declare function keyedDir(subdir: string): string;
12
+ /** Per-instance JSON record path: ~/.mearl/<subdir>/<hash>.json */
13
+ export declare function keyedConfigFile(subdir: string, key: string): string;
14
+ /** Per-instance log path: ~/.mearl/<subdir>/<hash>.log */
15
+ export declare function keyedLogFile(subdir: string, key: string): string;
package/dist/paths.js ADDED
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { createHash } from 'node:crypto';
4
+ /** Root directory for all mearl daemon runtime files (configs / logs). */
5
+ export const DAEMON_HOME = join(homedir(), '.mearl');
6
+ /**
7
+ * Stable short hash of an arbitrary instance key (e.g. a server URL). Used to
8
+ * derive per-instance file names for multi-instance subsystems. Both the writer
9
+ * (the daemon process) and any reader (another manager) MUST derive the path
10
+ * through these helpers so the two never disagree on where a record lives.
11
+ */
12
+ export function hashKey(key) {
13
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
14
+ }
15
+ /** Directory holding per-instance files for a subsystem, e.g. `connectors`. */
16
+ export function keyedDir(subdir) {
17
+ return join(DAEMON_HOME, subdir);
18
+ }
19
+ /** Per-instance JSON record path: ~/.mearl/<subdir>/<hash>.json */
20
+ export function keyedConfigFile(subdir, key) {
21
+ return join(keyedDir(subdir), `${hashKey(key)}.json`);
22
+ }
23
+ /** Per-instance log path: ~/.mearl/<subdir>/<hash>.log */
24
+ export function keyedLogFile(subdir, key) {
25
+ return join(keyedDir(subdir), `${hashKey(key)}.log`);
26
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether a process with the given pid is currently alive.
3
+ *
4
+ * Uses the signal-0 probe, which never actually delivers a signal — it only
5
+ * performs the kernel's permission/existence checks. EPERM means the process
6
+ * exists but is owned by another user, so it still counts as alive.
7
+ */
8
+ export declare function isAlive(pid: number | undefined | null): boolean;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Whether a process with the given pid is currently alive.
3
+ *
4
+ * Uses the signal-0 probe, which never actually delivers a signal — it only
5
+ * performs the kernel's permission/existence checks. EPERM means the process
6
+ * exists but is owned by another user, so it still counts as alive.
7
+ */
8
+ export function isAlive(pid) {
9
+ if (!pid || pid <= 0)
10
+ return false;
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ return error.code === 'EPERM';
17
+ }
18
+ }
@@ -0,0 +1,23 @@
1
+ /** Base shape every daemon record shares; subsystems extend it with their own fields. */
2
+ export interface DaemonRecord {
3
+ pid: number;
4
+ startedAt?: string;
5
+ [key: string]: unknown;
6
+ }
7
+ export declare function ensureDir(dir: string): void;
8
+ /**
9
+ * Atomically write a record: serialise to a temp file then rename over the
10
+ * target, so a concurrent reader never observes a half-written file. The temp
11
+ * name is pid-scoped so two writers don't clobber each other's temp file.
12
+ */
13
+ export declare function writeRecord(file: string, record: DaemonRecord): void;
14
+ export declare function readRecord<T extends DaemonRecord = DaemonRecord>(file: string): T | null;
15
+ export declare function removeRecord(file: string): void;
16
+ /** Absolute paths of every `*.json` record file in a directory. */
17
+ export declare function listRecordFiles(dir: string): string[];
18
+ /**
19
+ * Read every record in a directory whose process is still alive, pruning files
20
+ * whose pid is dead (or whose contents are corrupt) as a side effect. This is
21
+ * the canonical way to list a multi-instance subsystem's live records.
22
+ */
23
+ export declare function readLiveRecords<T extends DaemonRecord = DaemonRecord>(dir: string): T[];
@@ -0,0 +1,78 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { isAlive } from './process.js';
4
+ export function ensureDir(dir) {
5
+ if (!existsSync(dir)) {
6
+ mkdirSync(dir, { recursive: true });
7
+ }
8
+ }
9
+ /**
10
+ * Atomically write a record: serialise to a temp file then rename over the
11
+ * target, so a concurrent reader never observes a half-written file. The temp
12
+ * name is pid-scoped so two writers don't clobber each other's temp file.
13
+ */
14
+ export function writeRecord(file, record) {
15
+ ensureDir(dirname(file));
16
+ const tmp = `${file}.${process.pid}.tmp`;
17
+ try {
18
+ writeFileSync(tmp, JSON.stringify(record));
19
+ renameSync(tmp, file);
20
+ }
21
+ catch (error) {
22
+ try {
23
+ rmSync(tmp, { force: true });
24
+ }
25
+ catch {
26
+ // ignore
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ export function readRecord(file) {
32
+ try {
33
+ if (!existsSync(file))
34
+ return null;
35
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
36
+ return parsed && typeof parsed.pid === 'number' ? parsed : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function removeRecord(file) {
43
+ try {
44
+ rmSync(file, { force: true });
45
+ }
46
+ catch {
47
+ // ignore
48
+ }
49
+ }
50
+ /** Absolute paths of every `*.json` record file in a directory. */
51
+ export function listRecordFiles(dir) {
52
+ try {
53
+ return readdirSync(dir)
54
+ .filter(name => name.endsWith('.json'))
55
+ .map(name => join(dir, name));
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ /**
62
+ * Read every record in a directory whose process is still alive, pruning files
63
+ * whose pid is dead (or whose contents are corrupt) as a side effect. This is
64
+ * the canonical way to list a multi-instance subsystem's live records.
65
+ */
66
+ export function readLiveRecords(dir) {
67
+ const out = [];
68
+ for (const file of listRecordFiles(dir)) {
69
+ const rec = readRecord(file);
70
+ if (rec && isAlive(rec.pid)) {
71
+ out.push(rec);
72
+ }
73
+ else {
74
+ removeRecord(file);
75
+ }
76
+ }
77
+ return out;
78
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mearl/daemon-core",
3
+ "version": "2.0.0",
4
+ "description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "keywords": [
18
+ "mearl",
19
+ "daemon"
20
+ ],
21
+ "license": "ISC",
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.9.1",
27
+ "typescript": "^5.8.3"
28
+ },
29
+ "scripts": {
30
+ "build": "rm -rf dist/ && tsc",
31
+ "dev": "tsc --watch",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run"
34
+ }
35
+ }