@mearl/daemon-core 3.0.0-beta.1 → 3.0.0-beta.10
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 +39 -37
- package/README.zh-CN.md +102 -0
- package/dist/command.d.ts +2 -0
- package/dist/command.js +14 -1
- package/dist/node-capabilities.d.ts +1 -0
- package/dist/node-capabilities.js +16 -6
- package/dist/page-runtimes.d.ts +27 -0
- package/dist/page-runtimes.js +163 -0
- package/dist/preset-installation.d.ts +55 -0
- package/dist/preset-installation.js +260 -0
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -1,79 +1,81 @@
|
|
|
1
1
|
# @mearl/daemon-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Daemon lifecycle primitives shared by Mearl's Node packages: process liveness, JSON registration records, log rotation, and a detached daemon manager.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`cloud-server`, `cloud-connector`, and `native-host` share daemon operations (`isAlive`, atomic registry reads and writes, log rotation, and keyed paths) here so all readers and writers agree on **record locations and formats**.
|
|
8
|
+
|
|
9
|
+
This Node-only package can be used as a normal dependency or inlined into an execution host bundle.
|
|
8
10
|
|
|
9
11
|
## API
|
|
10
12
|
|
|
11
|
-
###
|
|
13
|
+
### Process liveness
|
|
12
14
|
|
|
13
15
|
```typescript
|
|
14
16
|
isAlive(pid: number | undefined | null): boolean
|
|
15
17
|
isProcessGroupAlive(group: number): boolean
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
Uses signal-0 to probe liveness. `EPERM` counts as alive: the process exists but belongs to another user. On Linux, `/proc/<pid>/stat` identifies exited, unreaped zombie and dead processes as stopped. If other threads remain after the main thread exits, or status cannot be read, it conservatively treats the process as alive.
|
|
19
21
|
|
|
20
|
-
POSIX
|
|
22
|
+
POSIX process-group checks include descendants surviving the root process. Linux confirms execution has stopped only when two checks find the same exited members and creation identities. Active descendants, membership changes, permission errors, or read failures leave cleanup unconfirmed. Windows does not use POSIX process-group checks.
|
|
21
23
|
|
|
22
|
-
###
|
|
24
|
+
### Path derivation (multiple instances)
|
|
23
25
|
|
|
24
26
|
```typescript
|
|
25
27
|
DAEMON_HOME // ~/.mearl
|
|
26
|
-
hashKey(key: string): string // sha256(key)
|
|
28
|
+
hashKey(key: string): string // First 16 hexadecimal digits of sha256(key)
|
|
27
29
|
keyedDir(subdir: string): string // ~/.mearl/<subdir>
|
|
28
30
|
keyedConfigFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.json
|
|
29
31
|
keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
Multi-instance subsystems, such as connectors keyed by `serverUrl`, derive per-instance paths with these helpers. **All readers and writers must use them** to agree on record locations.
|
|
33
35
|
|
|
34
|
-
###
|
|
36
|
+
### Record registry
|
|
35
37
|
|
|
36
38
|
```typescript
|
|
37
39
|
interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
|
|
38
40
|
|
|
39
41
|
ensureDir(dir: string): void
|
|
40
|
-
writeRecord(file: string, record: DaemonRecord): void //
|
|
41
|
-
claimRecord(file: string, record: DaemonRecord): boolean //
|
|
42
|
+
writeRecord(file: string, record: DaemonRecord): void // Cross-process lock; atomic temporary file + rename
|
|
43
|
+
claimRecord(file: string, record: DaemonRecord): boolean // Atomically claim only if absent
|
|
42
44
|
readRecord<T>(file: string): T | null
|
|
43
45
|
removeRecord(file: string): void
|
|
44
|
-
removeRecordIfPid(file: string, pid: number): boolean //
|
|
46
|
+
removeRecordIfPid(file: string, pid: number): boolean // Remove only if still owned by this PID
|
|
45
47
|
writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
|
|
46
|
-
removeInvalidRecord(file: string): boolean //
|
|
47
|
-
listRecordFiles(dir: string): string[] //
|
|
48
|
-
readLiveRecords<T>(dir: string): T[] //
|
|
48
|
+
removeInvalidRecord(file: string): boolean // Clean up unparseable records
|
|
49
|
+
listRecordFiles(dir: string): string[] // Absolute paths of all *.json files
|
|
50
|
+
readLiveRecords<T>(dir: string): T[] // Read live records and clean up dead ones
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
###
|
|
53
|
+
### Daemon manager
|
|
52
54
|
|
|
53
55
|
```typescript
|
|
54
56
|
createDaemon(opts: CreateDaemonOptions): Daemon
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Builds a single-instance daemon manager around `(configFile, logFile)`. `start()` atomically claims a startup lock,
|
|
60
|
+
launches the consumer CLI detached with `--foreground`, and immediately writes a `starting` record
|
|
61
|
+
with the child PID. The ready child overwrites the record, allowing the parent to confirm startup. Concurrent `start()` calls reuse a starting
|
|
62
|
+
or running child. Multi-instance subsystems derive their own `configFile` / `logFile` from the instance key.
|
|
61
63
|
|
|
62
64
|
```typescript
|
|
63
65
|
interface CreateDaemonOptions {
|
|
64
|
-
name: string; //
|
|
65
|
-
scriptPath: string; //
|
|
66
|
-
configFile: string; //
|
|
67
|
-
logFile: string; //
|
|
68
|
-
foregroundArgs?: string[]; //
|
|
69
|
-
readyTimeoutMs?: number; //
|
|
70
|
-
formatInfo?: (record: DaemonRecord) => string | null; //
|
|
66
|
+
name: string; // Log label, e.g. 'CloudServer' / 'CloudConnector'
|
|
67
|
+
scriptPath: string; // Absolute path to the consumer's bundled cli.js
|
|
68
|
+
configFile: string; // Instance JSON record written by the foreground child
|
|
69
|
+
logFile: string; // Instance log file
|
|
70
|
+
foregroundArgs?: string[]; // argv appended after `--foreground`
|
|
71
|
+
readyTimeoutMs?: number; // Child record timeout, default 15000ms
|
|
72
|
+
formatInfo?: (record: DaemonRecord) => string | null; // Additional startup information
|
|
71
73
|
startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
interface Daemon {
|
|
75
77
|
start(): Promise<void>;
|
|
76
|
-
stop(expectedPid?: number): Promise<void>; //
|
|
78
|
+
stop(expectedPid?: number): Promise<void>; // Stop only the matching instance when PID is supplied
|
|
77
79
|
restart(): Promise<void>;
|
|
78
80
|
status(): void;
|
|
79
81
|
logs(lines?: number): void;
|
|
@@ -81,20 +83,20 @@ interface Daemon {
|
|
|
81
83
|
}
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
> `scriptPath`
|
|
86
|
+
> `scriptPath` must identify the consumer's own `cli.js`. Derive it with `fileURLToPath(import.meta.url)` in a module bundled into the CLI entry. If daemon-core is resolved separately, its `import.meta.url` points to daemon-core, not the CLI.
|
|
85
87
|
|
|
86
|
-
###
|
|
88
|
+
### Logs
|
|
87
89
|
|
|
88
90
|
```typescript
|
|
89
|
-
rotateLogIfNeeded(logFile: string, maxBytes?: number): void //
|
|
91
|
+
rotateLogIfNeeded(logFile: string, maxBytes?: number): void // Rotate to <log>.1 above limit; default 5MB
|
|
90
92
|
tailFile(logFile: string, lines: number): string
|
|
91
93
|
```
|
|
92
94
|
|
|
93
|
-
##
|
|
95
|
+
## Consumers
|
|
94
96
|
|
|
95
|
-
- **cloud-server**
|
|
96
|
-
- **cloud-connector**
|
|
97
|
-
- **native-host**
|
|
97
|
+
- **cloud-server** — `createDaemon`, single instance at `~/.mearl/cloud-server.json`.
|
|
98
|
+
- **cloud-connector** — `createDaemon`, instances keyed by `serverUrl` at `~/.mearl/connectors/<hash>.json`, listed through `readLiveRecords`.
|
|
99
|
+
- **native-host** — Uses primitives such as `isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` to discover, take over, and list connectors through their shared registry.
|
|
98
100
|
|
|
99
101
|
|
|
100
|
-
`@mearl/daemon-core/capabilities`
|
|
102
|
+
`NodeCapabilityRegistry` from `@mearl/daemon-core/capabilities` accepts `hooks` in its third constructor argument to load only modules referenced by those bindings. The whole selection is still validated, target module bytes are digest-verified, and commits still check the current selection. Ignored modules do not execute. This partial instance suits hosts needing a few native services, such as the installer CLI; it does not replace full Native Host activation and readiness receipts. Omit `hooks` for full loading.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @mearl/daemon-core
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Mearl 各 Node 包共享的守护进程生命周期原语:进程存活探测、JSON 记录注册表、日志滚动,以及一个 detached 守护进程管理器。
|
|
6
|
+
|
|
7
|
+
把 `cloud-server`、`cloud-connector`、`native-host` 里原本各写一份、容易彼此漂移的 daemon 逻辑(`isAlive`、原子读写注册表、日志滚动、按 key 派生路径)收敛到这一处,确保「写方」和「读方」对**记录位置与格式**始终一致。
|
|
8
|
+
|
|
9
|
+
此包只在 Node 中运行,可以作为普通依赖使用,也可以由执行宿主打包内联。
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
### 进程存活
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
isAlive(pid: number | undefined | null): boolean
|
|
17
|
+
isProcessGroupAlive(group: number): boolean
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
用 signal-0 探测进程是否存活(`EPERM` 视为存活——进程存在但属于他人)。Linux 同时读取 `/proc/<pid>/stat`,将已退出、尚未回收的 zombie 和 dead 状态视为已停止;主线程退出但仍有其他线程时,以及无法读取状态时,保守视为存活。
|
|
21
|
+
|
|
22
|
+
POSIX 进程组检查包含根进程退出后仍存活的后代。Linux 只有在两次检查得到相同的已退出成员及创建身份时,才确认组内执行已停止;活跃后代、成员变化和权限或读取失败都会保留清理未确认状态。Windows 不使用 POSIX 进程组检查。
|
|
23
|
+
|
|
24
|
+
### 路径派生(多实例)
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
DAEMON_HOME // ~/.mearl
|
|
28
|
+
hashKey(key: string): string // sha256(key) 前 16 位十六进制
|
|
29
|
+
keyedDir(subdir: string): string // ~/.mearl/<subdir>
|
|
30
|
+
keyedConfigFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.json
|
|
31
|
+
keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
多实例子系统(如按 `serverUrl` 区分的连接器)用这些 helper 派生每实例的文件路径。**所有读写方都必须经由它们派生路径**,这样彼此才不会对记录位置产生分歧。
|
|
35
|
+
|
|
36
|
+
### 记录注册表
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
|
|
40
|
+
|
|
41
|
+
ensureDir(dir: string): void
|
|
42
|
+
writeRecord(file: string, record: DaemonRecord): void // 跨进程加锁,临时文件 + rename 原子写
|
|
43
|
+
claimRecord(file: string, record: DaemonRecord): boolean // 仅在文件不存在时原子占用
|
|
44
|
+
readRecord<T>(file: string): T | null
|
|
45
|
+
removeRecord(file: string): void
|
|
46
|
+
removeRecordIfPid(file: string, pid: number): boolean // 仅删除指定进程仍拥有的记录
|
|
47
|
+
writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
|
|
48
|
+
removeInvalidRecord(file: string): boolean // 清理无法解析的残留记录
|
|
49
|
+
listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
|
|
50
|
+
readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 守护进程管理器
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
createDaemon(opts: CreateDaemonOptions): Daemon
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。`start()` 先原子占用启动锁,
|
|
60
|
+
再以 `--foreground` detached 方式拉起消费方 CLI,并立即写入带子进程 pid 的 `starting`
|
|
61
|
+
记录。子进程就绪后覆盖该记录,父进程据此确认启动完成。并发 `start()` 会复用正在启动或
|
|
62
|
+
已经运行的子进程。多实例子系统按实例 key 派生各自的 `configFile` / `logFile`。
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
interface CreateDaemonOptions {
|
|
66
|
+
name: string; // 日志标签,如 'CloudServer' / 'CloudConnector'
|
|
67
|
+
scriptPath: string; // 可重新拉起的 CLI 脚本绝对路径(消费方打包后的 cli.js)
|
|
68
|
+
configFile: string; // 本实例的 JSON 记录文件(由前台子进程写入)
|
|
69
|
+
logFile: string; // 本实例的日志文件
|
|
70
|
+
foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
|
|
71
|
+
readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
|
|
72
|
+
formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
|
|
73
|
+
startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Daemon {
|
|
77
|
+
start(): Promise<void>;
|
|
78
|
+
stop(expectedPid?: number): Promise<void>; // 指定 PID 时仅停止仍匹配的实例
|
|
79
|
+
restart(): Promise<void>;
|
|
80
|
+
status(): void;
|
|
81
|
+
logs(lines?: number): void;
|
|
82
|
+
running(): DaemonRecord | null;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> `scriptPath` 必须是消费方自己的 `cli.js`:用打包进 cli 入口的某个模块里的 `fileURLToPath(import.meta.url)` 计算。daemon-core 自身被独立解析时,它的 `import.meta.url` 指向 daemon-core,而不是 cli。
|
|
87
|
+
|
|
88
|
+
### 日志
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
rotateLogIfNeeded(logFile: string, maxBytes?: number): void // 超限则滚动为 <log>.1,默认 5MB
|
|
92
|
+
tailFile(logFile: string, lines: number): string
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 使用方
|
|
96
|
+
|
|
97
|
+
- **cloud-server** —— `createDaemon`(单实例,`~/.mearl/cloud-server.json`)
|
|
98
|
+
- **cloud-connector** —— `createDaemon`(多实例,按 `serverUrl` 区分,`~/.mearl/connectors/<hash>.json`)+ `readLiveRecords` 列举
|
|
99
|
+
- **native-host** —— 仅用原语(`isAlive` / `keyedConfigFile` / `readRecord` / `readLiveRecords` 等)读取连接器共享注册表,以发现/接管/列举连接器
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
`@mearl/daemon-core/capabilities` 的 `NodeCapabilityRegistry` 可在第三个构造参数传入 `hooks`,只加载这些绑定引用的模块。选择本身仍完整校验,目标模块字节按摘要验证,提交仍核对当前选择;忽略的模块不执行。该局部实例适用于安装 CLI 等只需要少量本机服务的宿主,不替代完整 Native Host 的激活与就绪回执。省略 `hooks` 时保持完整加载。
|
package/dist/command.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export declare function resolveSpawnCommand(executable: string, args: string[],
|
|
|
41
41
|
export declare function prependCommandPath(command: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
42
42
|
export declare function prependNodePath(env?: NodeJS.ProcessEnv, nodeExecutable?: string): NodeJS.ProcessEnv;
|
|
43
43
|
export declare function prependPathDirectory(directory: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
44
|
+
/** npm exec's package selector belongs to its invocation, not nested package runners. */
|
|
45
|
+
export declare function npmExecEnvironment(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
44
46
|
export declare function findExecutable(name: string, options?: {
|
|
45
47
|
env?: NodeJS.ProcessEnv;
|
|
46
48
|
existsSync?: (filePath: string) => boolean;
|
package/dist/command.js
CHANGED
|
@@ -6,7 +6,16 @@ import { isProcessGroupAlive, isProcessOwnerAlive, processIdentity } from './pro
|
|
|
6
6
|
const ownedCommands = new AsyncLocalStorage();
|
|
7
7
|
/** Accepted installation commands retain process ownership until their complete process group exits. */
|
|
8
8
|
export function withOwnedCommands(lifecycle, operation) {
|
|
9
|
-
|
|
9
|
+
const parent = ownedCommands.getStore();
|
|
10
|
+
return ownedCommands.run(parent ? {
|
|
11
|
+
started(owner) { parent.started(owner); lifecycle.started(owner); },
|
|
12
|
+
finished(owner) { try {
|
|
13
|
+
lifecycle.finished(owner);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
parent.finished(owner);
|
|
17
|
+
} },
|
|
18
|
+
} : lifecycle, operation);
|
|
10
19
|
}
|
|
11
20
|
export function isCommandProcessAlive(owner) {
|
|
12
21
|
if (!owner.group || process.platform === 'win32')
|
|
@@ -118,6 +127,10 @@ export function prependPathDirectory(directory, env = process.env) {
|
|
|
118
127
|
return env;
|
|
119
128
|
return { ...env, [pathKey]: [directory, ...entries].join(path.delimiter) };
|
|
120
129
|
}
|
|
130
|
+
/** npm exec's package selector belongs to its invocation, not nested package runners. */
|
|
131
|
+
export function npmExecEnvironment(env = process.env) {
|
|
132
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== 'npm_config_package'));
|
|
133
|
+
}
|
|
121
134
|
export function findExecutable(name, options = {}) {
|
|
122
135
|
const env = options.env ?? process.env;
|
|
123
136
|
const platform = options.platform ?? process.platform;
|
|
@@ -14,6 +14,7 @@ export declare class NodeCapabilityRegistry<Host extends object = object> {
|
|
|
14
14
|
get revision(): string | undefined;
|
|
15
15
|
get installationRevision(): string | undefined;
|
|
16
16
|
get<T extends NodeCapabilityHandler>(name: string): T | undefined;
|
|
17
|
+
entries(prefix?: string): Array<[string, NodeCapabilityHandler]>;
|
|
17
18
|
require<T extends NodeCapabilityHandler>(name: string): T;
|
|
18
19
|
refresh(): Promise<string | undefined>;
|
|
19
20
|
private readSelection;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { Script } from 'node:vm';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
5
|
import { constants } from 'node:fs';
|
|
5
6
|
import { lstat, open } from 'node:fs/promises';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { DAEMON_HOME } from './paths.js';
|
|
8
|
-
import {
|
|
9
|
-
const MAX_SELECTION_BYTES =
|
|
9
|
+
import { verifyCapabilityActivation, MAX_CAPABILITY_SELECTION_BYTES } from '@mearl/preset';
|
|
10
|
+
const MAX_SELECTION_BYTES = MAX_CAPABILITY_SELECTION_BYTES + 128;
|
|
10
11
|
const handlerName = /^[a-z][a-zA-Z0-9_.-]{0,127}$/;
|
|
11
12
|
async function readBoundedFile(file, limit) {
|
|
12
13
|
const stat = await lstat(file);
|
|
@@ -50,6 +51,9 @@ export class NodeCapabilityRegistry {
|
|
|
50
51
|
get revision() { return this.active?.snapshot.revision; }
|
|
51
52
|
get installationRevision() { return this.active?.snapshot.installationRevision; }
|
|
52
53
|
get(name) { return this.active && Object.hasOwn(this.active.handlers, name) ? this.active.handlers[name] : undefined; }
|
|
54
|
+
entries(prefix = "") {
|
|
55
|
+
return Object.entries(this.active?.handlers ?? {}).filter(([name]) => name.startsWith(prefix));
|
|
56
|
+
}
|
|
53
57
|
require(name) {
|
|
54
58
|
const handler = this.get(name);
|
|
55
59
|
if (!handler)
|
|
@@ -84,7 +88,7 @@ export class NodeCapabilityRegistry {
|
|
|
84
88
|
return undefined;
|
|
85
89
|
throw error;
|
|
86
90
|
}
|
|
87
|
-
return
|
|
91
|
+
return verifyCapabilityActivation(JSON.parse(bytes.toString('utf8')));
|
|
88
92
|
}
|
|
89
93
|
async load(snapshot) {
|
|
90
94
|
if (!snapshot) {
|
|
@@ -104,7 +108,7 @@ export class NodeCapabilityRegistry {
|
|
|
104
108
|
const pointer = join(this.directory, 'active-node.json');
|
|
105
109
|
const bytes = await readBoundedFile(pointer, MAX_SELECTION_BYTES);
|
|
106
110
|
const requested = JSON.parse(bytes.toString('utf8'));
|
|
107
|
-
const current = await
|
|
111
|
+
const current = await verifyCapabilityActivation(requested).catch(() => undefined);
|
|
108
112
|
if (current && (current.revision !== failed?.revision || current.installationRevision !== failed?.installationRevision)) {
|
|
109
113
|
try {
|
|
110
114
|
await this.load(current);
|
|
@@ -117,7 +121,7 @@ export class NodeCapabilityRegistry {
|
|
|
117
121
|
const record = JSON.parse((await readBoundedFile(join(this.directory, 'rollback.json'), MAX_SELECTION_BYTES + 256)).toString('utf8'));
|
|
118
122
|
if (record.candidate !== requested.installationRevision || !record.previous)
|
|
119
123
|
return;
|
|
120
|
-
const previous = await
|
|
124
|
+
const previous = await verifyCapabilityActivation(record.previous);
|
|
121
125
|
const expected = createHash('sha256').update(bytes).digest('hex');
|
|
122
126
|
await this.prepareSelection(previous, async () => {
|
|
123
127
|
const current = await readBoundedFile(pointer, MAX_SELECTION_BYTES);
|
|
@@ -138,6 +142,12 @@ export class NodeCapabilityRegistry {
|
|
|
138
142
|
}
|
|
139
143
|
const modules = new Map();
|
|
140
144
|
for (const [id, resource] of entries) {
|
|
145
|
+
const previous = this.active?.snapshot.modules[id];
|
|
146
|
+
const reusable = this.active?.modules.get(id);
|
|
147
|
+
if (reusable && previous?.sha256 === resource.sha256 && previous.byteLength === resource.byteLength && isDeepStrictEqual(previous.config ?? {}, resource.config ?? {})) {
|
|
148
|
+
modules.set(id, reusable);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
141
151
|
const bytes = resources.get(id);
|
|
142
152
|
// CommonJS wrappers can be reclaimed after their active and in-flight handlers are released.
|
|
143
153
|
const module = { exports: {} };
|
|
@@ -163,7 +173,7 @@ export class NodeCapabilityRegistry {
|
|
|
163
173
|
const current = await guard();
|
|
164
174
|
if (!current)
|
|
165
175
|
throw new Error('Node capability selection changed while preparing modules');
|
|
166
|
-
this.active = { snapshot: current, handlers: Object.freeze(handlers) };
|
|
176
|
+
this.active = { snapshot: current, handlers: Object.freeze(handlers), modules };
|
|
167
177
|
return snapshot.revision;
|
|
168
178
|
}
|
|
169
179
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type PageRuntimeBinding } from '@mearl/preset/bundles';
|
|
2
|
+
export interface PageRuntimeOwner {
|
|
3
|
+
browser: string;
|
|
4
|
+
target: string;
|
|
5
|
+
pid?: number;
|
|
6
|
+
processIdentity?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const MAX_NODE_PAGE_CACHE_BYTES: number;
|
|
9
|
+
export declare function capabilityDirectory(): string;
|
|
10
|
+
/** Browser references survive host restarts; unavailable remote browsers retain their exact code. */
|
|
11
|
+
export declare class NodePageRuntimeStore {
|
|
12
|
+
private readonly quota;
|
|
13
|
+
private readonly root;
|
|
14
|
+
private readonly index;
|
|
15
|
+
constructor(directory?: string, quota?: number);
|
|
16
|
+
resolve(binding: PageRuntimeBinding, provide?: (binding: PageRuntimeBinding) => string | undefined, owner?: PageRuntimeOwner): string;
|
|
17
|
+
release(owner: PageRuntimeOwner): void;
|
|
18
|
+
retain(owner: PageRuntimeOwner, binding?: PageRuntimeBinding): void;
|
|
19
|
+
/** A live target inventory is authoritative after a disconnected host lost close events. */
|
|
20
|
+
reconcile(browser: string, targets: ReadonlySet<string>): void;
|
|
21
|
+
private key;
|
|
22
|
+
private mutate;
|
|
23
|
+
private collect;
|
|
24
|
+
private read;
|
|
25
|
+
private write;
|
|
26
|
+
private verify;
|
|
27
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DAEMON_HOME } from './paths.js';
|
|
5
|
+
import { isProcessOwnerAlive } from './process.js';
|
|
6
|
+
import { withRecordLock } from './registry.js';
|
|
7
|
+
import { MAX_PAGE_RUNTIME_BYTES } from '@mearl/preset/bundles';
|
|
8
|
+
const sha256 = /^[a-f0-9]{64}$/;
|
|
9
|
+
export const MAX_NODE_PAGE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
export function capabilityDirectory() { return process.env.MEARL_CAPABILITY_DIR || join(DAEMON_HOME, 'capabilities'); }
|
|
11
|
+
/** Browser references survive host restarts; unavailable remote browsers retain their exact code. */
|
|
12
|
+
export class NodePageRuntimeStore {
|
|
13
|
+
quota;
|
|
14
|
+
root;
|
|
15
|
+
index;
|
|
16
|
+
constructor(directory = capabilityDirectory(), quota = MAX_NODE_PAGE_CACHE_BYTES) {
|
|
17
|
+
this.quota = quota;
|
|
18
|
+
this.root = join(directory, 'page-runtimes');
|
|
19
|
+
this.index = join(this.root, 'references.json');
|
|
20
|
+
}
|
|
21
|
+
resolve(binding, provide, owner) {
|
|
22
|
+
if (!/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(binding.id) || !sha256.test(binding.sha256))
|
|
23
|
+
throw new Error('Invalid page runtime binding');
|
|
24
|
+
return this.mutate(references => {
|
|
25
|
+
const file = join(this.root, `${binding.sha256}.cjs`);
|
|
26
|
+
let source;
|
|
27
|
+
if (existsSync(file))
|
|
28
|
+
source = this.verify(binding, this.read(file, MAX_PAGE_RUNTIME_BYTES));
|
|
29
|
+
else {
|
|
30
|
+
const provided = provide?.(binding);
|
|
31
|
+
if (typeof provided !== 'string' || !provided.trim())
|
|
32
|
+
throw new Error(`Required page runtime is not installed: ${binding.id}@${binding.sha256}`);
|
|
33
|
+
const bytes = Buffer.from(provided);
|
|
34
|
+
source = this.verify(binding, bytes);
|
|
35
|
+
this.collect(references, bytes.byteLength, 1);
|
|
36
|
+
this.write(file, bytes);
|
|
37
|
+
}
|
|
38
|
+
if (owner) {
|
|
39
|
+
if (!sha256.test(owner.browser) || !owner.target || owner.target.length > 256)
|
|
40
|
+
throw new Error('Invalid page runtime owner');
|
|
41
|
+
const key = this.key(owner);
|
|
42
|
+
const previous = references.owners[key];
|
|
43
|
+
if (!previous && Object.keys(references.owners).length >= 4096)
|
|
44
|
+
throw new Error('Page runtime reference limit exceeded');
|
|
45
|
+
references.owners[key] = { ...owner, digests: [...new Set([...(previous?.digests ?? []), binding.sha256])] };
|
|
46
|
+
}
|
|
47
|
+
return source;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
release(owner) {
|
|
51
|
+
if (!existsSync(this.index))
|
|
52
|
+
return;
|
|
53
|
+
this.mutate(references => { delete references.owners[this.key(owner)]; this.collect(references); });
|
|
54
|
+
}
|
|
55
|
+
retain(owner, binding) {
|
|
56
|
+
if (!existsSync(this.index))
|
|
57
|
+
return;
|
|
58
|
+
this.mutate(references => {
|
|
59
|
+
const previous = references.owners[this.key(owner)];
|
|
60
|
+
if (previous && binding && previous.digests.includes(binding.sha256))
|
|
61
|
+
previous.digests = [binding.sha256];
|
|
62
|
+
else if (!binding)
|
|
63
|
+
delete references.owners[this.key(owner)];
|
|
64
|
+
this.collect(references);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** A live target inventory is authoritative after a disconnected host lost close events. */
|
|
68
|
+
reconcile(browser, targets) {
|
|
69
|
+
if (!existsSync(this.index))
|
|
70
|
+
return;
|
|
71
|
+
this.mutate(references => {
|
|
72
|
+
for (const [key, owner] of Object.entries(references.owners))
|
|
73
|
+
if (owner.browser === browser && !targets.has(owner.target))
|
|
74
|
+
delete references.owners[key];
|
|
75
|
+
this.collect(references);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
key(owner) { return createHash('sha256').update(JSON.stringify([owner.browser, owner.target])).digest('hex'); }
|
|
79
|
+
mutate(action) {
|
|
80
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
81
|
+
return withRecordLock(this.index, () => {
|
|
82
|
+
const references = existsSync(this.index) ? JSON.parse(this.read(this.index, 4 * 1024 * 1024).toString('utf8')) : { owners: {} };
|
|
83
|
+
if (!references || !references.owners || typeof references.owners !== 'object' || Array.isArray(references.owners) || Object.keys(references.owners).length > 4096)
|
|
84
|
+
throw new Error('Invalid page runtime reference index');
|
|
85
|
+
const liveness = new Map();
|
|
86
|
+
for (const [key, owner] of Object.entries(references.owners)) {
|
|
87
|
+
if (!owner || !sha256.test(owner.browser) || typeof owner.target !== 'string' || !Array.isArray(owner.digests) || owner.digests.length > 256 || owner.digests.some(digest => typeof digest !== 'string' || !sha256.test(digest)))
|
|
88
|
+
throw new Error('Invalid page runtime reference');
|
|
89
|
+
if (owner.pid !== undefined) {
|
|
90
|
+
const identity = JSON.stringify([owner.pid, owner.processIdentity]);
|
|
91
|
+
if (!liveness.has(identity))
|
|
92
|
+
liveness.set(identity, isProcessOwnerAlive({ pid: owner.pid, processIdentity: owner.processIdentity }));
|
|
93
|
+
if (!liveness.get(identity))
|
|
94
|
+
delete references.owners[key];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const result = action(references);
|
|
98
|
+
const bytes = Buffer.from(JSON.stringify(references));
|
|
99
|
+
if (bytes.byteLength > 4 * 1024 * 1024)
|
|
100
|
+
throw new Error('Page runtime reference index exceeds 4 MiB');
|
|
101
|
+
this.write(this.index, bytes);
|
|
102
|
+
return result;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
collect(references, requiredBytes = 0, requiredEntries = 0) {
|
|
106
|
+
const protectedDigests = new Set(Object.values(references.owners).flatMap(owner => owner.digests));
|
|
107
|
+
const files = readdirSync(this.root).filter(name => /^[a-f0-9]{64}\.cjs$/.test(name)).map(name => ({ file: join(this.root, name), digest: name.slice(0, 64), info: statSync(join(this.root, name)) }));
|
|
108
|
+
let total = files.reduce((sum, file) => sum + file.info.size, 0);
|
|
109
|
+
let entries = files.length;
|
|
110
|
+
for (const file of files.sort((a, b) => a.info.mtimeMs - b.info.mtimeMs)) {
|
|
111
|
+
if (protectedDigests.has(file.digest))
|
|
112
|
+
continue;
|
|
113
|
+
// Explicit release/reconciliation reclaims unreferenced objects; insertions evict as needed.
|
|
114
|
+
if (requiredBytes && total + requiredBytes <= this.quota && entries + requiredEntries <= 256)
|
|
115
|
+
break;
|
|
116
|
+
unlinkSync(file.file);
|
|
117
|
+
total -= file.info.size;
|
|
118
|
+
entries--;
|
|
119
|
+
}
|
|
120
|
+
if (total + requiredBytes > this.quota || entries + requiredEntries > 256)
|
|
121
|
+
throw new Error('Page runtime cache is full of active browser references');
|
|
122
|
+
}
|
|
123
|
+
read(file, maximum) {
|
|
124
|
+
const handle = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
125
|
+
try {
|
|
126
|
+
const stat = fstatSync(handle);
|
|
127
|
+
if (!stat.isFile() || stat.size > maximum)
|
|
128
|
+
throw new Error('Stored page runtime exceeds its limit');
|
|
129
|
+
const bytes = Buffer.alloc(maximum + 1);
|
|
130
|
+
let length = 0;
|
|
131
|
+
while (length < bytes.length) {
|
|
132
|
+
const count = readSync(handle, bytes, length, bytes.length - length, null);
|
|
133
|
+
if (!count)
|
|
134
|
+
break;
|
|
135
|
+
length += count;
|
|
136
|
+
}
|
|
137
|
+
if (length > maximum)
|
|
138
|
+
throw new Error('Stored page runtime exceeds its limit');
|
|
139
|
+
return bytes.subarray(0, length);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
closeSync(handle);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
write(file, bytes) {
|
|
146
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
147
|
+
try {
|
|
148
|
+
writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
149
|
+
renameSync(temporary, file);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(temporary);
|
|
154
|
+
}
|
|
155
|
+
catch { /* Atomic replacement consumed the temporary file. */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
verify(binding, bytes) {
|
|
159
|
+
if (bytes.byteLength > MAX_PAGE_RUNTIME_BYTES || createHash('sha256').update(bytes).digest('hex') !== binding.sha256)
|
|
160
|
+
throw new Error('Page runtime integrity check failed');
|
|
161
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type JsonObject, type CapabilityActivation, type ResolvedPreset } from '@mearl/preset';
|
|
2
|
+
import { type PreparedBundle, type PreparedPreset, type PresetSnapshot } from '@mearl/preset/bundles';
|
|
3
|
+
export interface PresetInstallReceipt {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
snapshot: PresetSnapshot;
|
|
6
|
+
bundles: Array<Omit<PreparedBundle, 'resources'>>;
|
|
7
|
+
browserRevision: string;
|
|
8
|
+
nodeRevision: string;
|
|
9
|
+
managedModules: string[];
|
|
10
|
+
nodeSyncRequired: boolean;
|
|
11
|
+
browserSyncRequired?: boolean;
|
|
12
|
+
overrides: JsonObject;
|
|
13
|
+
revision: string;
|
|
14
|
+
}
|
|
15
|
+
interface SelectionState {
|
|
16
|
+
rawHash: string | null;
|
|
17
|
+
activation?: CapabilityActivation;
|
|
18
|
+
receipt?: PresetInstallReceipt;
|
|
19
|
+
prepared?: PreparedPreset;
|
|
20
|
+
preset?: ResolvedPreset;
|
|
21
|
+
}
|
|
22
|
+
/** One pointer binds the desired preset, user overrides and exact Node selection. */
|
|
23
|
+
export declare class PresetInstallationStore {
|
|
24
|
+
readonly directory: string;
|
|
25
|
+
readonly mearlApiVersion: string;
|
|
26
|
+
private readonly pointer;
|
|
27
|
+
constructor(options?: {
|
|
28
|
+
directory?: string;
|
|
29
|
+
mearlApiVersion?: string;
|
|
30
|
+
});
|
|
31
|
+
private assertCurrent;
|
|
32
|
+
current(options?: {
|
|
33
|
+
verifyResources?: boolean;
|
|
34
|
+
}): Promise<SelectionState>;
|
|
35
|
+
apply(input: Pick<PreparedPreset, 'snapshot' | 'bundles'>, options: {
|
|
36
|
+
expectedRevision: string | null;
|
|
37
|
+
overrides?: JsonObject;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
confirm?: () => Promise<void>;
|
|
40
|
+
}): Promise<PresetInstallReceipt>;
|
|
41
|
+
rollback(expectedRevision: string): Promise<void>;
|
|
42
|
+
collectGarbage(): Promise<void>;
|
|
43
|
+
private collectLocked;
|
|
44
|
+
}
|
|
45
|
+
export declare function presetSelectionRevision(state: SelectionState): string | null;
|
|
46
|
+
/** Reads accepted local metadata without loading capability factories, browsers or network sources. */
|
|
47
|
+
export declare function readInstallationManagement(options?: {
|
|
48
|
+
directory?: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
setup: import("@mearl/preset").SetupManagement | undefined;
|
|
51
|
+
source: string | undefined;
|
|
52
|
+
revision: string | undefined;
|
|
53
|
+
}>;
|
|
54
|
+
export { setupManagementCommand, setupRuntimeCompatible } from '@mearl/preset';
|
|
55
|
+
export type { SetupManagement } from '@mearl/preset';
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { withRecordLock } from './registry.js';
|
|
6
|
+
import { mergePresetDefaults, prepareNodeBundleSnapshot, prepareCapabilitySnapshot, verifyCapabilityActivation, resolveCapabilityConfig, MAX_CAPABILITY_SELECTION_BYTES, parseSetupManagement } from '@mearl/preset';
|
|
7
|
+
import { BundleError, MAX_BROWSER_STORAGE_BYTES, presetRequiresBrowser, validateBundleManifest, verifyPreset, verifyBrowserPreset, verifyPreparedPreset } from '@mearl/preset/bundles';
|
|
8
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const hashPattern = /^[a-f0-9]{64}$/;
|
|
10
|
+
const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_OVERRIDES_BYTES = 262_144;
|
|
12
|
+
const POINTER_BYTES = MAX_CAPABILITY_SELECTION_BYTES + 128;
|
|
13
|
+
function readBounded(file, limit) {
|
|
14
|
+
const before = lstatSync(file);
|
|
15
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > limit)
|
|
16
|
+
throw new Error('Invalid preset resource file or size');
|
|
17
|
+
const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
18
|
+
try {
|
|
19
|
+
const stat = fstatSync(descriptor);
|
|
20
|
+
if (!stat.isFile() || stat.size > limit)
|
|
21
|
+
throw new Error('Invalid preset resource file or size');
|
|
22
|
+
const bytes = Buffer.alloc(Math.min(stat.size, limit) + 1);
|
|
23
|
+
let length = 0;
|
|
24
|
+
while (length < bytes.byteLength) {
|
|
25
|
+
const count = readSync(descriptor, bytes, length, bytes.byteLength - length, null);
|
|
26
|
+
if (count === 0)
|
|
27
|
+
break;
|
|
28
|
+
length += count;
|
|
29
|
+
}
|
|
30
|
+
if (length > stat.size)
|
|
31
|
+
throw new Error('Preset resource changed while reading');
|
|
32
|
+
return bytes.subarray(0, length);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
closeSync(descriptor);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function optionalFile(file, limit) {
|
|
39
|
+
try {
|
|
40
|
+
return readBounded(file, limit);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT')
|
|
44
|
+
return undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function atomicWrite(file, bytes) {
|
|
49
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
52
|
+
renameSync(temporary, file);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
rmSync(temporary, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function revision(state) {
|
|
59
|
+
return state.activation?.installationRevision ?? (state.activation ? `node:${state.activation.revision}` : null);
|
|
60
|
+
}
|
|
61
|
+
function overrides(value) {
|
|
62
|
+
if (Object.hasOwn(value, 'management'))
|
|
63
|
+
throw new BundleError('BUNDLE_INVALID', 'Installation management cannot be overridden');
|
|
64
|
+
const accepted = mergePresetDefaults({}, value);
|
|
65
|
+
if (Buffer.byteLength(JSON.stringify(accepted)) > MAX_OVERRIDES_BYTES)
|
|
66
|
+
throw new BundleError('BUNDLE_QUOTA', 'User overrides exceed 256 KiB');
|
|
67
|
+
return accepted;
|
|
68
|
+
}
|
|
69
|
+
/** One pointer binds the desired preset, user overrides and exact Node selection. */
|
|
70
|
+
export class PresetInstallationStore {
|
|
71
|
+
directory;
|
|
72
|
+
mearlApiVersion;
|
|
73
|
+
pointer;
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.directory = resolve(options.directory ?? (process.env.MEARL_CAPABILITY_DIR || join(homedir(), '.mearl/capabilities')));
|
|
76
|
+
this.mearlApiVersion = options.mearlApiVersion ?? '1.0.0';
|
|
77
|
+
this.pointer = join(this.directory, 'active-node.json');
|
|
78
|
+
}
|
|
79
|
+
assertCurrent(expected) {
|
|
80
|
+
const bytes = optionalFile(this.pointer, POINTER_BYTES);
|
|
81
|
+
if ((bytes ? digest(bytes) : null) !== expected)
|
|
82
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during preparation');
|
|
83
|
+
}
|
|
84
|
+
async current(options = {}) {
|
|
85
|
+
const raw = optionalFile(this.pointer, POINTER_BYTES);
|
|
86
|
+
if (!raw)
|
|
87
|
+
return { rawHash: null };
|
|
88
|
+
const rawHash = digest(raw);
|
|
89
|
+
const activation = await verifyCapabilityActivation(JSON.parse(raw.toString('utf8')));
|
|
90
|
+
if (!activation.installationRevision)
|
|
91
|
+
return { rawHash, activation };
|
|
92
|
+
const captured = withRecordLock(this.pointer, () => {
|
|
93
|
+
this.assertCurrent(rawHash);
|
|
94
|
+
const receiptBytes = readBounded(join(this.directory, 'installations', `${activation.installationRevision}.json`), MAX_RECEIPT_BYTES);
|
|
95
|
+
const receipt = JSON.parse(receiptBytes.toString('utf8'));
|
|
96
|
+
const { revision: receiptRevision, ...data } = receipt;
|
|
97
|
+
if (receiptRevision !== activation.installationRevision || digest(JSON.stringify(data)) !== receiptRevision || data.schemaVersion !== 1 ||
|
|
98
|
+
Object.keys(data).some(key => !['schemaVersion', 'snapshot', 'bundles', 'browserRevision', 'nodeRevision', 'managedModules', 'nodeSyncRequired', 'browserSyncRequired', 'overrides'].includes(key)) || typeof data.nodeSyncRequired !== 'boolean' || (data.browserSyncRequired !== undefined && typeof data.browserSyncRequired !== 'boolean') ||
|
|
99
|
+
data.nodeRevision !== activation.revision || !Array.isArray(data.bundles) || data.bundles.length > 64 ||
|
|
100
|
+
!Array.isArray(data.managedModules) || new Set(data.managedModules).size !== data.managedModules.length || data.managedModules.some(id => typeof id !== 'string' || !Object.hasOwn(activation.modules, id))) {
|
|
101
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Installed preset receipt does not match its selection');
|
|
102
|
+
}
|
|
103
|
+
const acceptedOverrides = overrides(data.overrides);
|
|
104
|
+
let total = 0;
|
|
105
|
+
const metadata = data.bundles.map(bundle => {
|
|
106
|
+
if (Object.keys(bundle).some(key => !['source', 'manifest', 'revision'].includes(key)))
|
|
107
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid installed package metadata');
|
|
108
|
+
const manifest = validateBundleManifest(bundle.manifest, this.mearlApiVersion);
|
|
109
|
+
total += Object.values(manifest.resources).reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
110
|
+
return { ...bundle, manifest };
|
|
111
|
+
});
|
|
112
|
+
if (total > MAX_BROWSER_STORAGE_BYTES)
|
|
113
|
+
throw new BundleError('BUNDLE_QUOTA', 'Installed preset resources exceed 64 MiB');
|
|
114
|
+
const bundles = options.verifyResources === false ? [] : metadata.map(bundle => ({ ...bundle, resources: Object.fromEntries(Object.entries(bundle.manifest.resources).map(([name, entry]) => [name, readBounded(join(this.directory, 'blobs', `${entry.sha256}.bin`), entry.byteLength)])) }));
|
|
115
|
+
for (const entry of options.verifyResources === false ? [] : Object.values(activation.modules)) {
|
|
116
|
+
const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
|
|
117
|
+
if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
|
|
118
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Installed Node object is missing or corrupt');
|
|
119
|
+
}
|
|
120
|
+
return { receipt: { ...receipt, overrides: acceptedOverrides }, bundles };
|
|
121
|
+
});
|
|
122
|
+
if (options.verifyResources === false) {
|
|
123
|
+
const graph = await verifyPreset(captured.receipt.snapshot, this.mearlApiVersion);
|
|
124
|
+
await verifyBrowserPreset({ source: graph.snapshot.source, manifests: graph.snapshot.manifests, packageTargets: graph.snapshot.packageTargets,
|
|
125
|
+
bundleRevisions: Object.fromEntries(Object.entries(graph.snapshot.bundleRevisions).filter(([id]) => graph.preset.packages[id].targets?.includes('browser'))), revision: captured.receipt.browserRevision }, this.mearlApiVersion);
|
|
126
|
+
return { rawHash, activation, receipt: captured.receipt, preset: graph.preset };
|
|
127
|
+
}
|
|
128
|
+
const prepared = await verifyPreparedPreset({ snapshot: captured.receipt.snapshot, bundles: captured.bundles }, this.mearlApiVersion);
|
|
129
|
+
if (prepared.browser.snapshot.revision !== captured.receipt.browserRevision)
|
|
130
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Installed browser projection does not match');
|
|
131
|
+
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].targets?.includes('node')), 'native-host', this.mearlApiVersion, resolveCapabilityConfig(mergePresetDefaults(prepared.preset.defaults, captured.receipt.overrides)));
|
|
132
|
+
if (Object.keys(selected.snapshot.modules).length !== captured.receipt.managedModules.length || captured.receipt.managedModules.some(id => !Object.hasOwn(selected.snapshot.modules, id)) ||
|
|
133
|
+
Object.entries(selected.snapshot.modules).some(([id, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.modules[id])) ||
|
|
134
|
+
Object.entries(selected.snapshot.bindings).some(([hook, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.bindings[hook])) ||
|
|
135
|
+
Object.values(activation.bindings).filter(entry => captured.receipt.managedModules.includes(entry.module)).length !== Object.keys(selected.snapshot.bindings).length) {
|
|
136
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Installed managed Node selection does not match its packages');
|
|
137
|
+
}
|
|
138
|
+
return { rawHash, activation, prepared, receipt: captured.receipt, preset: prepared.preset };
|
|
139
|
+
}
|
|
140
|
+
async apply(input, options) {
|
|
141
|
+
options.signal?.throwIfAborted();
|
|
142
|
+
const acceptedOverrides = options.overrides === undefined ? undefined : overrides(options.overrides);
|
|
143
|
+
const [current, prepared] = await Promise.all([this.current({ verifyResources: false }), verifyPreparedPreset(input, this.mearlApiVersion)]);
|
|
144
|
+
if (revision(current) !== options.expectedRevision)
|
|
145
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed');
|
|
146
|
+
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].targets?.includes('node')), 'native-host', this.mearlApiVersion, resolveCapabilityConfig(mergePresetDefaults(prepared.preset.defaults, acceptedOverrides ?? current.receipt?.overrides ?? {})));
|
|
147
|
+
const previousManaged = new Set(current.receipt?.managedModules ?? []);
|
|
148
|
+
const unmanaged = Object.fromEntries(Object.entries(current.activation?.modules ?? {}).filter(([id]) => !previousManaged.has(id)));
|
|
149
|
+
for (const entry of Object.values(unmanaged)) {
|
|
150
|
+
const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
|
|
151
|
+
if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
|
|
152
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'An independent Node module is corrupt');
|
|
153
|
+
}
|
|
154
|
+
if (Object.keys(selected.snapshot.modules).some(id => Object.hasOwn(unmanaged, id)))
|
|
155
|
+
throw new BundleError('BUNDLE_CONFLICT', 'A preset module conflicts with an independently installed Node module');
|
|
156
|
+
const bindings = Object.fromEntries(Object.entries(current.activation?.bindings ?? {}).filter(([, entry]) => Object.hasOwn(unmanaged, entry.module)));
|
|
157
|
+
if (Object.keys(selected.snapshot.bindings).some(hook => Object.hasOwn(bindings, hook)))
|
|
158
|
+
throw new BundleError('BUNDLE_CONFLICT', 'A preset hook conflicts with an independently installed Node module');
|
|
159
|
+
const snapshot = await prepareCapabilitySnapshot({ schemaVersion: 1, modules: { ...unmanaged, ...selected.snapshot.modules }, bindings: { ...bindings, ...selected.snapshot.bindings } });
|
|
160
|
+
const data = {
|
|
161
|
+
schemaVersion: 1, snapshot: prepared.snapshot,
|
|
162
|
+
bundles: prepared.bundles.map(({ source, revision, manifest }) => ({ source, revision, manifest })),
|
|
163
|
+
browserRevision: prepared.browser.snapshot.revision, nodeRevision: snapshot.revision,
|
|
164
|
+
managedModules: Object.keys(selected.snapshot.modules), overrides: acceptedOverrides ?? current.receipt?.overrides ?? {},
|
|
165
|
+
nodeSyncRequired: Boolean(current.receipt?.nodeSyncRequired || current.receipt?.managedModules.length || Object.values(prepared.preset.packages).some(entry => entry.enabled !== false && entry.targets?.includes('node'))),
|
|
166
|
+
browserSyncRequired: Boolean(current.receipt?.browserSyncRequired || presetRequiresBrowser(prepared.preset)),
|
|
167
|
+
};
|
|
168
|
+
const receipt = { ...data, revision: digest(JSON.stringify(data)) };
|
|
169
|
+
const receiptBytes = JSON.stringify(receipt);
|
|
170
|
+
if (Buffer.byteLength(receiptBytes) > MAX_RECEIPT_BYTES)
|
|
171
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset installation receipt exceeds 16 MiB');
|
|
172
|
+
const activation = { ...snapshot, installationRevision: receipt.revision };
|
|
173
|
+
await options.confirm?.();
|
|
174
|
+
withRecordLock(this.pointer, () => {
|
|
175
|
+
this.assertCurrent(current.rawHash);
|
|
176
|
+
options.signal?.throwIfAborted();
|
|
177
|
+
for (const child of ['blobs', 'objects', 'installations'])
|
|
178
|
+
mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
|
|
179
|
+
for (const bundle of prepared.bundles)
|
|
180
|
+
for (const [name, entry] of Object.entries(bundle.manifest.resources)) {
|
|
181
|
+
atomicWrite(join(this.directory, 'blobs', `${entry.sha256}.bin`), bundle.resources[name]);
|
|
182
|
+
}
|
|
183
|
+
for (const entry of Object.values(selected.snapshot.modules)) {
|
|
184
|
+
const destination = join(this.directory, 'objects', `${entry.sha256}.cjs`);
|
|
185
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
186
|
+
try {
|
|
187
|
+
linkSync(join(this.directory, 'blobs', `${entry.sha256}.bin`), temporary);
|
|
188
|
+
renameSync(temporary, destination);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
rmSync(temporary, { force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
atomicWrite(join(this.directory, 'installations', `${receipt.revision}.json`), receiptBytes);
|
|
195
|
+
if (current.receipt?.revision !== receipt.revision)
|
|
196
|
+
atomicWrite(join(this.directory, 'rollback.json'), JSON.stringify({ candidate: receipt.revision, previous: current.activation ?? null }));
|
|
197
|
+
atomicWrite(this.pointer, JSON.stringify(activation));
|
|
198
|
+
});
|
|
199
|
+
return receipt;
|
|
200
|
+
}
|
|
201
|
+
async rollback(expectedRevision) {
|
|
202
|
+
const current = await this.current({ verifyResources: false });
|
|
203
|
+
if (current.activation?.installationRevision !== expectedRevision)
|
|
204
|
+
throw new BundleError('BUNDLE_CONFLICT', 'A newer preset installation cannot be rolled back');
|
|
205
|
+
const record = JSON.parse(readBounded(join(this.directory, 'rollback.json'), POINTER_BYTES + 256).toString('utf8'));
|
|
206
|
+
if (record.candidate !== expectedRevision)
|
|
207
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The preset rollback record has changed');
|
|
208
|
+
const previous = record.previous === null ? undefined : await verifyCapabilityActivation(record.previous);
|
|
209
|
+
withRecordLock(this.pointer, () => {
|
|
210
|
+
this.assertCurrent(current.rawHash);
|
|
211
|
+
if (previous)
|
|
212
|
+
atomicWrite(this.pointer, JSON.stringify(previous));
|
|
213
|
+
else
|
|
214
|
+
rmSync(this.pointer, { force: true });
|
|
215
|
+
rmSync(join(this.directory, 'rollback.json'), { force: true });
|
|
216
|
+
this.collectLocked(previous);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async collectGarbage() {
|
|
220
|
+
const current = await this.current({ verifyResources: false });
|
|
221
|
+
const raw = optionalFile(join(this.directory, 'rollback.json'), POINTER_BYTES + 256);
|
|
222
|
+
const record = raw && JSON.parse(raw.toString('utf8'));
|
|
223
|
+
const previous = record?.candidate === current.activation?.installationRevision && record.previous !== null ? await verifyCapabilityActivation(record.previous) : undefined;
|
|
224
|
+
withRecordLock(this.pointer, () => {
|
|
225
|
+
this.assertCurrent(current.rawHash);
|
|
226
|
+
for (const child of ['blobs', 'objects', 'installations'])
|
|
227
|
+
mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
|
|
228
|
+
this.collectLocked(current.activation, previous);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
collectLocked(active, previous) {
|
|
232
|
+
const receipts = new Set([active?.installationRevision, previous?.installationRevision].filter((value) => Boolean(value)));
|
|
233
|
+
const blobs = new Set();
|
|
234
|
+
const modules = new Set([active, previous].flatMap(selection => Object.values(selection?.modules ?? {}).map(entry => entry.sha256)));
|
|
235
|
+
for (const id of receipts) {
|
|
236
|
+
const record = JSON.parse(readBounded(join(this.directory, 'installations', `${id}.json`), MAX_RECEIPT_BYTES).toString('utf8'));
|
|
237
|
+
for (const bundle of record.bundles)
|
|
238
|
+
for (const entry of Object.values(bundle.manifest.resources))
|
|
239
|
+
blobs.add(entry.sha256);
|
|
240
|
+
}
|
|
241
|
+
for (const [directory, extension, retained] of [['blobs', '.bin', blobs], ['objects', '.cjs', modules], ['installations', '.json', receipts]]) {
|
|
242
|
+
for (const name of readdirSync(join(this.directory, directory))) {
|
|
243
|
+
const id = name.slice(0, -extension.length);
|
|
244
|
+
if (name.endsWith(extension) && hashPattern.test(id) && !retained.has(id))
|
|
245
|
+
rmSync(join(this.directory, directory, name), { force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function presetSelectionRevision(state) { return revision(state); }
|
|
251
|
+
/** Reads accepted local metadata without loading capability factories, browsers or network sources. */
|
|
252
|
+
export async function readInstallationManagement(options = {}) {
|
|
253
|
+
const current = await new PresetInstallationStore(options).current({ verifyResources: false });
|
|
254
|
+
return {
|
|
255
|
+
setup: parseSetupManagement(current.preset?.defaults.management),
|
|
256
|
+
source: current.receipt?.snapshot.source,
|
|
257
|
+
revision: current.receipt?.revision,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export { setupManagementCommand, setupRuntimeCompatible } from '@mearl/preset';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/daemon-core",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.10",
|
|
4
4
|
"description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
"./capabilities": {
|
|
14
14
|
"types": "./dist/node-capabilities.d.ts",
|
|
15
15
|
"import": "./dist/node-capabilities.js"
|
|
16
|
+
},
|
|
17
|
+
"./installation": {
|
|
18
|
+
"types": "./dist/preset-installation.d.ts",
|
|
19
|
+
"import": "./dist/preset-installation.js"
|
|
20
|
+
},
|
|
21
|
+
"./page-runtimes": {
|
|
22
|
+
"types": "./dist/page-runtimes.d.ts",
|
|
23
|
+
"import": "./dist/page-runtimes.js"
|
|
16
24
|
}
|
|
17
25
|
},
|
|
18
26
|
"files": [
|
|
@@ -25,7 +33,7 @@
|
|
|
25
33
|
],
|
|
26
34
|
"license": "Apache-2.0",
|
|
27
35
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
36
|
+
"node": ">=20.18.0"
|
|
29
37
|
},
|
|
30
38
|
"publishConfig": {
|
|
31
39
|
"registry": "https://registry.npmjs.org"
|
|
@@ -35,11 +43,11 @@
|
|
|
35
43
|
"typescript": "^5.8.3"
|
|
36
44
|
},
|
|
37
45
|
"dependencies": {
|
|
38
|
-
"@mearl/preset": "3.0.0-beta.
|
|
46
|
+
"@mearl/preset": "3.0.0-beta.10"
|
|
39
47
|
},
|
|
40
48
|
"repository": {
|
|
41
49
|
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/
|
|
50
|
+
"url": "git+https://github.com/mearlhq/mearl.git",
|
|
43
51
|
"directory": "packages/daemon-core"
|
|
44
52
|
},
|
|
45
53
|
"scripts": {
|