@anhlp/alp-client 0.0.1-beta.3 → 0.0.1-beta.4
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 +6 -1
- package/dist/index.d.ts +37 -2
- package/dist/index.js +55 -22
- package/dist/integration-lock.d.ts +35 -0
- package/dist/integration-lock.js +179 -0
- package/dist/portable.d.ts +1 -0
- package/dist/portable.js +7 -1
- package/dist/rpc/registry.d.ts +211 -0
- package/dist/rpc/registry.js +145 -0
- package/dist/supervise.d.ts +2 -0
- package/dist/supervise.js +4 -4
- package/package.json +11 -3
- package/src/index.js +89 -21
- package/src/integration-lock.js +179 -0
- package/src/portable.js +1 -0
- package/src/rpc/registry.js +161 -0
- package/src/supervise.d.ts +2 -0
- package/src/supervise.js +8 -5
package/README.md
CHANGED
|
@@ -11,13 +11,18 @@ and setup helpers.
|
|
|
11
11
|
- `@anhlp/alp-client` (Node): everything above plus `connect`, `AlpClient`, `connectSocket`,
|
|
12
12
|
`ensureDaemon`, the private-socket checks and lock helpers, unchanged for existing callers.
|
|
13
13
|
- `@anhlp/alp-client/ipc` (Node): `createSocketTransport` for a Unix socket or named pipe.
|
|
14
|
+
- `@anhlp/alp-client/integration-lock` (Node): `createIntegrationLock(home, name)`, the single-owner
|
|
15
|
+
lock of one integration service at `<home>/integrations/<name>.lock`, serialized by the exclusive
|
|
16
|
+
guard directory `<name>.lock.reclaim`. Each instance is single-use (`acquire`, then `setReady` and
|
|
17
|
+
`release` while it owns the lock); its recovery rules are in
|
|
18
|
+
[docs/modular-monorepo.md](../../docs/modular-monorepo.md).
|
|
14
19
|
|
|
15
20
|
Timeouts and aborts end only the caller's wait, and nothing is ever resent after a disconnect.
|
|
16
21
|
See [docs/alp/client-sdk.md](../../docs/alp/client-sdk.md) for the transport contract, the
|
|
17
22
|
request lifecycle, the old-to-new API table and what still waits on the protocol (Task 06) and
|
|
18
23
|
the gateway (Task 08).
|
|
19
24
|
|
|
20
|
-
`startIntegration` discovers an optional integration's descriptor and starts it detached with `--service`, so the entry writes its own log; discovery and the selected `ALP_HOME` are pinned by tests. Restarting is non-destructive: a daemon left running by an older install is reported as older than the current CLI and is never killed in place.
|
|
25
|
+
`startIntegration({ name, entry, home, args })` (or `startIntegration(name)`; `name` defaults to `web`) discovers an optional integration's descriptor and starts it detached with `--service` and then `args`, so the entry writes its own log; discovery and the selected `ALP_HOME` are pinned by tests. `name` is a single lowercase path segment (`assertIntegrationName`). With `entry`, that script runs with the current Node and no descriptor, environment or PATH discovery happens; a missing entry is an error even when a live owner would be reused. `web` is alp-web, the service of the ALP CLI (`alp web`): it is never discovered, so it needs its `entry`, and `"web": { "enabled": false }` in the home's settings refuses it and no other integration. Everything once registered under `web` (`integrations/web.json`, the `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its rename; `discoverIntegration('paseo')` reads `ALP_PASEO_INTEGRATION`, then `ALP_WEB_INTEGRATION` for one release, then `ALP_INTEGRATION_ENTRY`. Errors name the integration, the entry and its log under `<home>/logs/integrations`. Either way a live owner of `integrations/<name>.lock` is reused, and the call resolves once that lock is ready and `<home>/<name>.json` names the same pid. Restarting is non-destructive: a daemon left running by an older install is reported as older than the current CLI and is never killed in place.
|
|
21
26
|
|
|
22
27
|
Build: `npm run build --workspace=@anhlp/alp-client`. Test: `npm run test --workspace=@anhlp/alp-client`.
|
|
23
28
|
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export function findDaemonEntry({ home, env, candidates }?: {
|
|
|
21
21
|
env?: NodeJS.ProcessEnv | undefined;
|
|
22
22
|
candidates?: (string | undefined)[] | undefined;
|
|
23
23
|
}): Promise<string | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* Integration names are single lowercase path segments: they name `<home>/integrations/<name>.lock`,
|
|
26
|
+
* its guard and `<home>/<name>.json`. Returns the name, or throws a TypeError.
|
|
27
|
+
* @param {unknown} name
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function assertIntegrationName(name: unknown): string;
|
|
24
31
|
/** Wall-clock boot time; a lock from an earlier boot is stale even if its pid is reused. */
|
|
25
32
|
export function bootTime(): number;
|
|
26
33
|
export function readLock(home: any): Promise<any>;
|
|
@@ -95,8 +102,34 @@ export function ensureDaemon({ home, entry, env, execPath, timeoutMs, lifecycleL
|
|
|
95
102
|
timeoutMs?: number | undefined;
|
|
96
103
|
lifecycleLocked?: boolean | undefined;
|
|
97
104
|
}): Promise<any>;
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The entry of integration `name`: an environment override, a descriptor under the home or a
|
|
107
|
+
* distribution root, or an `alp-<name>-integration` binary. Discovery never returns an entry for
|
|
108
|
+
* `web`: that name belongs to alp-web, which ships with the CLI and is started with its explicit
|
|
109
|
+
* entry. Everything once registered under `web` (the descriptor `integrations/web.json`, the
|
|
110
|
+
* `alp-web-integration` binary, `ALP_WEB_INTEGRATION`) is the Paseo integration from before its
|
|
111
|
+
* rename to `paseo`, which must never run under the web lock. `ALP_WEB_INTEGRATION` is read for
|
|
112
|
+
* `paseo` only, after `ALP_PASEO_INTEGRATION`, for one release (like the alp-web-integration alias).
|
|
113
|
+
* @param {string} [name]
|
|
114
|
+
* @param {{ home?: string }} [options]
|
|
115
|
+
*/
|
|
116
|
+
export function discoverIntegration(name?: string, options?: {
|
|
117
|
+
home?: string;
|
|
118
|
+
}): Promise<string | undefined>;
|
|
119
|
+
/**
|
|
120
|
+
* Starts integration `name` (default `web`) detached with `--service` (then `args`) unless its lock
|
|
121
|
+
* already names a live owner, then waits until its ready lock and `<home>/<name>.json` name the same
|
|
122
|
+
* pid. `entry` names the script to run with this Node and skips descriptor, environment and PATH
|
|
123
|
+
* discovery; `web` (alp-web) is never discovered, so it needs its entry. `"web": { "enabled": false }`
|
|
124
|
+
* in the home's settings.json refuses `web` only.
|
|
125
|
+
* @param {string | { name?: string, entry?: string, home?: string, args?: string[] }} [nameOrOptions]
|
|
126
|
+
*/
|
|
127
|
+
export function startIntegration(nameOrOptions?: string | {
|
|
128
|
+
name?: string;
|
|
129
|
+
entry?: string;
|
|
130
|
+
home?: string;
|
|
131
|
+
args?: string[];
|
|
132
|
+
}): Promise<any>;
|
|
100
133
|
export * from "./portable.js";
|
|
101
134
|
export { createSocketTransport } from "./ipc.js";
|
|
102
135
|
/**
|
|
@@ -110,6 +143,8 @@ export class AlpClient extends AlpRpcClient {
|
|
|
110
143
|
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
111
144
|
*/
|
|
112
145
|
constructor(socket: import("node:net").Socket, { maxFrame, maxBufferedBytes, ...options }?: import("./rpc/client.js").AlpRpcClientOptions & import("./ipc.js").SocketTransportOptions);
|
|
146
|
+
/** Host features captured by connect's handshake. @type {readonly string[]} */
|
|
147
|
+
capabilities: readonly string[];
|
|
113
148
|
}
|
|
114
149
|
import { AlpRpcClient } from './rpc/client.js';
|
|
115
150
|
export { PROTOCOL_VERSION, MAX_FRAME, AlpRpcError, encodeFrame } from "@anhlp/alp-protocol";
|
package/dist/index.js
CHANGED
|
@@ -98,6 +98,10 @@ async function findDaemonEntry({ home = alpHome(), env = process.env, candidates
|
|
|
98
98
|
}
|
|
99
99
|
return void 0;
|
|
100
100
|
}
|
|
101
|
+
function assertIntegrationName(name) {
|
|
102
|
+
if (typeof name !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new TypeError(`Invalid integration name: ${JSON.stringify(name)}`);
|
|
103
|
+
return name;
|
|
104
|
+
}
|
|
101
105
|
function bootTime() {
|
|
102
106
|
return Math.round((Date.now() - os.uptime() * 1e3) / 1e3);
|
|
103
107
|
}
|
|
@@ -144,6 +148,8 @@ async function releaseDaemonLock(home) {
|
|
|
144
148
|
});
|
|
145
149
|
}
|
|
146
150
|
var AlpClient = class extends AlpRpcClient {
|
|
151
|
+
/** Host features captured by connect's handshake. @type {readonly string[]} */
|
|
152
|
+
capabilities = [];
|
|
147
153
|
/**
|
|
148
154
|
* @param {import('node:net').Socket} socket a connected or connecting socket; the client owns it
|
|
149
155
|
* @param {import('./rpc/client.js').AlpRpcClientOptions & import('./ipc.js').SocketTransportOptions} [options]
|
|
@@ -172,7 +178,8 @@ async function connectSocket(socketPath, options) {
|
|
|
172
178
|
async function connect(socketPath, client = { name: "alp", version: "0" }, options = {}) {
|
|
173
179
|
const connection = new AlpClient(await openSocket(socketPath), options);
|
|
174
180
|
try {
|
|
175
|
-
await hello(connection, client);
|
|
181
|
+
const greeting = await hello(connection, client);
|
|
182
|
+
connection.capabilities = Object.freeze(Array.isArray(greeting.capabilities) ? greeting.capabilities.filter((value) => typeof value === "string") : []);
|
|
176
183
|
} catch (error) {
|
|
177
184
|
connection.close();
|
|
178
185
|
throw error;
|
|
@@ -346,7 +353,7 @@ async function integrationCandidateFromHome(home, name) {
|
|
|
346
353
|
return await isFile(legacy) ? legacy : void 0;
|
|
347
354
|
}
|
|
348
355
|
function integrationBinaryName(name) {
|
|
349
|
-
return
|
|
356
|
+
return `alp-${name}-integration`;
|
|
350
357
|
}
|
|
351
358
|
async function distributionRoots() {
|
|
352
359
|
const roots = [];
|
|
@@ -366,10 +373,12 @@ async function distributionRoots() {
|
|
|
366
373
|
return [...new Set(roots)];
|
|
367
374
|
}
|
|
368
375
|
async function discoverIntegration(name = "web", options = {}) {
|
|
376
|
+
assertIntegrationName(name);
|
|
377
|
+
if (name === "web") return void 0;
|
|
369
378
|
const envCandidates = [
|
|
370
|
-
process.env.
|
|
371
|
-
process.env.
|
|
372
|
-
|
|
379
|
+
process.env[`ALP_${name.toUpperCase()}_INTEGRATION`],
|
|
380
|
+
name === "paseo" ? process.env.ALP_WEB_INTEGRATION : void 0,
|
|
381
|
+
process.env.ALP_INTEGRATION_ENTRY
|
|
373
382
|
].filter(Boolean);
|
|
374
383
|
for (const candidate of envCandidates) if (await isFile(candidate)) return candidate;
|
|
375
384
|
const home = options?.home ? path.resolve(options.home) : alpHome();
|
|
@@ -398,21 +407,46 @@ function integrationAlive(lock) {
|
|
|
398
407
|
if (typeof lock.bootTime === "number" && Math.abs(lock.bootTime - bootTime()) > 30) return false;
|
|
399
408
|
return true;
|
|
400
409
|
}
|
|
410
|
+
function integrationText(name, home) {
|
|
411
|
+
const logs = path.join(home, "logs", "integrations");
|
|
412
|
+
if (name === "web") return {
|
|
413
|
+
what: "alp web",
|
|
414
|
+
log: path.join(logs, "alp-web.log"),
|
|
415
|
+
missing: "alp web runs the alp-web service of the ALP CLI, which is started with its entry (packages/cli/dist/alp-web.js); run `alp web`"
|
|
416
|
+
};
|
|
417
|
+
if (name === "paseo") return {
|
|
418
|
+
what: "The Paseo integration",
|
|
419
|
+
log: path.join(logs, "paseo.log"),
|
|
420
|
+
missing: "The Paseo integration is an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`), then run the command again."
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
what: `ALP integration ${name}`,
|
|
424
|
+
log: path.join(logs, `${name}.log`),
|
|
425
|
+
missing: `ALP integration ${name} is not installed: no ALP_${name.toUpperCase()}_INTEGRATION, descriptor <home>/integrations/${name}.json or alp-${name}-integration binary was found.`
|
|
426
|
+
};
|
|
427
|
+
}
|
|
401
428
|
async function startIntegration(nameOrOptions = "web") {
|
|
402
429
|
const options = typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions ?? {};
|
|
403
|
-
const name = options.name ?? "web";
|
|
430
|
+
const name = assertIntegrationName(options.name ?? "web");
|
|
431
|
+
if (options.entry !== void 0 && (typeof options.entry !== "string" || !options.entry)) throw new TypeError("startIntegration entry must be a non-empty path");
|
|
432
|
+
const args = options.args ?? [];
|
|
433
|
+
if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) throw new TypeError("startIntegration args must be a list of strings");
|
|
404
434
|
const fs = await import("node:fs/promises");
|
|
405
435
|
const pathMod = await import("node:path");
|
|
406
436
|
const home = options.home ? pathMod.resolve(options.home) : alpHome();
|
|
437
|
+
const text = integrationText(name, home);
|
|
438
|
+
const explicit = options.entry !== void 0 ? pathMod.resolve(options.entry) : void 0;
|
|
439
|
+
if (explicit !== void 0 && !await isFile(explicit)) throw new Error(`${text.what}: integration entry not found: ${explicit}`);
|
|
407
440
|
const lockPath = pathMod.join(home, "integrations", `${name}.lock`);
|
|
408
441
|
const webPath = pathMod.join(home, `${name}.json`);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
settings =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
442
|
+
const started = explicit ? ` (entry ${explicit})` : "";
|
|
443
|
+
if (name === "web") {
|
|
444
|
+
let settings = {};
|
|
445
|
+
try {
|
|
446
|
+
settings = JSON.parse(await fs.readFile(pathMod.join(home, "settings.json"), "utf8"));
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
if (settings?.web?.enabled === false) throw new Error('alp web is disabled by settings ("web.enabled": false).');
|
|
416
450
|
}
|
|
417
451
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
452
|
const readLock2 = async () => {
|
|
@@ -440,20 +474,18 @@ async function startIntegration(nameOrOptions = "web") {
|
|
|
440
474
|
await sleep(100);
|
|
441
475
|
}
|
|
442
476
|
const lock = await readLock2();
|
|
443
|
-
if (lock && !lock.ready) throw new Error(
|
|
444
|
-
if (!lock) throw new Error(
|
|
445
|
-
throw new Error(
|
|
477
|
+
if (lock && !lock.ready) throw new Error(`${text.what}${started} started but did not become ready; see ${text.log}`);
|
|
478
|
+
if (!lock) throw new Error(explicit ? `${text.what}${started} did not start; see ${text.log}` : text.missing);
|
|
479
|
+
throw new Error(`${text.what}${started} did not publish a ${name}.json matching its lock; see ${text.log}`);
|
|
446
480
|
}
|
|
447
481
|
const existing = await readLock2();
|
|
448
482
|
if (existing && integrationAlive(existing)) {
|
|
449
483
|
return waitForReady();
|
|
450
484
|
}
|
|
451
|
-
const entry = await discoverIntegration(name, { home });
|
|
452
|
-
if (!entry)
|
|
453
|
-
throw new Error("ALP web app is provided by an optional integration package. Install it (for example `npm install -g @anhlp/alp-paseo@beta`) and run `alp web` again.");
|
|
454
|
-
}
|
|
485
|
+
const entry = explicit ?? await discoverIntegration(name, { home });
|
|
486
|
+
if (!entry) throw new Error(text.missing);
|
|
455
487
|
const { spawn: spawn2 } = await import("node:child_process");
|
|
456
|
-
const child = spawn2(process.execPath, [entry, "--service"], { stdio: "ignore", detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
488
|
+
const child = spawn2(process.execPath, [entry, "--service", ...args], { stdio: "ignore", detached: true, env: { ...process.env, ALP_HOME: home } });
|
|
457
489
|
let spawnError = null;
|
|
458
490
|
child.on("error", (error) => {
|
|
459
491
|
spawnError = error;
|
|
@@ -466,7 +498,7 @@ async function startIntegration(nameOrOptions = "web") {
|
|
|
466
498
|
if (spawnError) throw spawnError;
|
|
467
499
|
await sleep(150);
|
|
468
500
|
if (spawnError) throw spawnError;
|
|
469
|
-
if (exited) throw new Error(
|
|
501
|
+
if (exited) throw new Error(`${text.what}${explicit ? started : ` (${entry})`} failed to start; see ${text.log}`);
|
|
470
502
|
return waitForReady();
|
|
471
503
|
}
|
|
472
504
|
export {
|
|
@@ -477,6 +509,7 @@ export {
|
|
|
477
509
|
PROTOCOL_VERSION,
|
|
478
510
|
acquireDaemonLock,
|
|
479
511
|
alpHome,
|
|
512
|
+
assertIntegrationName,
|
|
480
513
|
assertPrivateDirectory,
|
|
481
514
|
assertPrivateSocket,
|
|
482
515
|
bootTime,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ pid: number, bootTime: number, startedAt: string, ready: boolean, lockVersion?: number, owner?: string }} IntegrationLockRecord
|
|
3
|
+
* @typedef {{
|
|
4
|
+
* acquire(): Promise<{ acquired: boolean, lock: IntegrationLockRecord }>,
|
|
5
|
+
* setReady(ready: boolean): Promise<boolean>,
|
|
6
|
+
* release(): Promise<boolean>,
|
|
7
|
+
* }} IntegrationLock
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The single-owner lock of one integration service, at `<home>/integrations/<name>.lock`, with its
|
|
11
|
+
* mutation guard `<name>.lock.reclaim`. Each instance is single-use: acquire once, then setReady and
|
|
12
|
+
* release while it owns the lock.
|
|
13
|
+
*
|
|
14
|
+
* Single-host, same PID namespace, private local filesystem only. Never reap the mutation guard.
|
|
15
|
+
* @param {string} home the ALP_HOME the integration runs under
|
|
16
|
+
* @param {string} name the integration name, for example `web`
|
|
17
|
+
* @returns {IntegrationLock}
|
|
18
|
+
*/
|
|
19
|
+
export function createIntegrationLock(home: string, name: string): IntegrationLock;
|
|
20
|
+
export type IntegrationLockRecord = {
|
|
21
|
+
pid: number;
|
|
22
|
+
bootTime: number;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
lockVersion?: number;
|
|
26
|
+
owner?: string;
|
|
27
|
+
};
|
|
28
|
+
export type IntegrationLock = {
|
|
29
|
+
acquire(): Promise<{
|
|
30
|
+
acquired: boolean;
|
|
31
|
+
lock: IntegrationLockRecord;
|
|
32
|
+
}>;
|
|
33
|
+
setReady(ready: boolean): Promise<boolean>;
|
|
34
|
+
release(): Promise<boolean>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// packages/client/src/integration-lock.js
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, rename, rmdir, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertIntegrationName, bootTime } from "./index.js";
|
|
6
|
+
function createIntegrationLock(home, name) {
|
|
7
|
+
const file = path.join(home, "integrations", `${assertIntegrationName(name)}.lock`);
|
|
8
|
+
const guard = `${file}.reclaim`;
|
|
9
|
+
let phase = "new";
|
|
10
|
+
let owned;
|
|
11
|
+
const recovery = (reason) => new Error(`${reason}. Stop ALL integration processes using this ALP_HOME before manual lock-only recovery of ${file} and ${guard}; see docs/modular-monorepo.md`);
|
|
12
|
+
async function guarded(run) {
|
|
13
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
14
|
+
const deadline = Date.now() + 2e4;
|
|
15
|
+
for (; ; ) {
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(guard, { mode: 448 });
|
|
18
|
+
break;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (
|
|
21
|
+
/** @type {any} */
|
|
22
|
+
error?.code !== "EEXIST"
|
|
23
|
+
) throw error;
|
|
24
|
+
if (Date.now() >= deadline) throw recovery("Integration mutation guard remains busy or abandoned");
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const token = path.join(guard, `owner-${process.pid}-${randomUUID()}`);
|
|
29
|
+
try {
|
|
30
|
+
const handle = await open(token, "wx", 384);
|
|
31
|
+
await handle.close();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw recovery(`Integration guard initialization failed: ${String(error)}`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return await run();
|
|
37
|
+
} finally {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(token);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw recovery(
|
|
42
|
+
/** @type {any} */
|
|
43
|
+
error?.code === "ENOENT" ? "Integration guard ownership was lost" : `Integration guard token cleanup failed: ${String(error)}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await rmdir(guard);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw recovery(`Integration guard cleanup failed: ${String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function snapshot() {
|
|
54
|
+
let handle;
|
|
55
|
+
try {
|
|
56
|
+
handle = await open(file, "r");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (
|
|
59
|
+
/** @type {any} */
|
|
60
|
+
error?.code === "ENOENT"
|
|
61
|
+
) return void 0;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const info = await handle.stat();
|
|
66
|
+
const raw = await handle.readFile("utf8");
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
value = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
value = void 0;
|
|
72
|
+
}
|
|
73
|
+
return { value, info };
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function complete(value) {
|
|
79
|
+
return value && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.bootTime === "number" && Number.isFinite(value.bootTime) && value.bootTime > 0 && typeof value.startedAt === "string" && Number.isFinite(Date.parse(value.startedAt)) && typeof value.ready === "boolean" && (value.lockVersion === void 0 && value.owner === void 0 || value.lockVersion === 1 && typeof value.owner === "string" && /^[a-f0-9-]{36}$/.test(value.owner));
|
|
80
|
+
}
|
|
81
|
+
function dead(value) {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(value.pid, 0);
|
|
84
|
+
return false;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (
|
|
87
|
+
/** @type {any} */
|
|
88
|
+
error?.code !== "ESRCH"
|
|
89
|
+
) return false;
|
|
90
|
+
if (Math.abs(value.bootTime - bootTime()) > 30) throw recovery("Integration owner boot context is uncertain");
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function matches(current) {
|
|
95
|
+
return !!owned && current?.value?.owner === owned.owner && current.info.dev === owned.dev && current.info.ino === owned.ino;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
async acquire() {
|
|
99
|
+
if (phase !== "new") throw new Error("Integration lock instance has already been used");
|
|
100
|
+
phase = "acquiring";
|
|
101
|
+
try {
|
|
102
|
+
return await guarded(async () => {
|
|
103
|
+
const current = await snapshot();
|
|
104
|
+
if (current) {
|
|
105
|
+
if (!complete(current.value)) throw recovery("Integration lock is malformed, incomplete or unsupported");
|
|
106
|
+
if (!dead(current.value)) {
|
|
107
|
+
phase = "retired";
|
|
108
|
+
return { acquired: false, lock: current.value };
|
|
109
|
+
}
|
|
110
|
+
await unlink(file);
|
|
111
|
+
}
|
|
112
|
+
const value = { pid: process.pid, bootTime: bootTime(), startedAt: (/* @__PURE__ */ new Date()).toISOString(), ready: false, lockVersion: 1, owner: randomUUID() };
|
|
113
|
+
const handle = await open(file, "wx", 384);
|
|
114
|
+
let info;
|
|
115
|
+
try {
|
|
116
|
+
await handle.writeFile(JSON.stringify(value));
|
|
117
|
+
info = await handle.stat();
|
|
118
|
+
} finally {
|
|
119
|
+
await handle.close();
|
|
120
|
+
}
|
|
121
|
+
owned = { owner: value.owner, dev: info.dev, ino: info.ino };
|
|
122
|
+
phase = "owned";
|
|
123
|
+
return { acquired: true, lock: value };
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
phase = "retired";
|
|
127
|
+
owned = void 0;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async setReady(ready) {
|
|
132
|
+
if (phase !== "owned") return false;
|
|
133
|
+
return guarded(async () => {
|
|
134
|
+
if (phase !== "owned") return false;
|
|
135
|
+
const current = await snapshot();
|
|
136
|
+
if (!current || !matches(current)) {
|
|
137
|
+
phase = "retired";
|
|
138
|
+
owned = void 0;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const value = { ...current.value, ready };
|
|
142
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
143
|
+
const handle = await open(tmp, "wx", 384);
|
|
144
|
+
let info;
|
|
145
|
+
try {
|
|
146
|
+
try {
|
|
147
|
+
await handle.writeFile(JSON.stringify(value));
|
|
148
|
+
info = await handle.stat();
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
await rename(tmp, file);
|
|
153
|
+
owned = { owner: value.owner, dev: info.dev, ino: info.ino };
|
|
154
|
+
return true;
|
|
155
|
+
} finally {
|
|
156
|
+
await unlink(tmp).catch((error) => {
|
|
157
|
+
if (error?.code !== "ENOENT") throw error;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
async release() {
|
|
163
|
+
if (phase !== "owned") return false;
|
|
164
|
+
return guarded(async () => {
|
|
165
|
+
if (phase !== "owned") return false;
|
|
166
|
+
const current = await snapshot();
|
|
167
|
+
const ours = matches(current);
|
|
168
|
+
phase = "retired";
|
|
169
|
+
owned = void 0;
|
|
170
|
+
if (!ours) return false;
|
|
171
|
+
await unlink(file);
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
createIntegrationLock
|
|
179
|
+
};
|
package/dist/portable.d.ts
CHANGED
|
@@ -16,3 +16,4 @@ export { AlpClientError, CLIENT_ERROR, isAlpClientError } from "./rpc/errors.js"
|
|
|
16
16
|
export { createLineDecoder, utf8Length } from "./rpc/framing.js";
|
|
17
17
|
export { MAX_TIMER_MS, timerDelay } from "./rpc/timers.js";
|
|
18
18
|
export { backoffDelay, createReconnectingClient, retryable } from "./rpc/reconnect.js";
|
|
19
|
+
export { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from "./rpc/registry.js";
|
package/dist/portable.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createLineDecoder, utf8Length } from "./rpc/framing.js";
|
|
|
6
6
|
import { MAX_TIMER_MS, timerDelay } from "./rpc/timers.js";
|
|
7
7
|
import { hello } from "./rpc/handshake.js";
|
|
8
8
|
import { backoffDelay, createReconnectingClient, retryable } from "./rpc/reconnect.js";
|
|
9
|
+
import { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from "./rpc/registry.js";
|
|
9
10
|
export {
|
|
10
11
|
AlpClientError,
|
|
11
12
|
AlpRpcClient,
|
|
@@ -16,12 +17,17 @@ export {
|
|
|
16
17
|
MAX_FRAME,
|
|
17
18
|
MAX_TIMER_MS,
|
|
18
19
|
PROTOCOL_VERSION,
|
|
20
|
+
attachments,
|
|
19
21
|
backoffDelay,
|
|
22
|
+
base64Encode,
|
|
23
|
+
conversation,
|
|
20
24
|
createLineDecoder,
|
|
21
25
|
createReconnectingClient,
|
|
22
26
|
hello,
|
|
23
27
|
isAlpClientError,
|
|
24
28
|
retryable,
|
|
29
|
+
sha256Hex,
|
|
25
30
|
timerDelay,
|
|
26
|
-
utf8Length
|
|
31
|
+
utf8Length,
|
|
32
|
+
workspaceRegistry
|
|
27
33
|
};
|