@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 +86 -0
- package/dist/daemon.d.ts +42 -0
- package/dist/daemon.js +131 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +32 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.js +26 -0
- package/dist/process.d.ts +8 -0
- package/dist/process.js +18 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.js +78 -0
- package/package.json +35 -0
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` 等)读取连接器共享注册表,以发现/接管/列举连接器
|
package/dist/daemon.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -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;
|
package/dist/process.js
ADDED
|
@@ -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[];
|
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|