@mearl/provider 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # @mearl/provider
2
+
3
+ External browser providers use this package to register themselves with Mearl, publish their
4
+ available browsers, and handle Mearl browser actions over a local socket.
5
+
6
+ ## Installation
7
+
8
+ Provider packages should declare `@mearl/provider` as a runtime dependency:
9
+
10
+ ```bash
11
+ pnpm add @mearl/provider
12
+ ```
13
+
14
+ Provider implementations import the package root. The `/host` and `/runtime` subpaths are for
15
+ Mearl's setup, discovery, and routing processes rather than Provider business code.
16
+
17
+ ## Provider lifecycle
18
+
19
+ 1. The provider package declares its protocol entry in `package.json#mearl.provider`.
20
+ 2. `npx @mearl/setup provider install <package>` installs the package and registers its metadata.
21
+ 3. `mearl browser_list` starts an `on-demand` provider when necessary.
22
+ 4. The provider starts `createProviderServer()` and publishes browsers with `updateBrowsers()`.
23
+ 5. Mearl reads browser discovery from the shared registry and routes each supported browser action
24
+ to the provider socket with the provider-local browser id.
25
+ 6. The provider removes its live browser records when it stops.
26
+
27
+ Mearl discovers only explicit manifests in `~/.mearl/providers/installed`. It does not scan or
28
+ execute arbitrary global packages.
29
+
30
+ ## Package metadata
31
+
32
+ Provider packages do not need a user-facing binary or lifecycle scripts. Their `package.json`
33
+ declares an internal Node.js entry owned by the package:
34
+
35
+ ```json
36
+ {
37
+ "name": "@example/mearl-provider-device",
38
+ "version": "1.0.0",
39
+ "type": "module",
40
+ "mearl": {
41
+ "provider": {
42
+ "providerId": "example-device",
43
+ "name": "Example device provider",
44
+ "protocolVersion": 1,
45
+ "entry": "./dist/runtime.js",
46
+ "browserType": {
47
+ "name": "Device",
48
+ "parameters": [
49
+ { "key": "code", "label": "Pairing code", "placeholder": "Generated by default" }
50
+ ]
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ The entry must be a relative path to a file inside the installed package. Mearl launches registered
58
+ providers on demand with the current Node.js runtime; packages cannot register arbitrary commands,
59
+ arguments, working directories, or environment overrides.
60
+
61
+ ## Minimal runtime
62
+
63
+ ```ts
64
+ import { createProviderServer } from '@mearl/provider';
65
+
66
+ const server = createProviderServer({
67
+ descriptor: { providerId: 'example', name: 'Example provider', version: '1.0.0' },
68
+ handleAction: async ({ browserId, action, data }) => {
69
+ // Translate Mearl actions to the external browser-control backend.
70
+ return { browserId, action, data };
71
+ },
72
+ createBrowser: async parameters => createDevice(parameters),
73
+ deleteBrowser: async browserId => deleteDevice(browserId),
74
+ });
75
+
76
+ await server.start();
77
+ server.updateBrowsers([
78
+ {
79
+ browserId: 'device-1',
80
+ name: 'Example device',
81
+ capabilities: {
82
+ actions: ['tab_list', 'page_snapshot', 'page_click'],
83
+ screenshot: 'none',
84
+ },
85
+ },
86
+ ]);
87
+ ```
88
+
89
+ `createBrowser` returns the Provider-local `browserId`, display `name`, and initial `status`.
90
+ When the initial status is `pairing`, it also returns the same `pairing` metadata published during
91
+ discovery, so `browser_launch` callers can present the QR URL without an extra `browser_list` call.
92
+ Mearl converts that local id into the same global id shape returned by `browser_list`.
93
+
94
+ A device provider can remain discoverable before its hardware is online by publishing a pairing
95
+ browser. Mearl keeps it out of implicit action routing and can render its QR URL:
96
+
97
+ ```ts
98
+ const pairing = {
99
+ kind: 'qr' as const,
100
+ url: 'https://example.test/pair?id=device-pairing',
101
+ code: 'device-pairing',
102
+ };
103
+
104
+ const createBrowser = async () => ({
105
+ browserId: 'device-pairing',
106
+ name: 'Pair a device',
107
+ status: 'pairing' as const,
108
+ pairing,
109
+ });
110
+
111
+ server.updateBrowsers([
112
+ {
113
+ browserId: 'device-pairing',
114
+ name: 'Pair a device',
115
+ status: 'pairing',
116
+ pairing,
117
+ capabilities: { actions: [], screenshot: 'none' },
118
+ },
119
+ ]);
120
+ ```
121
+
122
+ Screenshot capability is declared as `native`, `reconstructed`, or `none`, so callers can retain
123
+ the image while distinguishing real pixels from an HTML reconstruction.
124
+
125
+ Capabilities stay in Mearl's internal routing registry and are returned by `get_versions` for a
126
+ selected provider browser. They are intentionally omitted from `browser_list`; use
127
+ `mearl check --browser <id>` to inspect them. The package accepts only declared browser-targeted Mearl
128
+ actions. Mearl owns discovery, while `browser_launch` and `browser_close` are routed to the
129
+ Provider's `createBrowser` and `deleteBrowser` handlers. Requests for undeclared actions are
130
+ rejected before reaching the provider.
@@ -0,0 +1,3 @@
1
+ export declare const PROVIDER_ACTIONS: readonly ["tab_checkpoint", "capture_checkpoint", "get_requests", "get_logs", "get_events", "get_api_schema", "set_mock", "get_mocks", "set_rule", "get_rules", "send_request", "send_mtop_request", "tdbank_account", "browser_release", "page_screenshot", "page_selected_element", "tab_open", "tab_close", "tab_list", "page_click", "page_drag", "page_type", "page_scroll", "page_hover", "page_eval", "page_snapshot", "page_press", "page_wait", "page_navigate", "page_upload", "page_frames", "set_device_emulation", "set_app_profile", "set_timezone", "get_cookie", "set_cookie", "record_start", "record_stop", "request_domain_permission", "get_user_info", "run_actions"];
2
+ export type ProviderAction = (typeof PROVIDER_ACTIONS)[number];
3
+ export declare function isProviderAction(value: unknown): value is ProviderAction;
@@ -0,0 +1,49 @@
1
+ // Generated by scripts/generate-provider-actions.mjs.
2
+ // Do not edit directly; update browser-core/src/browserActionProtocol.ts.
3
+ export const PROVIDER_ACTIONS = [
4
+ 'tab_checkpoint',
5
+ 'capture_checkpoint',
6
+ 'get_requests',
7
+ 'get_logs',
8
+ 'get_events',
9
+ 'get_api_schema',
10
+ 'set_mock',
11
+ 'get_mocks',
12
+ 'set_rule',
13
+ 'get_rules',
14
+ 'send_request',
15
+ 'send_mtop_request',
16
+ 'tdbank_account',
17
+ 'browser_release',
18
+ 'page_screenshot',
19
+ 'page_selected_element',
20
+ 'tab_open',
21
+ 'tab_close',
22
+ 'tab_list',
23
+ 'page_click',
24
+ 'page_drag',
25
+ 'page_type',
26
+ 'page_scroll',
27
+ 'page_hover',
28
+ 'page_eval',
29
+ 'page_snapshot',
30
+ 'page_press',
31
+ 'page_wait',
32
+ 'page_navigate',
33
+ 'page_upload',
34
+ 'page_frames',
35
+ 'set_device_emulation',
36
+ 'set_app_profile',
37
+ 'set_timezone',
38
+ 'get_cookie',
39
+ 'set_cookie',
40
+ 'record_start',
41
+ 'record_stop',
42
+ 'request_domain_permission',
43
+ 'get_user_info',
44
+ 'run_actions',
45
+ ];
46
+ const providerActionSet = new Set(PROVIDER_ACTIONS);
47
+ export function isProviderAction(value) {
48
+ return typeof value === 'string' && providerActionSet.has(value);
49
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { listInstalledProviders, registerProviderPackage, stopProvider, uninstallProvider, } from './registry.js';
2
+ export type { InstalledProviderManifest, ProviderPackageMetadata } from './types.js';
package/dist/host.js ADDED
@@ -0,0 +1 @@
1
+ export { listInstalledProviders, registerProviderPackage, stopProvider, uninstallProvider, } from './registry.js';
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { PROVIDER_ACTIONS, isProviderAction } from './generated-provider-actions.js';
3
+ export { providerDataDir } from './registry.js';
4
+ export { createProviderServer } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { PROVIDER_ACTIONS, isProviderAction } from './generated-provider-actions.js';
3
+ export { providerDataDir } from './registry.js';
4
+ export { createProviderServer } from './server.js';
@@ -0,0 +1,3 @@
1
+ export declare const PROVIDER_LOGS_SUBDIR = "providers/logs";
2
+ /** Start explicitly installed on-demand providers before browser discovery. */
3
+ export declare function ensureInstalledProvidersRunning(): Promise<string[]>;
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { hashKey, keyedDir, rotateLogIfNeeded } from '@mearl/daemon-core';
6
+ import { PROVIDER_START_TOKEN_ENV, claimProviderStart, listInstalledProviders, listLiveProviders, readLiveProvider, readProviderStart, releaseProviderStart, transferProviderStart, validateInstalledProviderManifest, } from './registry.js';
7
+ export const PROVIDER_LOGS_SUBDIR = 'providers/logs';
8
+ function providerLogFile(providerId) {
9
+ const hash = hashKey(providerId);
10
+ return join(keyedDir(PROVIDER_LOGS_SUBDIR), `${hash}.log`);
11
+ }
12
+ function liveProviderIds() {
13
+ return new Set(listLiveProviders().map(record => record.providerId));
14
+ }
15
+ function delay(ms) {
16
+ return new Promise(resolve => setTimeout(resolve, ms));
17
+ }
18
+ async function spawnProvider(manifest, logFile, startToken) {
19
+ mkdirSync(keyedDir(PROVIDER_LOGS_SUBDIR), { recursive: true, mode: 0o700 });
20
+ rotateLogIfNeeded(logFile);
21
+ const output = openSync(logFile, 'a');
22
+ try {
23
+ return await new Promise((resolve, reject) => {
24
+ const child = spawn(process.execPath, [manifest.entryPath], {
25
+ cwd: manifest.packageRoot,
26
+ env: {
27
+ ...process.env,
28
+ [PROVIDER_START_TOKEN_ENV]: startToken,
29
+ },
30
+ detached: true,
31
+ stdio: ['ignore', output, output],
32
+ windowsHide: true,
33
+ });
34
+ child.once('error', reject);
35
+ child.once('spawn', () => {
36
+ child.removeAllListeners('error');
37
+ child.on('error', () => { });
38
+ child.unref();
39
+ if (child.pid)
40
+ resolve(child.pid);
41
+ else
42
+ reject(new Error('spawned process has no pid'));
43
+ });
44
+ });
45
+ }
46
+ finally {
47
+ closeSync(output);
48
+ }
49
+ }
50
+ /** Start explicitly installed on-demand providers before browser discovery. */
51
+ export async function ensureInstalledProvidersRunning() {
52
+ const warnings = [];
53
+ const installed = listInstalledProviders();
54
+ const live = liveProviderIds();
55
+ const starting = new Map();
56
+ for (const manifest of installed) {
57
+ if (live.has(manifest.providerId))
58
+ continue;
59
+ const invalid = validateInstalledProviderManifest(manifest);
60
+ if (invalid) {
61
+ warnings.push(`provider ${manifest.providerId || '(unknown)'}: ${invalid}`);
62
+ continue;
63
+ }
64
+ const startToken = randomUUID();
65
+ if (!claimProviderStart(manifest.providerId, startToken)) {
66
+ if (readLiveProvider(manifest.providerId))
67
+ continue;
68
+ if (readProviderStart(manifest.providerId))
69
+ starting.set(manifest.providerId, null);
70
+ else
71
+ warnings.push(`provider ${manifest.providerId}: failed to acquire startup ownership`);
72
+ continue;
73
+ }
74
+ if (readLiveProvider(manifest.providerId)) {
75
+ releaseProviderStart(manifest.providerId, startToken);
76
+ continue;
77
+ }
78
+ const logFile = providerLogFile(manifest.providerId);
79
+ try {
80
+ const childPid = await spawnProvider(manifest, logFile, startToken);
81
+ transferProviderStart(manifest.providerId, startToken, childPid);
82
+ starting.set(manifest.providerId, startToken);
83
+ }
84
+ catch (error) {
85
+ releaseProviderStart(manifest.providerId, startToken);
86
+ warnings.push(`provider ${manifest.providerId}: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ }
89
+ if (starting.size === 0)
90
+ return warnings;
91
+ const deadline = Date.now() + 2_000;
92
+ let remaining = [...starting.keys()];
93
+ while (remaining.length > 0 && Date.now() < deadline) {
94
+ await delay(100);
95
+ const current = liveProviderIds();
96
+ remaining = remaining.filter(providerId => !current.has(providerId));
97
+ }
98
+ for (const [providerId, token] of starting) {
99
+ if (!remaining.includes(providerId) && token)
100
+ releaseProviderStart(providerId, token);
101
+ }
102
+ for (const providerId of remaining) {
103
+ const logFile = providerLogFile(providerId);
104
+ warnings.push(`provider ${providerId}: did not become ready within 2s${existsSync(logFile) ? `; see ${logFile}` : ''}`);
105
+ }
106
+ // A ready provider may still be publishing its first browser inventory.
107
+ await delay(100);
108
+ return warnings;
109
+ }
@@ -0,0 +1,85 @@
1
+ import { type DaemonRecord } from '@mearl/daemon-core';
2
+ import { type InstalledProviderManifest, type ProviderBrowser, type ProviderDescriptor } from './types.js';
3
+ export declare const INSTALLED_PROVIDERS_SUBDIR = "providers/installed";
4
+ export declare const LIVE_PROVIDERS_SUBDIR = "providers/live";
5
+ export declare const STARTING_PROVIDERS_SUBDIR = "providers/starting";
6
+ export declare const PROVIDER_SOCKETS_SUBDIR = "providers/sockets";
7
+ export declare const PROVIDER_DATA_SUBDIR = "providers/data";
8
+ export declare const BROWSERS_SUBDIR = "browsers";
9
+ export declare const PROVIDER_START_TOKEN_ENV = "MEARL_PROVIDER_START_TOKEN";
10
+ export interface LiveProviderRecord extends DaemonRecord {
11
+ providerId: string;
12
+ providerName: string;
13
+ providerVersion: string;
14
+ providerProtocolVersion: number;
15
+ socketPath: string;
16
+ startedAt: string;
17
+ updatedAt: number;
18
+ }
19
+ export interface ProviderStartRecord extends DaemonRecord {
20
+ providerId: string;
21
+ token: string;
22
+ startedAt: string;
23
+ }
24
+ export interface ProviderBrowserRecord extends DaemonRecord {
25
+ browserId: string;
26
+ providerBrowserId: string;
27
+ browserName: string;
28
+ socketPath: string;
29
+ transport: 'provider';
30
+ providerId: string;
31
+ providerName: string;
32
+ providerVersion: string;
33
+ providerProtocolVersion: number;
34
+ capabilities: ProviderBrowser['capabilities'];
35
+ status?: ProviderBrowser['status'];
36
+ pairing?: ProviderBrowser['pairing'];
37
+ startedAt: string;
38
+ updatedAt: number;
39
+ lastFocusedAt?: number;
40
+ }
41
+ export interface RegisteredBrowserRecord extends DaemonRecord {
42
+ browserId: string;
43
+ browserName: string;
44
+ socketPath: string;
45
+ transport: 'extension' | 'cdp' | 'provider';
46
+ extensionVersion?: string;
47
+ nativeHostVersion?: string;
48
+ providerId?: string;
49
+ providerName?: string;
50
+ providerVersion?: string;
51
+ providerProtocolVersion?: number;
52
+ capabilities?: ProviderBrowser['capabilities'];
53
+ status?: ProviderBrowser['status'];
54
+ pairing?: ProviderBrowser['pairing'];
55
+ startedAt?: string;
56
+ updatedAt?: number;
57
+ lastFocusedAt?: number;
58
+ }
59
+ export declare function providerBrowserKey(providerId: string, browserId: string): string;
60
+ export declare function providerSocketPath(providerId: string): string;
61
+ /** Persistent private data directory owned by one installed provider. */
62
+ export declare function providerDataDir(providerId: string): string;
63
+ export declare function installedProviderFile(providerId: string): string;
64
+ export declare function liveProviderFile(providerId: string): string;
65
+ export declare function startingProviderFile(providerId: string): string;
66
+ export declare function claimProviderStart(providerId: string, token: string, pid?: number): boolean;
67
+ export declare function readProviderStart(providerId: string): ProviderStartRecord | null;
68
+ export declare function transferProviderStart(providerId: string, token: string, pid: number): boolean;
69
+ export declare function releaseProviderStart(providerId: string, token: string): boolean;
70
+ export declare function validateInstalledProviderManifest(manifest: InstalledProviderManifest): string | null;
71
+ export declare function installProvider(manifest: InstalledProviderManifest): void;
72
+ export declare function registerProviderPackage(packageRoot: string): InstalledProviderManifest;
73
+ export declare function uninstallProvider(providerId: string): void;
74
+ export declare function stopProvider(providerId: string): boolean;
75
+ export declare function listInstalledProviders(): InstalledProviderManifest[];
76
+ export declare function readLiveProvider(providerId: string): LiveProviderRecord | null;
77
+ export declare function publishLiveProvider(descriptor: ProviderDescriptor, socketPath: string): LiveProviderRecord;
78
+ export declare function unpublishLiveProvider(providerId: string, pid?: number): void;
79
+ export declare function listLiveProviders(): LiveProviderRecord[];
80
+ export declare function publishProviderBrowsers(descriptor: ProviderDescriptor, socketPath: string, browsers: ProviderBrowser[], previousIds: ReadonlySet<string>): Set<string>;
81
+ export declare function touchProviderBrowser(browserId: string): void;
82
+ export declare function unpublishProviderBrowsers(browserIds: Iterable<string>, pid?: number): void;
83
+ export declare function listProviderBrowserRecords(): ProviderBrowserRecord[];
84
+ export declare function listRegisteredBrowserRecords(): RegisteredBrowserRecord[];
85
+ export declare function removeStaleProviderSocket(providerId: string, socketPath?: string): void;
@@ -0,0 +1,341 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { claimRecord, ensureDir, hashKey, isAlive, keyedConfigFile, keyedDir, listRecordFiles, readLiveRecords, readRecord, removeInvalidRecord, removeRecordIfPid, writeRecord, writeRecordIfPid, } from '@mearl/daemon-core';
5
+ import { MEARL_PROVIDER_PROTOCOL_VERSION, } from './types.js';
6
+ export const INSTALLED_PROVIDERS_SUBDIR = 'providers/installed';
7
+ export const LIVE_PROVIDERS_SUBDIR = 'providers/live';
8
+ export const STARTING_PROVIDERS_SUBDIR = 'providers/starting';
9
+ export const PROVIDER_SOCKETS_SUBDIR = 'providers/sockets';
10
+ export const PROVIDER_DATA_SUBDIR = 'providers/data';
11
+ export const BROWSERS_SUBDIR = 'browsers';
12
+ export const PROVIDER_START_TOKEN_ENV = 'MEARL_PROVIDER_START_TOKEN';
13
+ export function providerBrowserKey(providerId, browserId) {
14
+ return `provider:${encodeURIComponent(providerId)}:${encodeURIComponent(browserId)}`;
15
+ }
16
+ export function providerSocketPath(providerId) {
17
+ const suffix = hashKey(providerId);
18
+ if (process.platform === 'win32')
19
+ return `\\\\.\\pipe\\mearl-provider-${suffix}`;
20
+ return join(keyedDir(PROVIDER_SOCKETS_SUBDIR), `${suffix}.sock`);
21
+ }
22
+ /** Persistent private data directory owned by one installed provider. */
23
+ export function providerDataDir(providerId) {
24
+ return join(keyedDir(PROVIDER_DATA_SUBDIR), hashKey(providerId));
25
+ }
26
+ export function installedProviderFile(providerId) {
27
+ return keyedConfigFile(INSTALLED_PROVIDERS_SUBDIR, providerId);
28
+ }
29
+ export function liveProviderFile(providerId) {
30
+ return keyedConfigFile(LIVE_PROVIDERS_SUBDIR, providerId);
31
+ }
32
+ export function startingProviderFile(providerId) {
33
+ return keyedConfigFile(STARTING_PROVIDERS_SUBDIR, providerId);
34
+ }
35
+ export function claimProviderStart(providerId, token, pid = process.pid) {
36
+ const file = startingProviderFile(providerId);
37
+ const existing = readRecord(file);
38
+ if (existing && isAlive(existing.pid))
39
+ return existing.token === token;
40
+ if (existing)
41
+ removeRecordIfPid(file, existing.pid);
42
+ else
43
+ removeInvalidRecord(file);
44
+ return claimRecord(file, {
45
+ pid,
46
+ providerId,
47
+ token,
48
+ startedAt: new Date().toISOString(),
49
+ });
50
+ }
51
+ export function readProviderStart(providerId) {
52
+ const record = readRecord(startingProviderFile(providerId));
53
+ return record && isAlive(record.pid) ? record : null;
54
+ }
55
+ export function transferProviderStart(providerId, token, pid) {
56
+ const file = startingProviderFile(providerId);
57
+ const record = readRecord(file);
58
+ if (!record || record.token !== token)
59
+ return false;
60
+ return writeRecordIfPid(file, record.pid, { ...record, pid });
61
+ }
62
+ export function releaseProviderStart(providerId, token) {
63
+ const file = startingProviderFile(providerId);
64
+ const record = readRecord(file);
65
+ return record?.token === token ? removeRecordIfPid(file, record.pid) : false;
66
+ }
67
+ function writeManifest(file, manifest) {
68
+ ensureDir(keyedDir(INSTALLED_PROVIDERS_SUBDIR));
69
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
70
+ try {
71
+ writeFileSync(temporary, JSON.stringify(manifest), { mode: 0o600 });
72
+ renameSync(temporary, file);
73
+ }
74
+ catch (error) {
75
+ rmSync(temporary, { force: true });
76
+ throw error;
77
+ }
78
+ }
79
+ export function validateInstalledProviderManifest(manifest) {
80
+ if (!manifest ||
81
+ typeof manifest.providerId !== 'string' ||
82
+ !manifest.providerId.trim() ||
83
+ typeof manifest.name !== 'string' ||
84
+ !manifest.name.trim() ||
85
+ typeof manifest.version !== 'string' ||
86
+ !manifest.version.trim()) {
87
+ return 'manifest is incomplete';
88
+ }
89
+ if (manifest.protocolVersion !== MEARL_PROVIDER_PROTOCOL_VERSION) {
90
+ return `protocol ${manifest.protocolVersion} is unsupported`;
91
+ }
92
+ if (!isProviderBrowserType(manifest.browserType)) {
93
+ return 'browserType is invalid';
94
+ }
95
+ if (typeof manifest.packageName !== 'string' || !manifest.packageName.trim()) {
96
+ return 'packageName is required';
97
+ }
98
+ if (typeof manifest.packageRoot !== 'string' || !isAbsolute(manifest.packageRoot)) {
99
+ return 'packageRoot must be an absolute path';
100
+ }
101
+ if (typeof manifest.entryPath !== 'string' || !isAbsolute(manifest.entryPath)) {
102
+ return 'entryPath must be an absolute path';
103
+ }
104
+ const entryRelative = relative(manifest.packageRoot, manifest.entryPath);
105
+ if (entryRelative === '' ||
106
+ isAbsolute(entryRelative) ||
107
+ entryRelative === '..' ||
108
+ entryRelative.startsWith(`..${sep}`)) {
109
+ return 'entryPath must be a file inside packageRoot';
110
+ }
111
+ if (!isFile(manifest.entryPath))
112
+ return 'entryPath does not exist or is not a file';
113
+ return null;
114
+ }
115
+ export function installProvider(manifest) {
116
+ const invalid = validateInstalledProviderManifest(manifest);
117
+ if (invalid)
118
+ throw new Error(`Invalid provider manifest: ${invalid}`);
119
+ writeManifest(installedProviderFile(manifest.providerId), manifest);
120
+ }
121
+ function packageFile(packageRoot) {
122
+ return join(packageRoot, 'package.json');
123
+ }
124
+ function isFile(filePath) {
125
+ try {
126
+ return statSync(filePath).isFile();
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ function isProviderBrowserType(value) {
133
+ if (!value || typeof value !== 'object')
134
+ return false;
135
+ const browserType = value;
136
+ if (typeof browserType.name !== 'string' || !browserType.name.trim())
137
+ return false;
138
+ if (!Array.isArray(browserType.parameters))
139
+ return false;
140
+ const keys = new Set();
141
+ for (const parameter of browserType.parameters) {
142
+ if (!parameter ||
143
+ typeof parameter !== 'object' ||
144
+ typeof parameter.key !== 'string' ||
145
+ !/^[A-Za-z][A-Za-z0-9_]*$/.test(parameter.key) ||
146
+ typeof parameter.label !== 'string' ||
147
+ !parameter.label.trim() ||
148
+ (parameter.required !== undefined && typeof parameter.required !== 'boolean') ||
149
+ (parameter.placeholder !== undefined && typeof parameter.placeholder !== 'string') ||
150
+ (parameter.description !== undefined && typeof parameter.description !== 'string') ||
151
+ keys.has(parameter.key)) {
152
+ return false;
153
+ }
154
+ keys.add(parameter.key);
155
+ }
156
+ return true;
157
+ }
158
+ export function registerProviderPackage(packageRoot) {
159
+ if (!isAbsolute(packageRoot))
160
+ throw new Error('Provider packageRoot must be an absolute path');
161
+ let packageJson;
162
+ try {
163
+ packageJson = JSON.parse(readFileSync(packageFile(packageRoot), 'utf8'));
164
+ }
165
+ catch (error) {
166
+ throw new Error(`Unable to read Provider package metadata from ${packageRoot}`, {
167
+ cause: error,
168
+ });
169
+ }
170
+ const metadata = packageJson.mearl?.provider;
171
+ if (typeof packageJson.name !== 'string' ||
172
+ !packageJson.name.trim() ||
173
+ typeof packageJson.version !== 'string' ||
174
+ !packageJson.version.trim() ||
175
+ typeof metadata?.providerId !== 'string' ||
176
+ !metadata.providerId.trim() ||
177
+ typeof metadata.name !== 'string' ||
178
+ !metadata.name.trim() ||
179
+ metadata.protocolVersion !== MEARL_PROVIDER_PROTOCOL_VERSION ||
180
+ typeof metadata.entry !== 'string' ||
181
+ !metadata.entry.trim() ||
182
+ !isProviderBrowserType(metadata.browserType)) {
183
+ throw new Error(`Invalid mearl.provider metadata in ${packageFile(packageRoot)}`);
184
+ }
185
+ if (isAbsolute(metadata.entry)) {
186
+ throw new Error(`Provider entry must be relative to ${packageFile(packageRoot)}`);
187
+ }
188
+ const normalizedRoot = resolve(packageRoot);
189
+ const entryPath = resolve(normalizedRoot, metadata.entry);
190
+ const entryRelative = relative(normalizedRoot, entryPath);
191
+ if (isAbsolute(entryRelative) ||
192
+ entryRelative === '..' ||
193
+ entryRelative.startsWith(`..${sep}`) ||
194
+ !isFile(entryPath)) {
195
+ throw new Error(`Provider entry must be an existing file inside ${normalizedRoot}`);
196
+ }
197
+ const installed = listInstalledProviders();
198
+ const idConflict = installed.find(provider => provider.providerId === metadata.providerId && provider.packageName !== packageJson.name);
199
+ if (idConflict) {
200
+ throw new Error(`Provider id ${metadata.providerId} is already registered by ${idConflict.packageName}`);
201
+ }
202
+ const packageConflict = installed.find(provider => provider.packageName === packageJson.name && provider.providerId !== metadata.providerId);
203
+ if (packageConflict) {
204
+ throw new Error(`Package ${packageJson.name} is already registered as Provider ${packageConflict.providerId}`);
205
+ }
206
+ const manifest = {
207
+ providerId: metadata.providerId,
208
+ name: metadata.name,
209
+ version: packageJson.version,
210
+ protocolVersion: metadata.protocolVersion,
211
+ browserType: metadata.browserType,
212
+ packageName: packageJson.name,
213
+ packageRoot: normalizedRoot,
214
+ entryPath,
215
+ };
216
+ installProvider(manifest);
217
+ return manifest;
218
+ }
219
+ export function uninstallProvider(providerId) {
220
+ rmSync(installedProviderFile(providerId), { force: true });
221
+ }
222
+ export function stopProvider(providerId) {
223
+ const live = readLiveProvider(providerId);
224
+ if (!live)
225
+ return false;
226
+ try {
227
+ process.kill(live.pid, 'SIGTERM');
228
+ }
229
+ catch (error) {
230
+ if (error.code !== 'ESRCH')
231
+ throw error;
232
+ }
233
+ return true;
234
+ }
235
+ export function listInstalledProviders() {
236
+ const manifests = [];
237
+ for (const file of listRecordFiles(keyedDir(INSTALLED_PROVIDERS_SUBDIR))) {
238
+ try {
239
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
240
+ if (parsed &&
241
+ typeof parsed.providerId === 'string' &&
242
+ typeof parsed.packageName === 'string') {
243
+ manifests.push(parsed);
244
+ }
245
+ }
246
+ catch {
247
+ // A broken third-party manifest must not break discovery of other providers.
248
+ }
249
+ }
250
+ return manifests.sort((a, b) => a.providerId.localeCompare(b.providerId));
251
+ }
252
+ export function readLiveProvider(providerId) {
253
+ const record = readRecord(liveProviderFile(providerId));
254
+ return record && isAlive(record.pid) ? record : null;
255
+ }
256
+ export function publishLiveProvider(descriptor, socketPath) {
257
+ const now = Date.now();
258
+ const record = {
259
+ pid: process.pid,
260
+ providerId: descriptor.providerId,
261
+ providerName: descriptor.name,
262
+ providerVersion: descriptor.version,
263
+ providerProtocolVersion: descriptor.protocolVersion ?? MEARL_PROVIDER_PROTOCOL_VERSION,
264
+ socketPath,
265
+ startedAt: new Date(now).toISOString(),
266
+ updatedAt: now,
267
+ };
268
+ writeRecord(liveProviderFile(descriptor.providerId), record);
269
+ return record;
270
+ }
271
+ export function unpublishLiveProvider(providerId, pid = process.pid) {
272
+ removeRecordIfPid(liveProviderFile(providerId), pid);
273
+ }
274
+ export function listLiveProviders() {
275
+ return readLiveRecords(keyedDir(LIVE_PROVIDERS_SUBDIR));
276
+ }
277
+ export function publishProviderBrowsers(descriptor, socketPath, browsers, previousIds) {
278
+ const nextIds = new Set();
279
+ const now = Date.now();
280
+ const startedAt = new Date(now).toISOString();
281
+ for (const browser of browsers) {
282
+ const browserId = providerBrowserKey(descriptor.providerId, browser.browserId);
283
+ nextIds.add(browserId);
284
+ const file = keyedConfigFile(BROWSERS_SUBDIR, browserId);
285
+ const previous = readRecord(file);
286
+ const record = {
287
+ pid: process.pid,
288
+ browserId,
289
+ providerBrowserId: browser.browserId,
290
+ browserName: browser.name,
291
+ socketPath,
292
+ transport: 'provider',
293
+ providerId: descriptor.providerId,
294
+ providerName: descriptor.name,
295
+ providerVersion: descriptor.version,
296
+ providerProtocolVersion: descriptor.protocolVersion ?? MEARL_PROVIDER_PROTOCOL_VERSION,
297
+ capabilities: browser.capabilities,
298
+ status: browser.status ?? 'connected',
299
+ pairing: browser.pairing,
300
+ startedAt: previous?.startedAt ?? startedAt,
301
+ updatedAt: now,
302
+ lastFocusedAt: Math.max(browser.lastFocusedAt ?? 0, previous?.lastFocusedAt ?? 0),
303
+ };
304
+ writeRecord(file, record);
305
+ }
306
+ for (const browserId of previousIds) {
307
+ if (!nextIds.has(browserId)) {
308
+ removeRecordIfPid(keyedConfigFile(BROWSERS_SUBDIR, browserId), process.pid);
309
+ }
310
+ }
311
+ return nextIds;
312
+ }
313
+ export function touchProviderBrowser(browserId) {
314
+ const file = keyedConfigFile(BROWSERS_SUBDIR, browserId);
315
+ const record = readRecord(file);
316
+ if (!record || record.pid !== process.pid)
317
+ return;
318
+ writeRecordIfPid(file, process.pid, {
319
+ ...record,
320
+ lastFocusedAt: Date.now(),
321
+ updatedAt: Date.now(),
322
+ });
323
+ }
324
+ export function unpublishProviderBrowsers(browserIds, pid = process.pid) {
325
+ for (const browserId of browserIds) {
326
+ removeRecordIfPid(keyedConfigFile(BROWSERS_SUBDIR, browserId), pid);
327
+ }
328
+ }
329
+ export function listProviderBrowserRecords() {
330
+ return listRegisteredBrowserRecords().filter((record) => record.transport === 'provider');
331
+ }
332
+ export function listRegisteredBrowserRecords() {
333
+ return readLiveRecords(keyedDir(BROWSERS_SUBDIR));
334
+ }
335
+ export function removeStaleProviderSocket(providerId, socketPath) {
336
+ if (process.platform === 'win32')
337
+ return;
338
+ const socket = socketPath ?? providerSocketPath(providerId);
339
+ if (existsSync(socket) && !readLiveProvider(providerId))
340
+ rmSync(socket, { force: true });
341
+ }
@@ -0,0 +1,4 @@
1
+ /** Mearl host integration; Provider implementations should use the package root API. */
2
+ export { ensureInstalledProvidersRunning } from './launcher.js';
3
+ export { providerBrowserKey, readLiveProvider } from './registry.js';
4
+ export type { LiveProviderRecord } from './registry.js';
@@ -0,0 +1,3 @@
1
+ /** Mearl host integration; Provider implementations should use the package root API. */
2
+ export { ensureInstalledProvidersRunning } from './launcher.js';
3
+ export { providerBrowserKey, readLiveProvider } from './registry.js';
@@ -0,0 +1,2 @@
1
+ import type { ProviderServer, ProviderServerOptions } from './types.js';
2
+ export declare function createProviderServer(options: ProviderServerOptions): ProviderServer;
package/dist/server.js ADDED
@@ -0,0 +1,326 @@
1
+ import net from 'node:net';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { chmodSync, rmSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { ensureDir } from '@mearl/daemon-core';
6
+ import { isProviderAction } from './generated-provider-actions.js';
7
+ import { PROVIDER_START_TOKEN_ENV, claimProviderStart, providerBrowserKey, providerSocketPath, publishLiveProvider, publishProviderBrowsers, readLiveProvider, releaseProviderStart, removeStaleProviderSocket, touchProviderBrowser, unpublishLiveProvider, unpublishProviderBrowsers, } from './registry.js';
8
+ import { MEARL_PROVIDER_PROTOCOL_VERSION } from './types.js';
9
+ const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
10
+ const activeProviderIds = new Set();
11
+ function errorMessage(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ function isProviderActionRequest(value) {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value))
16
+ return false;
17
+ const request = value;
18
+ return (typeof request.id === 'string' &&
19
+ request.id.length > 0 &&
20
+ typeof request.action === 'string' &&
21
+ request.action.length > 0 &&
22
+ (request.data === undefined ||
23
+ (request.data !== null &&
24
+ typeof request.data === 'object' &&
25
+ !Array.isArray(request.data))) &&
26
+ (request.browserId === undefined || typeof request.browserId === 'string') &&
27
+ (request.version === undefined || typeof request.version === 'string') &&
28
+ (request.controlSource === undefined || typeof request.controlSource === 'string'));
29
+ }
30
+ export function createProviderServer(options) {
31
+ const socketPath = options.socketPath ?? providerSocketPath(options.descriptor.providerId);
32
+ let server = null;
33
+ let browserIds = new Set();
34
+ let browsers = [];
35
+ let state = 'idle';
36
+ let stopPromise = null;
37
+ const connections = new Set();
38
+ function assertDescriptor() {
39
+ const { providerId, name, version, protocolVersion } = options.descriptor;
40
+ if (typeof providerId !== 'string' || !providerId.trim()) {
41
+ throw new Error('providerId is required');
42
+ }
43
+ if (typeof name !== 'string' || !name.trim())
44
+ throw new Error('provider name is required');
45
+ if (typeof version !== 'string' || !version.trim()) {
46
+ throw new Error('provider version is required');
47
+ }
48
+ if (protocolVersion !== undefined && protocolVersion !== MEARL_PROVIDER_PROTOCOL_VERSION) {
49
+ throw new Error(`Unsupported provider protocol ${protocolVersion}; expected ${MEARL_PROVIDER_PROTOCOL_VERSION}`);
50
+ }
51
+ }
52
+ function assertBrowsers(nextBrowsers) {
53
+ const seen = new Set();
54
+ for (const browser of nextBrowsers) {
55
+ if (typeof browser.browserId !== 'string' || !browser.browserId.trim()) {
56
+ throw new Error('provider browserId is required');
57
+ }
58
+ if (typeof browser.name !== 'string' || !browser.name.trim()) {
59
+ throw new Error(`Provider browser ${browser.browserId} needs a name`);
60
+ }
61
+ if (seen.has(browser.browserId)) {
62
+ throw new Error(`Duplicate provider browserId: ${browser.browserId}`);
63
+ }
64
+ seen.add(browser.browserId);
65
+ if (!Array.isArray(browser.capabilities?.actions)) {
66
+ throw new Error(`Provider browser ${browser.browserId} needs an actions capability list`);
67
+ }
68
+ for (const action of browser.capabilities.actions) {
69
+ if (!isProviderAction(action)) {
70
+ throw new Error(`Unsupported provider capability action: ${String(action)}`);
71
+ }
72
+ }
73
+ if (new Set(browser.capabilities.actions).size !== browser.capabilities.actions.length) {
74
+ throw new Error(`Provider browser ${browser.browserId} has duplicate capability actions`);
75
+ }
76
+ if (!['native', 'reconstructed', 'none'].includes(browser.capabilities.screenshot)) {
77
+ throw new Error(`Unsupported screenshot capability: ${browser.capabilities.screenshot}`);
78
+ }
79
+ const supportsScreenshot = browser.capabilities.actions.includes('page_screenshot');
80
+ if (supportsScreenshot === (browser.capabilities.screenshot === 'none')) {
81
+ throw new Error(`Provider browser ${browser.browserId} has inconsistent screenshot capabilities`);
82
+ }
83
+ if (browser.status !== undefined && !['connected', 'pairing'].includes(browser.status)) {
84
+ throw new Error(`Unsupported provider browser status: ${String(browser.status)}`);
85
+ }
86
+ assertPairing(browser.status, browser.pairing, browser.browserId);
87
+ if (browser.lastFocusedAt !== undefined &&
88
+ (!Number.isFinite(browser.lastFocusedAt) || browser.lastFocusedAt < 0)) {
89
+ throw new Error(`Provider browser ${browser.browserId} has invalid lastFocusedAt`);
90
+ }
91
+ }
92
+ }
93
+ function providerBrowserId(requested) {
94
+ if (requested)
95
+ return requested;
96
+ if (browsers.length === 1)
97
+ return browsers[0].browserId;
98
+ if (browsers.length === 0)
99
+ throw new Error('Provider has no connected browsers');
100
+ throw new Error('browserId is required when the provider has multiple browsers');
101
+ }
102
+ async function dispatch(request) {
103
+ if (request.action === 'browser_launch') {
104
+ if (request.browserId)
105
+ throw new Error('browser_launch does not accept browserId');
106
+ const parameters = request.data ?? {};
107
+ if (Object.values(parameters).some(value => typeof value !== 'string')) {
108
+ throw new Error('Provider browser parameters must be strings');
109
+ }
110
+ const created = await options.createBrowser(parameters);
111
+ if (!created ||
112
+ typeof created.browserId !== 'string' ||
113
+ !created.browserId.trim() ||
114
+ typeof created.name !== 'string' ||
115
+ !created.name.trim() ||
116
+ !['connected', 'pairing'].includes(created.status)) {
117
+ throw new Error('Provider returned an invalid browser creation result');
118
+ }
119
+ try {
120
+ assertPairing(created.status, created.pairing, created.browserId);
121
+ }
122
+ catch {
123
+ throw new Error('Provider returned an invalid browser creation result');
124
+ }
125
+ return created;
126
+ }
127
+ if (request.action !== 'get_versions' &&
128
+ request.action !== 'browser_close' &&
129
+ !isProviderAction(request.action)) {
130
+ throw new Error(`Unsupported provider action: ${String(request.action)}`);
131
+ }
132
+ const browserId = providerBrowserId(request.browserId);
133
+ const found = browsers.find(browser => browser.browserId === browserId);
134
+ if (!found)
135
+ throw new Error(`Provider browser not found: ${browserId}`);
136
+ if (request.action === 'browser_close') {
137
+ return options.deleteBrowser(browserId);
138
+ }
139
+ if (request.action === 'get_versions') {
140
+ return {
141
+ provider: {
142
+ providerId: options.descriptor.providerId,
143
+ name: options.descriptor.name,
144
+ version: options.descriptor.version,
145
+ protocolVersion: options.descriptor.protocolVersion ?? MEARL_PROVIDER_PROTOCOL_VERSION,
146
+ },
147
+ capabilities: found.capabilities,
148
+ };
149
+ }
150
+ if (found.status === 'pairing') {
151
+ throw new Error(`Provider browser ${browserId} is waiting for device pairing`);
152
+ }
153
+ if (!found.capabilities.actions.includes(request.action)) {
154
+ throw new Error(`Provider browser ${browserId} does not support ${request.action}`);
155
+ }
156
+ touchProviderBrowser(providerBrowserKey(options.descriptor.providerId, browserId));
157
+ return options.handleAction({
158
+ browserId,
159
+ action: request.action,
160
+ data: request.data ?? {},
161
+ controlSource: request.controlSource,
162
+ });
163
+ }
164
+ function assertPairing(status, pairing, browserId) {
165
+ if (status === 'pairing' &&
166
+ (pairing?.kind !== 'qr' || typeof pairing.url !== 'string' || !pairing.url.trim())) {
167
+ throw new Error(`Pairing browser ${browserId} needs a pairing URL`);
168
+ }
169
+ if (status !== 'pairing' && pairing) {
170
+ throw new Error(`Pairing metadata requires status "pairing": ${browserId}`);
171
+ }
172
+ if (pairing &&
173
+ ((pairing.code !== undefined && typeof pairing.code !== 'string') ||
174
+ (pairing.label !== undefined && typeof pairing.label !== 'string'))) {
175
+ throw new Error(`Provider browser ${browserId} has invalid pairing metadata`);
176
+ }
177
+ }
178
+ function handleConnection(socket) {
179
+ connections.add(socket);
180
+ socket.once('close', () => connections.delete(socket));
181
+ let buffer = '';
182
+ socket.setEncoding('utf8');
183
+ socket.on('data', chunk => {
184
+ buffer += chunk;
185
+ if (Buffer.byteLength(buffer, 'utf8') > MAX_REQUEST_BYTES) {
186
+ socket.destroy();
187
+ return;
188
+ }
189
+ const newline = buffer.indexOf('\n');
190
+ if (newline < 0)
191
+ return;
192
+ const line = buffer.slice(0, newline);
193
+ buffer = buffer.slice(newline + 1);
194
+ let parsed;
195
+ try {
196
+ parsed = JSON.parse(line);
197
+ }
198
+ catch {
199
+ socket.end(`${JSON.stringify({ id: '', success: false, error: 'Invalid JSON request' })}\n`);
200
+ return;
201
+ }
202
+ if (!isProviderActionRequest(parsed)) {
203
+ const id = parsed &&
204
+ typeof parsed === 'object' &&
205
+ typeof parsed.id === 'string'
206
+ ? parsed.id
207
+ : '';
208
+ socket.end(`${JSON.stringify({ id, success: false, error: 'Invalid request envelope' })}\n`);
209
+ return;
210
+ }
211
+ const request = parsed;
212
+ void dispatch(request)
213
+ .then(data => socket.end(`${JSON.stringify({ id: request.id, success: true, data })}\n`))
214
+ .catch(error => socket.end(`${JSON.stringify({ id: request.id, success: false, error: errorMessage(error) })}\n`));
215
+ });
216
+ }
217
+ return {
218
+ socketPath,
219
+ async start() {
220
+ if (state !== 'idle')
221
+ throw new Error(`Provider server is already ${state}`);
222
+ assertDescriptor();
223
+ const providerId = options.descriptor.providerId;
224
+ if (activeProviderIds.has(providerId)) {
225
+ throw new Error(`Provider ${providerId} is already running in this process`);
226
+ }
227
+ state = 'starting';
228
+ activeProviderIds.add(providerId);
229
+ const startToken = process.env[PROVIDER_START_TOKEN_ENV] || randomUUID();
230
+ const existing = readLiveProvider(options.descriptor.providerId);
231
+ if (existing) {
232
+ state = 'idle';
233
+ activeProviderIds.delete(providerId);
234
+ throw new Error(`Provider ${options.descriptor.providerId} is already running (pid ${existing.pid})`);
235
+ }
236
+ if (!claimProviderStart(providerId, startToken)) {
237
+ state = 'idle';
238
+ activeProviderIds.delete(providerId);
239
+ throw new Error(`Provider ${providerId} start is already in progress`);
240
+ }
241
+ try {
242
+ const competing = readLiveProvider(providerId);
243
+ if (competing) {
244
+ throw new Error(`Provider ${providerId} is already running (pid ${competing.pid})`);
245
+ }
246
+ removeStaleProviderSocket(providerId, socketPath);
247
+ if (process.platform !== 'win32')
248
+ ensureDir(dirname(socketPath));
249
+ server = net.createServer(handleConnection);
250
+ await new Promise((resolve, reject) => {
251
+ server.once('error', reject);
252
+ server.listen(socketPath, () => {
253
+ server.off('error', reject);
254
+ resolve();
255
+ });
256
+ });
257
+ if (process.platform !== 'win32')
258
+ chmodSync(socketPath, 0o600);
259
+ publishLiveProvider(options.descriptor, socketPath);
260
+ releaseProviderStart(providerId, startToken);
261
+ state = 'running';
262
+ }
263
+ catch (error) {
264
+ const active = server;
265
+ server = null;
266
+ for (const connection of connections)
267
+ connection.destroy();
268
+ connections.clear();
269
+ if (active?.listening) {
270
+ await new Promise(resolve => active.close(() => resolve()));
271
+ }
272
+ unpublishLiveProvider(providerId);
273
+ if (process.platform !== 'win32')
274
+ rmSync(socketPath, { force: true });
275
+ releaseProviderStart(providerId, startToken);
276
+ activeProviderIds.delete(providerId);
277
+ state = 'idle';
278
+ throw error;
279
+ }
280
+ },
281
+ updateBrowsers(nextBrowsers) {
282
+ if (state !== 'running')
283
+ throw new Error('Provider server is not running');
284
+ assertBrowsers(nextBrowsers);
285
+ browsers = [...nextBrowsers];
286
+ browserIds = publishProviderBrowsers(options.descriptor, socketPath, browsers, browserIds);
287
+ },
288
+ touchBrowser(browserId) {
289
+ if (state !== 'running')
290
+ throw new Error('Provider server is not running');
291
+ if (!browsers.some(browser => browser.browserId === browserId)) {
292
+ throw new Error(`Provider browser not found: ${browserId}`);
293
+ }
294
+ touchProviderBrowser(providerBrowserKey(options.descriptor.providerId, browserId));
295
+ },
296
+ async stop() {
297
+ if (state === 'idle')
298
+ return;
299
+ if (state === 'starting')
300
+ throw new Error('Provider server is still starting');
301
+ if (state === 'stopping' && stopPromise)
302
+ return stopPromise;
303
+ state = 'stopping';
304
+ stopPromise = (async () => {
305
+ const active = server;
306
+ server = null;
307
+ for (const connection of connections)
308
+ connection.destroy();
309
+ connections.clear();
310
+ if (active)
311
+ await new Promise(resolve => active.close(() => resolve()));
312
+ unpublishProviderBrowsers(browserIds);
313
+ browserIds.clear();
314
+ browsers = [];
315
+ if (process.platform !== 'win32')
316
+ rmSync(socketPath, { force: true });
317
+ unpublishLiveProvider(options.descriptor.providerId);
318
+ })().finally(() => {
319
+ activeProviderIds.delete(options.descriptor.providerId);
320
+ stopPromise = null;
321
+ state = 'idle';
322
+ });
323
+ return stopPromise;
324
+ },
325
+ };
326
+ }
@@ -0,0 +1,98 @@
1
+ import type { ProviderAction } from './generated-provider-actions.js';
2
+ export declare const MEARL_PROVIDER_PROTOCOL_VERSION = 1;
3
+ export type { ProviderAction } from './generated-provider-actions.js';
4
+ export type ProviderScreenshotKind = 'native' | 'reconstructed' | 'none';
5
+ export interface ProviderCapabilities {
6
+ actions: ProviderAction[];
7
+ screenshot: ProviderScreenshotKind;
8
+ }
9
+ export interface ProviderPairingInfo {
10
+ kind: 'qr';
11
+ url: string;
12
+ code?: string;
13
+ label?: string;
14
+ }
15
+ export interface ProviderDescriptor {
16
+ providerId: string;
17
+ name: string;
18
+ version: string;
19
+ protocolVersion?: number;
20
+ }
21
+ export interface ProviderBrowserParameter {
22
+ key: string;
23
+ label: string;
24
+ required?: boolean;
25
+ placeholder?: string;
26
+ description?: string;
27
+ }
28
+ export interface ProviderBrowserType {
29
+ name: string;
30
+ parameters: ProviderBrowserParameter[];
31
+ }
32
+ export interface ProviderBrowser {
33
+ /** Stable within this provider. Mearl prefixes it with the provider id globally. */
34
+ browserId: string;
35
+ name: string;
36
+ status?: 'connected' | 'pairing';
37
+ lastFocusedAt?: number;
38
+ capabilities: ProviderCapabilities;
39
+ /** Pairing metadata is valid only while status is pairing. */
40
+ pairing?: ProviderPairingInfo;
41
+ }
42
+ export interface InstalledProviderManifest extends ProviderDescriptor {
43
+ protocolVersion: number;
44
+ browserType: ProviderBrowserType;
45
+ packageName: string;
46
+ packageRoot: string;
47
+ entryPath: string;
48
+ }
49
+ export interface ProviderPackageMetadata {
50
+ providerId: string;
51
+ name: string;
52
+ protocolVersion: number;
53
+ entry: string;
54
+ browserType: ProviderBrowserType;
55
+ }
56
+ export interface ProviderActionRequest {
57
+ id: string;
58
+ action: string;
59
+ data?: Record<string, unknown>;
60
+ /** Provider-local browser id selected by the Mearl client. */
61
+ browserId?: string;
62
+ version?: string;
63
+ controlSource?: string;
64
+ }
65
+ export interface ProviderActionContext {
66
+ browserId: string;
67
+ action: ProviderAction;
68
+ data: Record<string, unknown>;
69
+ controlSource?: string;
70
+ }
71
+ export type ProviderActionHandler = (context: ProviderActionContext) => Promise<unknown>;
72
+ interface ProviderBrowserCreateResultBase {
73
+ browserId: string;
74
+ name: string;
75
+ }
76
+ export type ProviderBrowserCreateResult = (ProviderBrowserCreateResultBase & {
77
+ status: 'connected';
78
+ pairing?: never;
79
+ }) | (ProviderBrowserCreateResultBase & {
80
+ status: 'pairing';
81
+ pairing: ProviderPairingInfo;
82
+ });
83
+ export type ProviderBrowserCreateHandler = (parameters: Record<string, string>) => Promise<ProviderBrowserCreateResult>;
84
+ export type ProviderBrowserDeleteHandler = (browserId: string) => Promise<unknown>;
85
+ export interface ProviderServerOptions {
86
+ descriptor: ProviderDescriptor;
87
+ handleAction: ProviderActionHandler;
88
+ createBrowser: ProviderBrowserCreateHandler;
89
+ deleteBrowser: ProviderBrowserDeleteHandler;
90
+ socketPath?: string;
91
+ }
92
+ export interface ProviderServer {
93
+ readonly socketPath: string;
94
+ start(): Promise<void>;
95
+ updateBrowsers(browsers: ProviderBrowser[]): void;
96
+ touchBrowser(browserId: string): void;
97
+ stop(): Promise<void>;
98
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export const MEARL_PROVIDER_PROTOCOL_VERSION = 1;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@mearl/provider",
3
+ "version": "2.13.0",
4
+ "description": "Contracts and runtime utilities for external Mearl browser-control providers",
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
+ "./runtime": {
14
+ "types": "./dist/runtime.d.ts",
15
+ "import": "./dist/runtime.js"
16
+ },
17
+ "./host": {
18
+ "types": "./dist/host.d.ts",
19
+ "import": "./dist/host.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "!dist/**/*.map"
25
+ ],
26
+ "keywords": [
27
+ "mearl",
28
+ "browser-provider"
29
+ ],
30
+ "license": "ISC",
31
+ "engines": {
32
+ "node": ">=22.12.0"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org"
36
+ },
37
+ "dependencies": {
38
+ "@mearl/daemon-core": "2.13.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.9.1",
42
+ "typescript": "^5.8.3"
43
+ },
44
+ "scripts": {
45
+ "build": "node ../../scripts/generate-provider-actions.mjs --check && rm -rf dist/ && tsc",
46
+ "typecheck": "node ../../scripts/generate-provider-actions.mjs --check && tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
49
+ }