@mearl/daemon-core 3.0.0-beta.4 → 3.0.0-beta.7

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/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
@@ -118,6 +118,10 @@ export function prependPathDirectory(directory, env = process.env) {
118
118
  return env;
119
119
  return { ...env, [pathKey]: [directory, ...entries].join(path.delimiter) };
120
120
  }
121
+ /** npm exec's package selector belongs to its invocation, not nested package runners. */
122
+ export function npmExecEnvironment(env = process.env) {
123
+ return Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== 'npm_config_package'));
124
+ }
121
125
  export function findExecutable(name, options = {}) {
122
126
  const env = options.env ?? process.env;
123
127
  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 { verifyNodeCapabilityActivation, MAX_NODE_SELECTION_BYTES } from '@mearl/preset';
9
- const MAX_SELECTION_BYTES = MAX_NODE_SELECTION_BYTES + 128;
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 verifyNodeCapabilityActivation(JSON.parse(bytes.toString('utf8')));
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 verifyNodeCapabilityActivation(requested).catch(() => undefined);
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 verifyNodeCapabilityActivation(record.previous);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/daemon-core",
3
- "version": "3.0.0-beta.4",
3
+ "version": "3.0.0-beta.7",
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,10 @@
13
13
  "./capabilities": {
14
14
  "types": "./dist/node-capabilities.d.ts",
15
15
  "import": "./dist/node-capabilities.js"
16
+ },
17
+ "./page-runtimes": {
18
+ "types": "./dist/page-runtimes.d.ts",
19
+ "import": "./dist/page-runtimes.js"
16
20
  }
17
21
  },
18
22
  "files": [
@@ -25,7 +29,7 @@
25
29
  ],
26
30
  "license": "Apache-2.0",
27
31
  "engines": {
28
- "node": ">=22.12.0"
32
+ "node": ">=20.18.0"
29
33
  },
30
34
  "publishConfig": {
31
35
  "registry": "https://registry.npmjs.org"
@@ -35,11 +39,11 @@
35
39
  "typescript": "^5.8.3"
36
40
  },
37
41
  "dependencies": {
38
- "@mearl/preset": "3.0.0-beta.4"
42
+ "@mearl/preset": "3.0.0-beta.7"
39
43
  },
40
44
  "repository": {
41
45
  "type": "git",
42
- "url": "git+https://github.com/F-loat/mearl.git",
46
+ "url": "git+https://github.com/mearlhq/mearl.git",
43
47
  "directory": "packages/daemon-core"
44
48
  },
45
49
  "scripts": {