@sonara/client 0.21.6
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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/cjs/client.d.ts +87 -0
- package/dist/cjs/client.js +196 -0
- package/dist/cjs/connect.d.ts +44 -0
- package/dist/cjs/connect.js +157 -0
- package/dist/cjs/connection.d.ts +25 -0
- package/dist/cjs/connection.js +121 -0
- package/dist/cjs/discovery.d.ts +23 -0
- package/dist/cjs/discovery.js +116 -0
- package/dist/cjs/engines.d.ts +99 -0
- package/dist/cjs/engines.js +75 -0
- package/dist/cjs/errors.d.ts +23 -0
- package/dist/cjs/errors.js +14 -0
- package/dist/cjs/extensions.d.ts +82 -0
- package/dist/cjs/extensions.js +106 -0
- package/dist/cjs/index.d.ts +21 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/types.d.ts +111 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/version.d.ts +7 -0
- package/dist/cjs/version.js +7 -0
- package/dist/esm/client.d.ts +87 -0
- package/dist/esm/client.js +192 -0
- package/dist/esm/connect.d.ts +44 -0
- package/dist/esm/connect.js +154 -0
- package/dist/esm/connection.d.ts +25 -0
- package/dist/esm/connection.js +117 -0
- package/dist/esm/discovery.d.ts +23 -0
- package/dist/esm/discovery.js +106 -0
- package/dist/esm/engines.d.ts +99 -0
- package/dist/esm/engines.js +71 -0
- package/dist/esm/errors.d.ts +23 -0
- package/dist/esm/errors.js +10 -0
- package/dist/esm/extensions.d.ts +82 -0
- package/dist/esm/extensions.js +100 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/types.d.ts +111 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/version.d.ts +7 -0
- package/dist/esm/version.js +4 -0
- package/package.json +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sonara/client: talk to the Sonara runtime (protocol v1) from Node 18+
|
|
3
|
+
* or an Electron main process. Zero dependencies.
|
|
4
|
+
*
|
|
5
|
+
* const sonara = await connect({ clientName: "my-app", runtimePath });
|
|
6
|
+
* sonara.onState((s) => render(s));
|
|
7
|
+
* await sonara.speak("Hello.");
|
|
8
|
+
*/
|
|
9
|
+
export { connect } from "./connect.js";
|
|
10
|
+
export type { ConnectOptions } from "./connect.js";
|
|
11
|
+
export { SonaraClient } from "./client.js";
|
|
12
|
+
export type { Unsubscribe } from "./client.js";
|
|
13
|
+
export { SonaraError } from "./errors.js";
|
|
14
|
+
export type { ErrorCode } from "./errors.js";
|
|
15
|
+
export { AgentApi, ChannelsApi, SystemApi } from "./extensions.js";
|
|
16
|
+
export { EnginesApi } from "./engines.js";
|
|
17
|
+
export type { EngineAddOptions, EngineModelsDraft, EngineProfile, SendMode, EngineTestOptions, } from "./engines.js";
|
|
18
|
+
export type { AskKind, AudioMode, ChannelOpenOptions, ChannelPolicy, StreamMessage } from "./extensions.js";
|
|
19
|
+
export { readRuntime, resolveHome } from "./discovery.js";
|
|
20
|
+
export { PROTOCOL, VERSION } from "./version.js";
|
|
21
|
+
export type { ControlAction, EngineStatus, HelloInfo, ItemEvent, ItemPhase, LogEvent, NowPlaying, Reply, RuntimeInfo, SettingKey, SpeakMode, SpeakOptions, State, Voice, } from "./types.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VERSION = exports.PROTOCOL = exports.resolveHome = exports.readRuntime = exports.EnginesApi = exports.SystemApi = exports.ChannelsApi = exports.AgentApi = exports.SonaraError = exports.SonaraClient = exports.connect = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* @sonara/client: talk to the Sonara runtime (protocol v1) from Node 18+
|
|
6
|
+
* or an Electron main process. Zero dependencies.
|
|
7
|
+
*
|
|
8
|
+
* const sonara = await connect({ clientName: "my-app", runtimePath });
|
|
9
|
+
* sonara.onState((s) => render(s));
|
|
10
|
+
* await sonara.speak("Hello.");
|
|
11
|
+
*/
|
|
12
|
+
var connect_js_1 = require("./connect.js");
|
|
13
|
+
Object.defineProperty(exports, "connect", { enumerable: true, get: function () { return connect_js_1.connect; } });
|
|
14
|
+
var client_js_1 = require("./client.js");
|
|
15
|
+
Object.defineProperty(exports, "SonaraClient", { enumerable: true, get: function () { return client_js_1.SonaraClient; } });
|
|
16
|
+
var errors_js_1 = require("./errors.js");
|
|
17
|
+
Object.defineProperty(exports, "SonaraError", { enumerable: true, get: function () { return errors_js_1.SonaraError; } });
|
|
18
|
+
var extensions_js_1 = require("./extensions.js");
|
|
19
|
+
Object.defineProperty(exports, "AgentApi", { enumerable: true, get: function () { return extensions_js_1.AgentApi; } });
|
|
20
|
+
Object.defineProperty(exports, "ChannelsApi", { enumerable: true, get: function () { return extensions_js_1.ChannelsApi; } });
|
|
21
|
+
Object.defineProperty(exports, "SystemApi", { enumerable: true, get: function () { return extensions_js_1.SystemApi; } });
|
|
22
|
+
var engines_js_1 = require("./engines.js");
|
|
23
|
+
Object.defineProperty(exports, "EnginesApi", { enumerable: true, get: function () { return engines_js_1.EnginesApi; } });
|
|
24
|
+
var discovery_js_1 = require("./discovery.js");
|
|
25
|
+
Object.defineProperty(exports, "readRuntime", { enumerable: true, get: function () { return discovery_js_1.readRuntime; } });
|
|
26
|
+
Object.defineProperty(exports, "resolveHome", { enumerable: true, get: function () { return discovery_js_1.resolveHome; } });
|
|
27
|
+
var version_js_1 = require("./version.js");
|
|
28
|
+
Object.defineProperty(exports, "PROTOCOL", { enumerable: true, get: function () { return version_js_1.PROTOCOL; } });
|
|
29
|
+
Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return version_js_1.VERSION; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Queue mode of `speak`: after everything queued, or drop the unread items first. */
|
|
2
|
+
export type SpeakMode = "append" | "replace";
|
|
3
|
+
/** Core playback controls (protocol v1, `control`). */
|
|
4
|
+
export type ControlAction = "play" | "pause" | "toggle" | "stop" | "skip" | "previous" | "next" | "restart" | "mute" | "unmute";
|
|
5
|
+
/** Core setting keys (protocol v1, `set` / `get`). */
|
|
6
|
+
export type SettingKey = "volume" | "rate" | "voice" | "engine";
|
|
7
|
+
export interface SpeakOptions {
|
|
8
|
+
/** `append` (default) or `replace` (drop unread items, never the current one). */
|
|
9
|
+
mode?: SpeakMode;
|
|
10
|
+
/** Also cut the current item and start this one now. */
|
|
11
|
+
interrupt?: boolean;
|
|
12
|
+
/** Shown in `state.now_playing.label`. */
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface NowPlaying {
|
|
16
|
+
item_id: number;
|
|
17
|
+
label: string | null;
|
|
18
|
+
/** The chunk being read. */
|
|
19
|
+
text: string;
|
|
20
|
+
chunk: number;
|
|
21
|
+
chunks: number;
|
|
22
|
+
[extra: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** A `state` event: a full snapshot, sent on change only. */
|
|
25
|
+
export interface State {
|
|
26
|
+
seq: number;
|
|
27
|
+
now_playing: NowPlaying | null;
|
|
28
|
+
queued: number;
|
|
29
|
+
paused: boolean;
|
|
30
|
+
muted: boolean;
|
|
31
|
+
volume: number;
|
|
32
|
+
rate: number;
|
|
33
|
+
voice: string | null;
|
|
34
|
+
engine_status: EngineStatus;
|
|
35
|
+
[extra: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `state.engine_status`. Protocol 1.1 adds readiness: Kokoro is `loading`,
|
|
39
|
+
* `downloading` (with `progress`), `waiting` to retry or `unavailable`
|
|
40
|
+
* until it is `ready`, and names the engine speaking meanwhile in
|
|
41
|
+
* `fallback`. A 1.0 runtime sends only `engine`.
|
|
42
|
+
*/
|
|
43
|
+
export interface EngineStatus {
|
|
44
|
+
engine: string;
|
|
45
|
+
ready?: boolean;
|
|
46
|
+
status?: "ready" | "loading" | "downloading" | "waiting" | "unavailable";
|
|
47
|
+
progress?: {
|
|
48
|
+
done: number;
|
|
49
|
+
total: number;
|
|
50
|
+
};
|
|
51
|
+
fallback?: string;
|
|
52
|
+
message?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Protocol 1.2: why an external engine is not speaking itself
|
|
55
|
+
* (`no_key`, `auth`, `quota`, `rate_limited`, `network`, `timeout`,
|
|
56
|
+
* `server`, `bad_voice`, `bad_config`, `format`).
|
|
57
|
+
*/
|
|
58
|
+
reason?: string;
|
|
59
|
+
[extra: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
export type ItemPhase = "started" | "finished" | "skipped" | "failed";
|
|
62
|
+
/** An `item` event. */
|
|
63
|
+
export interface ItemEvent {
|
|
64
|
+
item_id: number;
|
|
65
|
+
phase: ItemPhase;
|
|
66
|
+
[extra: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
/** A `log` event. */
|
|
69
|
+
export interface LogEvent {
|
|
70
|
+
message: string;
|
|
71
|
+
[extra: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
export interface Voice {
|
|
74
|
+
id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
language: string;
|
|
77
|
+
engine: string;
|
|
78
|
+
license_class: "permissive" | "os" | "external";
|
|
79
|
+
installed: boolean;
|
|
80
|
+
[extra: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** The `hello` reply: what the runtime offers. */
|
|
83
|
+
export interface HelloInfo {
|
|
84
|
+
version: string;
|
|
85
|
+
protocol: {
|
|
86
|
+
major: number;
|
|
87
|
+
minor: number;
|
|
88
|
+
};
|
|
89
|
+
capabilities: string[];
|
|
90
|
+
extensions: string[];
|
|
91
|
+
/** Requested extensions this runtime lacks. */
|
|
92
|
+
unavailable: string[];
|
|
93
|
+
[extra: string]: unknown;
|
|
94
|
+
}
|
|
95
|
+
/** Contents of `runtime.json` (protocol v1, Discovery). */
|
|
96
|
+
export interface RuntimeInfo {
|
|
97
|
+
pid: number;
|
|
98
|
+
port: number;
|
|
99
|
+
http_port: number;
|
|
100
|
+
token: string;
|
|
101
|
+
version: string;
|
|
102
|
+
protocol: {
|
|
103
|
+
major: number;
|
|
104
|
+
minor: number;
|
|
105
|
+
};
|
|
106
|
+
capabilities: string[];
|
|
107
|
+
started_at: string;
|
|
108
|
+
[extra: string]: unknown;
|
|
109
|
+
}
|
|
110
|
+
/** Any reply with `ok: true`; the fields depend on the request. */
|
|
111
|
+
export type Reply = Record<string, unknown>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROTOCOL = exports.VERSION = void 0;
|
|
4
|
+
/** Version of this package, sent as `client.version` in `hello`. */
|
|
5
|
+
exports.VERSION = "0.21.6";
|
|
6
|
+
/** Protocol this client speaks. */
|
|
7
|
+
exports.PROTOCOL = { major: 1, minor: 0 };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Connection } from "./connection.js";
|
|
2
|
+
import { EnginesApi } from "./engines.js";
|
|
3
|
+
import { AgentApi, ChannelsApi, SystemApi } from "./extensions.js";
|
|
4
|
+
import type { ControlAction, HelloInfo, ItemEvent, LogEvent, Reply, RuntimeInfo, SettingKey, SpeakOptions, State, Voice } from "./types.js";
|
|
5
|
+
export type Unsubscribe = () => void;
|
|
6
|
+
type Listener<T> = (value: T) => void;
|
|
7
|
+
/** Opens and greets one more connection (the event connection). */
|
|
8
|
+
export type Dial = () => Promise<Connection>;
|
|
9
|
+
/**
|
|
10
|
+
* A connected Sonara client. Requests use one connection; events
|
|
11
|
+
* (`onState`, `onItem`, `onLog`) arrive on a second one, opened on the first
|
|
12
|
+
* listener and subscribed to every stream.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SonaraClient {
|
|
15
|
+
/** The runtime's `hello` reply: version, protocol, capabilities, extensions. */
|
|
16
|
+
readonly info: HelloInfo;
|
|
17
|
+
/** `runtime.json` of the instance this client talks to. */
|
|
18
|
+
readonly runtime: Readonly<RuntimeInfo>;
|
|
19
|
+
/** Extension `channels` (pass-through; needs a runtime that offers it). */
|
|
20
|
+
readonly channels: ChannelsApi;
|
|
21
|
+
/** Extension `agent` (pass-through). */
|
|
22
|
+
readonly agent: AgentApi;
|
|
23
|
+
/** Extension `system` (pass-through). */
|
|
24
|
+
readonly system: SystemApi;
|
|
25
|
+
/** External engines (protocol 1.2, capability `engines`; pass-through). */
|
|
26
|
+
readonly engines: EnginesApi;
|
|
27
|
+
private readonly conn;
|
|
28
|
+
private readonly dial;
|
|
29
|
+
private eventConn;
|
|
30
|
+
private eventsOpening;
|
|
31
|
+
private eventsFailed;
|
|
32
|
+
/** The newest `state` snapshot, handed to a state listener added later. */
|
|
33
|
+
private lastState;
|
|
34
|
+
private closing;
|
|
35
|
+
private readonly stateListeners;
|
|
36
|
+
private readonly itemListeners;
|
|
37
|
+
private readonly logListeners;
|
|
38
|
+
private readonly closeListeners;
|
|
39
|
+
constructor(conn: Connection, runtime: RuntimeInfo, info: HelloInfo, dial: Dial);
|
|
40
|
+
/** True once the connection closed (close(), a takeover or the runtime exited). */
|
|
41
|
+
get closed(): boolean;
|
|
42
|
+
/** Send any protocol message; resolves with the `ok: true` reply. */
|
|
43
|
+
request(type: string, fields?: Record<string, unknown>): Promise<Reply>;
|
|
44
|
+
/** Add `text` as one item; resolves with its item id. */
|
|
45
|
+
speak(text: string, opts?: SpeakOptions): Promise<number>;
|
|
46
|
+
/** Playback control: play, pause, toggle, stop, skip, previous, next, restart, mute, unmute. */
|
|
47
|
+
control(action: ControlAction): Promise<void>;
|
|
48
|
+
/** Change a setting; resolves with the value now in force. */
|
|
49
|
+
set(key: SettingKey, value: unknown): Promise<unknown>;
|
|
50
|
+
/** Read a setting. */
|
|
51
|
+
get(key: SettingKey): Promise<unknown>;
|
|
52
|
+
/**
|
|
53
|
+
* Voices of one engine, or of all. `refresh` asks an external engine's
|
|
54
|
+
* provider again (protocol 1.2).
|
|
55
|
+
*/
|
|
56
|
+
voices(engine?: string, opts?: {
|
|
57
|
+
refresh?: boolean;
|
|
58
|
+
}): Promise<Voice[]>;
|
|
59
|
+
/**
|
|
60
|
+
* Call `cb` with every `state` snapshot. The first is the current state,
|
|
61
|
+
* also for a listener added after others: the runtime sends state on
|
|
62
|
+
* change only, so the latest snapshot is replayed to it (asynchronously).
|
|
63
|
+
*/
|
|
64
|
+
onState(cb: Listener<State>): Unsubscribe;
|
|
65
|
+
/** Call `cb` with every `item` event (started, finished, skipped, failed). */
|
|
66
|
+
onItem(cb: Listener<ItemEvent>): Unsubscribe;
|
|
67
|
+
/** Call `cb` with every `log` event. */
|
|
68
|
+
onLog(cb: Listener<LogEvent>): Unsubscribe;
|
|
69
|
+
/**
|
|
70
|
+
* Call `cb` once the request connection closes for any reason. A drop of
|
|
71
|
+
* the event connection alone is reported to `onLog` ("event stream
|
|
72
|
+
* closed"); the next listener added opens a new one.
|
|
73
|
+
*/
|
|
74
|
+
onClose(cb: () => void): Unsubscribe;
|
|
75
|
+
/**
|
|
76
|
+
* Resolves once the event connection is subscribed (opened by the first
|
|
77
|
+
* listener), so nothing that follows is missed. Rejects when that failed;
|
|
78
|
+
* the next listener added tries again.
|
|
79
|
+
*/
|
|
80
|
+
eventsReady(): Promise<void>;
|
|
81
|
+
/** Close both connections. The runtime keeps running for other clients and exits on its own when idle. */
|
|
82
|
+
close(): Promise<void>;
|
|
83
|
+
private listen;
|
|
84
|
+
private openEvents;
|
|
85
|
+
private dispatch;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { SonaraError } from "./errors.js";
|
|
2
|
+
import { EnginesApi } from "./engines.js";
|
|
3
|
+
import { AgentApi, ChannelsApi, SystemApi } from "./extensions.js";
|
|
4
|
+
/**
|
|
5
|
+
* A connected Sonara client. Requests use one connection; events
|
|
6
|
+
* (`onState`, `onItem`, `onLog`) arrive on a second one, opened on the first
|
|
7
|
+
* listener and subscribed to every stream.
|
|
8
|
+
*/
|
|
9
|
+
export class SonaraClient {
|
|
10
|
+
constructor(conn, runtime, info, dial) {
|
|
11
|
+
this.eventConn = null;
|
|
12
|
+
this.eventsOpening = null;
|
|
13
|
+
this.eventsFailed = false;
|
|
14
|
+
/** The newest `state` snapshot, handed to a state listener added later. */
|
|
15
|
+
this.lastState = null;
|
|
16
|
+
this.closing = false;
|
|
17
|
+
this.stateListeners = new Set();
|
|
18
|
+
this.itemListeners = new Set();
|
|
19
|
+
this.logListeners = new Set();
|
|
20
|
+
this.closeListeners = new Set();
|
|
21
|
+
this.conn = conn;
|
|
22
|
+
this.runtime = runtime;
|
|
23
|
+
this.info = info;
|
|
24
|
+
this.dial = dial;
|
|
25
|
+
const send = (type, fields) => this.request(type, fields);
|
|
26
|
+
this.channels = new ChannelsApi(send);
|
|
27
|
+
this.agent = new AgentApi(send);
|
|
28
|
+
this.system = new SystemApi(send);
|
|
29
|
+
this.engines = new EnginesApi(send);
|
|
30
|
+
conn.onClose = () => {
|
|
31
|
+
this.eventConn?.close();
|
|
32
|
+
for (const cb of [...this.closeListeners])
|
|
33
|
+
cb();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** True once the connection closed (close(), a takeover or the runtime exited). */
|
|
37
|
+
get closed() {
|
|
38
|
+
return this.conn.closed;
|
|
39
|
+
}
|
|
40
|
+
/** Send any protocol message; resolves with the `ok: true` reply. */
|
|
41
|
+
request(type, fields = {}) {
|
|
42
|
+
return this.conn.request(type, fields);
|
|
43
|
+
}
|
|
44
|
+
/** Add `text` as one item; resolves with its item id. */
|
|
45
|
+
async speak(text, opts = {}) {
|
|
46
|
+
const fields = { text };
|
|
47
|
+
if (opts.mode !== undefined)
|
|
48
|
+
fields.mode = opts.mode;
|
|
49
|
+
if (opts.interrupt !== undefined)
|
|
50
|
+
fields.interrupt = opts.interrupt;
|
|
51
|
+
if (opts.label !== undefined)
|
|
52
|
+
fields.label = opts.label;
|
|
53
|
+
const r = await this.request("speak", fields);
|
|
54
|
+
return r.item_id;
|
|
55
|
+
}
|
|
56
|
+
/** Playback control: play, pause, toggle, stop, skip, previous, next, restart, mute, unmute. */
|
|
57
|
+
async control(action) {
|
|
58
|
+
await this.request("control", { action });
|
|
59
|
+
}
|
|
60
|
+
/** Change a setting; resolves with the value now in force. */
|
|
61
|
+
async set(key, value) {
|
|
62
|
+
const r = await this.request("set", { key, value });
|
|
63
|
+
return r.value;
|
|
64
|
+
}
|
|
65
|
+
/** Read a setting. */
|
|
66
|
+
async get(key) {
|
|
67
|
+
const r = await this.request("get", { key });
|
|
68
|
+
return r.value;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Voices of one engine, or of all. `refresh` asks an external engine's
|
|
72
|
+
* provider again (protocol 1.2).
|
|
73
|
+
*/
|
|
74
|
+
async voices(engine, opts = {}) {
|
|
75
|
+
const fields = engine === undefined ? {} : { engine };
|
|
76
|
+
if (opts.refresh !== undefined)
|
|
77
|
+
fields.refresh = opts.refresh;
|
|
78
|
+
const r = await this.request("voices", fields);
|
|
79
|
+
return r.voices;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Call `cb` with every `state` snapshot. The first is the current state,
|
|
83
|
+
* also for a listener added after others: the runtime sends state on
|
|
84
|
+
* change only, so the latest snapshot is replayed to it (asynchronously).
|
|
85
|
+
*/
|
|
86
|
+
onState(cb) {
|
|
87
|
+
const off = this.listen(this.stateListeners, cb);
|
|
88
|
+
const last = this.lastState;
|
|
89
|
+
if (last) {
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
// Skip it when unsubscribed meanwhile or a newer state already came.
|
|
92
|
+
if (this.stateListeners.has(cb) && this.lastState === last)
|
|
93
|
+
cb(last);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return off;
|
|
97
|
+
}
|
|
98
|
+
/** Call `cb` with every `item` event (started, finished, skipped, failed). */
|
|
99
|
+
onItem(cb) {
|
|
100
|
+
return this.listen(this.itemListeners, cb);
|
|
101
|
+
}
|
|
102
|
+
/** Call `cb` with every `log` event. */
|
|
103
|
+
onLog(cb) {
|
|
104
|
+
return this.listen(this.logListeners, cb);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Call `cb` once the request connection closes for any reason. A drop of
|
|
108
|
+
* the event connection alone is reported to `onLog` ("event stream
|
|
109
|
+
* closed"); the next listener added opens a new one.
|
|
110
|
+
*/
|
|
111
|
+
onClose(cb) {
|
|
112
|
+
this.closeListeners.add(cb);
|
|
113
|
+
return () => {
|
|
114
|
+
this.closeListeners.delete(cb);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolves once the event connection is subscribed (opened by the first
|
|
119
|
+
* listener), so nothing that follows is missed. Rejects when that failed;
|
|
120
|
+
* the next listener added tries again.
|
|
121
|
+
*/
|
|
122
|
+
eventsReady() {
|
|
123
|
+
return this.eventsOpening ?? Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
/** Close both connections. The runtime keeps running for other clients and exits on its own when idle. */
|
|
126
|
+
async close() {
|
|
127
|
+
this.closing = true;
|
|
128
|
+
this.eventConn?.close();
|
|
129
|
+
this.conn.close();
|
|
130
|
+
}
|
|
131
|
+
listen(set, cb) {
|
|
132
|
+
set.add(cb);
|
|
133
|
+
// A failed attempt is retried by the next listener.
|
|
134
|
+
if ((!this.eventsOpening || this.eventsFailed) && !this.closing) {
|
|
135
|
+
this.eventsFailed = false;
|
|
136
|
+
this.eventsOpening = this.openEvents();
|
|
137
|
+
// The failure is reported to log listeners; eventsReady() still rejects.
|
|
138
|
+
this.eventsOpening.catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
return () => {
|
|
141
|
+
set.delete(cb);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async openEvents() {
|
|
145
|
+
try {
|
|
146
|
+
const conn = await this.dial();
|
|
147
|
+
if (this.closing) {
|
|
148
|
+
conn.close();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.eventConn = conn;
|
|
152
|
+
conn.onEvent = (e) => this.dispatch(e);
|
|
153
|
+
await conn.request("subscribe", { events: ["state", "items", "log"] });
|
|
154
|
+
// The event connection can drop on its own (the request connection
|
|
155
|
+
// reports through onClose). Once subscribed, tell log listeners, and
|
|
156
|
+
// let the next listener open a new one.
|
|
157
|
+
conn.onClose = () => {
|
|
158
|
+
if (this.eventConn === conn)
|
|
159
|
+
this.eventConn = null;
|
|
160
|
+
// A new event connection starts with the then current state.
|
|
161
|
+
this.lastState = null;
|
|
162
|
+
if (this.closing || this.conn.closed)
|
|
163
|
+
return;
|
|
164
|
+
this.eventsFailed = true;
|
|
165
|
+
for (const cb of [...this.logListeners])
|
|
166
|
+
cb({ message: "event stream closed" });
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
this.eventsFailed = true;
|
|
171
|
+
const message = `event stream failed: ${err.message}`;
|
|
172
|
+
for (const cb of [...this.logListeners])
|
|
173
|
+
cb({ message });
|
|
174
|
+
throw err instanceof SonaraError ? err : new SonaraError("E_CLOSED", message);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
dispatch(e) {
|
|
178
|
+
const { event, ...body } = e;
|
|
179
|
+
if (event === "state") {
|
|
180
|
+
const state = body;
|
|
181
|
+
this.lastState = state;
|
|
182
|
+
for (const cb of [...this.stateListeners])
|
|
183
|
+
cb(state);
|
|
184
|
+
}
|
|
185
|
+
else if (event === "item")
|
|
186
|
+
for (const cb of [...this.itemListeners])
|
|
187
|
+
cb(body);
|
|
188
|
+
else if (event === "log")
|
|
189
|
+
for (const cb of [...this.logListeners])
|
|
190
|
+
cb(body);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SonaraClient } from "./client.js";
|
|
2
|
+
export interface ConnectOptions {
|
|
3
|
+
/** Your app's name, sent in `hello` (informational). */
|
|
4
|
+
clientName: string;
|
|
5
|
+
/** Your app's version (default: this package's version). */
|
|
6
|
+
clientVersion?: string;
|
|
7
|
+
/**
|
|
8
|
+
* The bundled `sonarad.exe`, started when no usable runtime is running.
|
|
9
|
+
* Default: `SONARA_RUNTIME` from the environment. With
|
|
10
|
+
* `@sonara/runtime-win32-x64`: `runtimePath()` from that package.
|
|
11
|
+
*/
|
|
12
|
+
runtimePath?: string;
|
|
13
|
+
/** Home folder (default: `SONARA_HOME`, else `%LOCALAPPDATA%\Sonara`). */
|
|
14
|
+
home?: string;
|
|
15
|
+
/** Start the bundled runtime when none is usable (default true). */
|
|
16
|
+
autostart?: boolean;
|
|
17
|
+
/** Capabilities or extensions this client cannot work without. */
|
|
18
|
+
require?: string[];
|
|
19
|
+
/** Extensions to enable (`channels`, `agent`, `system`). */
|
|
20
|
+
extensions?: string[];
|
|
21
|
+
/** Keep the runtime running after the last client left. */
|
|
22
|
+
keepAlive?: boolean;
|
|
23
|
+
/** Extra command-line arguments for a started runtime (tests: `["--engine", "fake"]`). */
|
|
24
|
+
runtimeArgs?: string[];
|
|
25
|
+
/** Wait for a started runtime's `runtime.json` (default 5000 ms). */
|
|
26
|
+
startTimeoutMs?: number;
|
|
27
|
+
/** Retry the takeover of a busy, incompatible runtime this long (default 30000 ms). */
|
|
28
|
+
takeoverTimeoutMs?: number;
|
|
29
|
+
/** Wait between takeover retries (default 250 ms). */
|
|
30
|
+
takeoverRetryMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Connect to the shared Sonara runtime (spec section 3):
|
|
34
|
+
*
|
|
35
|
+
* 1. Read `runtime.json` in the home; when its pid is alive, connect and
|
|
36
|
+
* send `hello`.
|
|
37
|
+
* 2. Use it when it speaks protocol 1 and offers everything in `require`.
|
|
38
|
+
* 3. Otherwise (and with `autostart`), start `runtimePath --home <home>`,
|
|
39
|
+
* wait up to 5 s for its `runtime.json`, then `hello`.
|
|
40
|
+
* 4. An incompatible running instance is first asked to step down
|
|
41
|
+
* (`hello` with `takeover: true`); while it is busy the takeover is
|
|
42
|
+
* retried for up to 30 s, then `E_INCOMPATIBLE`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function connect(opts: ConnectOptions): Promise<SonaraClient>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { SonaraClient } from "./client.js";
|
|
2
|
+
import { Connection } from "./connection.js";
|
|
3
|
+
import { liveRuntime, resolveHome, sleep, startRuntime, waitExit } from "./discovery.js";
|
|
4
|
+
import { SonaraError } from "./errors.js";
|
|
5
|
+
import { PROTOCOL, VERSION } from "./version.js";
|
|
6
|
+
const CONNECT_TIMEOUT_MS = 5000;
|
|
7
|
+
const EXIT_WAIT_MS = 5000;
|
|
8
|
+
/** Why an instance cannot serve this client. */
|
|
9
|
+
class Incompatible extends Error {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Connect to the shared Sonara runtime (spec section 3):
|
|
13
|
+
*
|
|
14
|
+
* 1. Read `runtime.json` in the home; when its pid is alive, connect and
|
|
15
|
+
* send `hello`.
|
|
16
|
+
* 2. Use it when it speaks protocol 1 and offers everything in `require`.
|
|
17
|
+
* 3. Otherwise (and with `autostart`), start `runtimePath --home <home>`,
|
|
18
|
+
* wait up to 5 s for its `runtime.json`, then `hello`.
|
|
19
|
+
* 4. An incompatible running instance is first asked to step down
|
|
20
|
+
* (`hello` with `takeover: true`); while it is busy the takeover is
|
|
21
|
+
* retried for up to 30 s, then `E_INCOMPATIBLE`.
|
|
22
|
+
*/
|
|
23
|
+
export async function connect(opts) {
|
|
24
|
+
if (!opts || typeof opts.clientName !== "string" || !opts.clientName) {
|
|
25
|
+
throw new SonaraError("E_BAD_REQUEST", "connect() needs a clientName");
|
|
26
|
+
}
|
|
27
|
+
const home = resolveHome(opts.home);
|
|
28
|
+
const runtimePath = opts.runtimePath ?? process.env.SONARA_RUNTIME;
|
|
29
|
+
const canStart = (opts.autostart ?? true) && !!runtimePath;
|
|
30
|
+
const require = opts.require ?? [];
|
|
31
|
+
const hello = {
|
|
32
|
+
client: { name: opts.clientName, version: opts.clientVersion ?? VERSION },
|
|
33
|
+
protocol: { ...PROTOCOL },
|
|
34
|
+
require,
|
|
35
|
+
extensions: opts.extensions ?? [],
|
|
36
|
+
};
|
|
37
|
+
if (opts.keepAlive)
|
|
38
|
+
hello.keep_alive = true;
|
|
39
|
+
const greeting = { hello, require };
|
|
40
|
+
const running = liveRuntime(home);
|
|
41
|
+
if (running) {
|
|
42
|
+
try {
|
|
43
|
+
const client = await greet(running, greeting);
|
|
44
|
+
if (client)
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (!(err instanceof Incompatible))
|
|
49
|
+
throw err;
|
|
50
|
+
if (!canStart) {
|
|
51
|
+
throw new SonaraError("E_INCOMPATIBLE", `the running Sonara ${running.version} cannot serve this client (${err.message}) and there is no runtime to start in its place`);
|
|
52
|
+
}
|
|
53
|
+
await takeOver(home, opts, err.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!canStart) {
|
|
57
|
+
throw new SonaraError("E_NOT_RUNNING", runtimePath
|
|
58
|
+
? "no Sonara runtime is running and autostart is off"
|
|
59
|
+
: "no Sonara runtime is running and no runtimePath to start one");
|
|
60
|
+
}
|
|
61
|
+
const started = await startRuntime(runtimePath, home, opts.runtimeArgs ?? [], opts.startTimeoutMs ?? 5000);
|
|
62
|
+
try {
|
|
63
|
+
const client = await greet(started, greeting);
|
|
64
|
+
if (client)
|
|
65
|
+
return client;
|
|
66
|
+
throw new SonaraError("E_CLOSED", "the started runtime closed the connection");
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof Incompatible) {
|
|
70
|
+
throw new SonaraError("E_INCOMPATIBLE", `the bundled Sonara ${started.version} cannot serve this client: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Connect and send `hello`. Null when the instance cannot be reached (it is
|
|
77
|
+
* exiting); throws Incompatible when it answers but cannot serve us.
|
|
78
|
+
*/
|
|
79
|
+
async function greet(info, g) {
|
|
80
|
+
const dial = async () => {
|
|
81
|
+
const conn = await Connection.open(info.port, CONNECT_TIMEOUT_MS);
|
|
82
|
+
try {
|
|
83
|
+
await conn.request("hello", { ...g.hello, token: info.token });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
conn.close();
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return conn;
|
|
90
|
+
};
|
|
91
|
+
let conn;
|
|
92
|
+
try {
|
|
93
|
+
conn = await Connection.open(info.port, CONNECT_TIMEOUT_MS);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
let reply;
|
|
99
|
+
try {
|
|
100
|
+
reply = await conn.request("hello", { ...g.hello, token: info.token });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
conn.close();
|
|
104
|
+
if (err instanceof SonaraError && (err.code === "E_INCOMPATIBLE" || err.code === "E_UNSUPPORTED")) {
|
|
105
|
+
throw new Incompatible(err.message);
|
|
106
|
+
}
|
|
107
|
+
if (err instanceof SonaraError && err.code === "E_CLOSED")
|
|
108
|
+
return null;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
const helloInfo = reply;
|
|
112
|
+
const offered = new Set([...(helloInfo.capabilities ?? []), ...(helloInfo.extensions ?? [])]);
|
|
113
|
+
const missing = g.require.filter((r) => !offered.has(r));
|
|
114
|
+
if (helloInfo.protocol?.major !== PROTOCOL.major || missing.length) {
|
|
115
|
+
conn.close();
|
|
116
|
+
throw new Incompatible(missing.length ? `it does not offer ${missing.join(", ")}` : `it speaks protocol ${helloInfo.protocol?.major}`);
|
|
117
|
+
}
|
|
118
|
+
return new SonaraClient(conn, info, helloInfo, dial);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Ask the running instance to exit so the bundled runtime can start. It
|
|
122
|
+
* accepts only when idle; while it is busy, retry until `takeoverTimeoutMs`.
|
|
123
|
+
* Each attempt uses a fresh connection: the runtime closes one that has no
|
|
124
|
+
* successful `hello` within 5 s.
|
|
125
|
+
*/
|
|
126
|
+
async function takeOver(home, opts, why) {
|
|
127
|
+
const deadline = Date.now() + (opts.takeoverTimeoutMs ?? 30000);
|
|
128
|
+
const retryMs = opts.takeoverRetryMs ?? 250;
|
|
129
|
+
const client = { name: opts.clientName, version: opts.clientVersion ?? VERSION };
|
|
130
|
+
for (;;) {
|
|
131
|
+
const cur = liveRuntime(home);
|
|
132
|
+
if (!cur)
|
|
133
|
+
return;
|
|
134
|
+
let conn = null;
|
|
135
|
+
try {
|
|
136
|
+
conn = await Connection.open(cur.port, CONNECT_TIMEOUT_MS);
|
|
137
|
+
await conn.request("hello", { token: cur.token, client, takeover: true });
|
|
138
|
+
conn.close();
|
|
139
|
+
if (await waitExit(cur.pid, EXIT_WAIT_MS))
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
conn?.close();
|
|
144
|
+
const code = err instanceof SonaraError ? err.code : "";
|
|
145
|
+
if (code !== "E_BUSY" && code !== "E_CLOSED") {
|
|
146
|
+
throw new SonaraError("E_INCOMPATIBLE", `the running Sonara cannot serve this client (${why}) and refused a takeover: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (Date.now() >= deadline) {
|
|
150
|
+
throw new SonaraError("E_INCOMPATIBLE", `the running Sonara cannot serve this client (${why}) and stayed busy; gave up the takeover`);
|
|
151
|
+
}
|
|
152
|
+
await sleep(retryMs);
|
|
153
|
+
}
|
|
154
|
+
}
|