@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nima Hakimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @sonara/client
|
|
2
|
+
|
|
3
|
+
Talk to the Sonara text-to-speech runtime from Node 18+ or an Electron main process. Zero dependencies; ESM and CommonJS builds with type declarations.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { connect } from "@sonara/client";
|
|
7
|
+
import { runtimePath } from "@sonara/runtime-win32-x64";
|
|
8
|
+
|
|
9
|
+
const sonara = await connect({ clientName: "my-app", runtimePath: runtimePath() });
|
|
10
|
+
sonara.onState((s) => console.log(s.now_playing?.text ?? "idle"));
|
|
11
|
+
const item = await sonara.speak("Hello.", { label: "greeting" });
|
|
12
|
+
await sonara.control("pause");
|
|
13
|
+
await sonara.set("rate", 240);
|
|
14
|
+
await sonara.close();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `connect(options)`: finds the shared runtime through `runtime.json`, starts the bundled `sonarad.exe` when none is usable (`autostart`, default on), takes over an idle incompatible one. Options: `clientName` (required), `runtimePath` (default `SONARA_RUNTIME`), `home`, `autostart`, `require`, `extensions`, `keepAlive`.
|
|
18
|
+
- `speak(text, { mode: "append" | "replace", interrupt, label })` resolves with the item id.
|
|
19
|
+
- `control(action)`: `play`, `pause`, `toggle`, `stop`, `skip`, `previous`, `next`, `restart`, `mute`, `unmute`.
|
|
20
|
+
- `set(key, value)` / `get(key)` for `volume` (0..100), `rate` (100..400 wpm), `voice`, `engine`; `voices(engine?)`.
|
|
21
|
+
- `onState`, `onItem`, `onLog` return an unsubscribe function; events arrive on a second connection opened by the first listener. Every state listener, also one added later, gets the current state first. `onClose` fires when the runtime goes away.
|
|
22
|
+
- `channels`, `agent`, `system`: extension namespaces that send the protocol's extension messages as they are.
|
|
23
|
+
- Errors are `SonaraError` with a `code` (`E_BUSY`, `E_NOT_RUNNING`, ...).
|
|
24
|
+
|
|
25
|
+
Guide: [Bundle Sonara in your app](https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md). Protocol: [protocol v1](https://github.com/Maxaubert/Sonara/blob/main/docs/protocol-v1.md).
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm ci
|
|
31
|
+
npm run build # tsc to dist/esm and dist/cjs
|
|
32
|
+
npm run test:unit # against a fake runtime
|
|
33
|
+
npm test # also against a real sonarad.exe (cargo build -p sonarad, or SONARAD=...)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
MIT. Ship `THIRD_PARTY_NOTICES.md` with an app that bundles the runtime (see `LICENSING.md` in the repository).
|
|
@@ -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,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SonaraClient = void 0;
|
|
4
|
+
const errors_js_1 = require("./errors.js");
|
|
5
|
+
const engines_js_1 = require("./engines.js");
|
|
6
|
+
const extensions_js_1 = require("./extensions.js");
|
|
7
|
+
/**
|
|
8
|
+
* A connected Sonara client. Requests use one connection; events
|
|
9
|
+
* (`onState`, `onItem`, `onLog`) arrive on a second one, opened on the first
|
|
10
|
+
* listener and subscribed to every stream.
|
|
11
|
+
*/
|
|
12
|
+
class SonaraClient {
|
|
13
|
+
constructor(conn, runtime, info, dial) {
|
|
14
|
+
this.eventConn = null;
|
|
15
|
+
this.eventsOpening = null;
|
|
16
|
+
this.eventsFailed = false;
|
|
17
|
+
/** The newest `state` snapshot, handed to a state listener added later. */
|
|
18
|
+
this.lastState = null;
|
|
19
|
+
this.closing = false;
|
|
20
|
+
this.stateListeners = new Set();
|
|
21
|
+
this.itemListeners = new Set();
|
|
22
|
+
this.logListeners = new Set();
|
|
23
|
+
this.closeListeners = new Set();
|
|
24
|
+
this.conn = conn;
|
|
25
|
+
this.runtime = runtime;
|
|
26
|
+
this.info = info;
|
|
27
|
+
this.dial = dial;
|
|
28
|
+
const send = (type, fields) => this.request(type, fields);
|
|
29
|
+
this.channels = new extensions_js_1.ChannelsApi(send);
|
|
30
|
+
this.agent = new extensions_js_1.AgentApi(send);
|
|
31
|
+
this.system = new extensions_js_1.SystemApi(send);
|
|
32
|
+
this.engines = new engines_js_1.EnginesApi(send);
|
|
33
|
+
conn.onClose = () => {
|
|
34
|
+
this.eventConn?.close();
|
|
35
|
+
for (const cb of [...this.closeListeners])
|
|
36
|
+
cb();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** True once the connection closed (close(), a takeover or the runtime exited). */
|
|
40
|
+
get closed() {
|
|
41
|
+
return this.conn.closed;
|
|
42
|
+
}
|
|
43
|
+
/** Send any protocol message; resolves with the `ok: true` reply. */
|
|
44
|
+
request(type, fields = {}) {
|
|
45
|
+
return this.conn.request(type, fields);
|
|
46
|
+
}
|
|
47
|
+
/** Add `text` as one item; resolves with its item id. */
|
|
48
|
+
async speak(text, opts = {}) {
|
|
49
|
+
const fields = { text };
|
|
50
|
+
if (opts.mode !== undefined)
|
|
51
|
+
fields.mode = opts.mode;
|
|
52
|
+
if (opts.interrupt !== undefined)
|
|
53
|
+
fields.interrupt = opts.interrupt;
|
|
54
|
+
if (opts.label !== undefined)
|
|
55
|
+
fields.label = opts.label;
|
|
56
|
+
const r = await this.request("speak", fields);
|
|
57
|
+
return r.item_id;
|
|
58
|
+
}
|
|
59
|
+
/** Playback control: play, pause, toggle, stop, skip, previous, next, restart, mute, unmute. */
|
|
60
|
+
async control(action) {
|
|
61
|
+
await this.request("control", { action });
|
|
62
|
+
}
|
|
63
|
+
/** Change a setting; resolves with the value now in force. */
|
|
64
|
+
async set(key, value) {
|
|
65
|
+
const r = await this.request("set", { key, value });
|
|
66
|
+
return r.value;
|
|
67
|
+
}
|
|
68
|
+
/** Read a setting. */
|
|
69
|
+
async get(key) {
|
|
70
|
+
const r = await this.request("get", { key });
|
|
71
|
+
return r.value;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Voices of one engine, or of all. `refresh` asks an external engine's
|
|
75
|
+
* provider again (protocol 1.2).
|
|
76
|
+
*/
|
|
77
|
+
async voices(engine, opts = {}) {
|
|
78
|
+
const fields = engine === undefined ? {} : { engine };
|
|
79
|
+
if (opts.refresh !== undefined)
|
|
80
|
+
fields.refresh = opts.refresh;
|
|
81
|
+
const r = await this.request("voices", fields);
|
|
82
|
+
return r.voices;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Call `cb` with every `state` snapshot. The first is the current state,
|
|
86
|
+
* also for a listener added after others: the runtime sends state on
|
|
87
|
+
* change only, so the latest snapshot is replayed to it (asynchronously).
|
|
88
|
+
*/
|
|
89
|
+
onState(cb) {
|
|
90
|
+
const off = this.listen(this.stateListeners, cb);
|
|
91
|
+
const last = this.lastState;
|
|
92
|
+
if (last) {
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
// Skip it when unsubscribed meanwhile or a newer state already came.
|
|
95
|
+
if (this.stateListeners.has(cb) && this.lastState === last)
|
|
96
|
+
cb(last);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return off;
|
|
100
|
+
}
|
|
101
|
+
/** Call `cb` with every `item` event (started, finished, skipped, failed). */
|
|
102
|
+
onItem(cb) {
|
|
103
|
+
return this.listen(this.itemListeners, cb);
|
|
104
|
+
}
|
|
105
|
+
/** Call `cb` with every `log` event. */
|
|
106
|
+
onLog(cb) {
|
|
107
|
+
return this.listen(this.logListeners, cb);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Call `cb` once the request connection closes for any reason. A drop of
|
|
111
|
+
* the event connection alone is reported to `onLog` ("event stream
|
|
112
|
+
* closed"); the next listener added opens a new one.
|
|
113
|
+
*/
|
|
114
|
+
onClose(cb) {
|
|
115
|
+
this.closeListeners.add(cb);
|
|
116
|
+
return () => {
|
|
117
|
+
this.closeListeners.delete(cb);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolves once the event connection is subscribed (opened by the first
|
|
122
|
+
* listener), so nothing that follows is missed. Rejects when that failed;
|
|
123
|
+
* the next listener added tries again.
|
|
124
|
+
*/
|
|
125
|
+
eventsReady() {
|
|
126
|
+
return this.eventsOpening ?? Promise.resolve();
|
|
127
|
+
}
|
|
128
|
+
/** Close both connections. The runtime keeps running for other clients and exits on its own when idle. */
|
|
129
|
+
async close() {
|
|
130
|
+
this.closing = true;
|
|
131
|
+
this.eventConn?.close();
|
|
132
|
+
this.conn.close();
|
|
133
|
+
}
|
|
134
|
+
listen(set, cb) {
|
|
135
|
+
set.add(cb);
|
|
136
|
+
// A failed attempt is retried by the next listener.
|
|
137
|
+
if ((!this.eventsOpening || this.eventsFailed) && !this.closing) {
|
|
138
|
+
this.eventsFailed = false;
|
|
139
|
+
this.eventsOpening = this.openEvents();
|
|
140
|
+
// The failure is reported to log listeners; eventsReady() still rejects.
|
|
141
|
+
this.eventsOpening.catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
return () => {
|
|
144
|
+
set.delete(cb);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async openEvents() {
|
|
148
|
+
try {
|
|
149
|
+
const conn = await this.dial();
|
|
150
|
+
if (this.closing) {
|
|
151
|
+
conn.close();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.eventConn = conn;
|
|
155
|
+
conn.onEvent = (e) => this.dispatch(e);
|
|
156
|
+
await conn.request("subscribe", { events: ["state", "items", "log"] });
|
|
157
|
+
// The event connection can drop on its own (the request connection
|
|
158
|
+
// reports through onClose). Once subscribed, tell log listeners, and
|
|
159
|
+
// let the next listener open a new one.
|
|
160
|
+
conn.onClose = () => {
|
|
161
|
+
if (this.eventConn === conn)
|
|
162
|
+
this.eventConn = null;
|
|
163
|
+
// A new event connection starts with the then current state.
|
|
164
|
+
this.lastState = null;
|
|
165
|
+
if (this.closing || this.conn.closed)
|
|
166
|
+
return;
|
|
167
|
+
this.eventsFailed = true;
|
|
168
|
+
for (const cb of [...this.logListeners])
|
|
169
|
+
cb({ message: "event stream closed" });
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
this.eventsFailed = true;
|
|
174
|
+
const message = `event stream failed: ${err.message}`;
|
|
175
|
+
for (const cb of [...this.logListeners])
|
|
176
|
+
cb({ message });
|
|
177
|
+
throw err instanceof errors_js_1.SonaraError ? err : new errors_js_1.SonaraError("E_CLOSED", message);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
dispatch(e) {
|
|
181
|
+
const { event, ...body } = e;
|
|
182
|
+
if (event === "state") {
|
|
183
|
+
const state = body;
|
|
184
|
+
this.lastState = state;
|
|
185
|
+
for (const cb of [...this.stateListeners])
|
|
186
|
+
cb(state);
|
|
187
|
+
}
|
|
188
|
+
else if (event === "item")
|
|
189
|
+
for (const cb of [...this.itemListeners])
|
|
190
|
+
cb(body);
|
|
191
|
+
else if (event === "log")
|
|
192
|
+
for (const cb of [...this.logListeners])
|
|
193
|
+
cb(body);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
exports.SonaraClient = SonaraClient;
|
|
@@ -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,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.connect = connect;
|
|
4
|
+
const client_js_1 = require("./client.js");
|
|
5
|
+
const connection_js_1 = require("./connection.js");
|
|
6
|
+
const discovery_js_1 = require("./discovery.js");
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const version_js_1 = require("./version.js");
|
|
9
|
+
const CONNECT_TIMEOUT_MS = 5000;
|
|
10
|
+
const EXIT_WAIT_MS = 5000;
|
|
11
|
+
/** Why an instance cannot serve this client. */
|
|
12
|
+
class Incompatible extends Error {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Connect to the shared Sonara runtime (spec section 3):
|
|
16
|
+
*
|
|
17
|
+
* 1. Read `runtime.json` in the home; when its pid is alive, connect and
|
|
18
|
+
* send `hello`.
|
|
19
|
+
* 2. Use it when it speaks protocol 1 and offers everything in `require`.
|
|
20
|
+
* 3. Otherwise (and with `autostart`), start `runtimePath --home <home>`,
|
|
21
|
+
* wait up to 5 s for its `runtime.json`, then `hello`.
|
|
22
|
+
* 4. An incompatible running instance is first asked to step down
|
|
23
|
+
* (`hello` with `takeover: true`); while it is busy the takeover is
|
|
24
|
+
* retried for up to 30 s, then `E_INCOMPATIBLE`.
|
|
25
|
+
*/
|
|
26
|
+
async function connect(opts) {
|
|
27
|
+
if (!opts || typeof opts.clientName !== "string" || !opts.clientName) {
|
|
28
|
+
throw new errors_js_1.SonaraError("E_BAD_REQUEST", "connect() needs a clientName");
|
|
29
|
+
}
|
|
30
|
+
const home = (0, discovery_js_1.resolveHome)(opts.home);
|
|
31
|
+
const runtimePath = opts.runtimePath ?? process.env.SONARA_RUNTIME;
|
|
32
|
+
const canStart = (opts.autostart ?? true) && !!runtimePath;
|
|
33
|
+
const require = opts.require ?? [];
|
|
34
|
+
const hello = {
|
|
35
|
+
client: { name: opts.clientName, version: opts.clientVersion ?? version_js_1.VERSION },
|
|
36
|
+
protocol: { ...version_js_1.PROTOCOL },
|
|
37
|
+
require,
|
|
38
|
+
extensions: opts.extensions ?? [],
|
|
39
|
+
};
|
|
40
|
+
if (opts.keepAlive)
|
|
41
|
+
hello.keep_alive = true;
|
|
42
|
+
const greeting = { hello, require };
|
|
43
|
+
const running = (0, discovery_js_1.liveRuntime)(home);
|
|
44
|
+
if (running) {
|
|
45
|
+
try {
|
|
46
|
+
const client = await greet(running, greeting);
|
|
47
|
+
if (client)
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (!(err instanceof Incompatible))
|
|
52
|
+
throw err;
|
|
53
|
+
if (!canStart) {
|
|
54
|
+
throw new errors_js_1.SonaraError("E_INCOMPATIBLE", `the running Sonara ${running.version} cannot serve this client (${err.message}) and there is no runtime to start in its place`);
|
|
55
|
+
}
|
|
56
|
+
await takeOver(home, opts, err.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!canStart) {
|
|
60
|
+
throw new errors_js_1.SonaraError("E_NOT_RUNNING", runtimePath
|
|
61
|
+
? "no Sonara runtime is running and autostart is off"
|
|
62
|
+
: "no Sonara runtime is running and no runtimePath to start one");
|
|
63
|
+
}
|
|
64
|
+
const started = await (0, discovery_js_1.startRuntime)(runtimePath, home, opts.runtimeArgs ?? [], opts.startTimeoutMs ?? 5000);
|
|
65
|
+
try {
|
|
66
|
+
const client = await greet(started, greeting);
|
|
67
|
+
if (client)
|
|
68
|
+
return client;
|
|
69
|
+
throw new errors_js_1.SonaraError("E_CLOSED", "the started runtime closed the connection");
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof Incompatible) {
|
|
73
|
+
throw new errors_js_1.SonaraError("E_INCOMPATIBLE", `the bundled Sonara ${started.version} cannot serve this client: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Connect and send `hello`. Null when the instance cannot be reached (it is
|
|
80
|
+
* exiting); throws Incompatible when it answers but cannot serve us.
|
|
81
|
+
*/
|
|
82
|
+
async function greet(info, g) {
|
|
83
|
+
const dial = async () => {
|
|
84
|
+
const conn = await connection_js_1.Connection.open(info.port, CONNECT_TIMEOUT_MS);
|
|
85
|
+
try {
|
|
86
|
+
await conn.request("hello", { ...g.hello, token: info.token });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
conn.close();
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
return conn;
|
|
93
|
+
};
|
|
94
|
+
let conn;
|
|
95
|
+
try {
|
|
96
|
+
conn = await connection_js_1.Connection.open(info.port, CONNECT_TIMEOUT_MS);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
let reply;
|
|
102
|
+
try {
|
|
103
|
+
reply = await conn.request("hello", { ...g.hello, token: info.token });
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
conn.close();
|
|
107
|
+
if (err instanceof errors_js_1.SonaraError && (err.code === "E_INCOMPATIBLE" || err.code === "E_UNSUPPORTED")) {
|
|
108
|
+
throw new Incompatible(err.message);
|
|
109
|
+
}
|
|
110
|
+
if (err instanceof errors_js_1.SonaraError && err.code === "E_CLOSED")
|
|
111
|
+
return null;
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
const helloInfo = reply;
|
|
115
|
+
const offered = new Set([...(helloInfo.capabilities ?? []), ...(helloInfo.extensions ?? [])]);
|
|
116
|
+
const missing = g.require.filter((r) => !offered.has(r));
|
|
117
|
+
if (helloInfo.protocol?.major !== version_js_1.PROTOCOL.major || missing.length) {
|
|
118
|
+
conn.close();
|
|
119
|
+
throw new Incompatible(missing.length ? `it does not offer ${missing.join(", ")}` : `it speaks protocol ${helloInfo.protocol?.major}`);
|
|
120
|
+
}
|
|
121
|
+
return new client_js_1.SonaraClient(conn, info, helloInfo, dial);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Ask the running instance to exit so the bundled runtime can start. It
|
|
125
|
+
* accepts only when idle; while it is busy, retry until `takeoverTimeoutMs`.
|
|
126
|
+
* Each attempt uses a fresh connection: the runtime closes one that has no
|
|
127
|
+
* successful `hello` within 5 s.
|
|
128
|
+
*/
|
|
129
|
+
async function takeOver(home, opts, why) {
|
|
130
|
+
const deadline = Date.now() + (opts.takeoverTimeoutMs ?? 30000);
|
|
131
|
+
const retryMs = opts.takeoverRetryMs ?? 250;
|
|
132
|
+
const client = { name: opts.clientName, version: opts.clientVersion ?? version_js_1.VERSION };
|
|
133
|
+
for (;;) {
|
|
134
|
+
const cur = (0, discovery_js_1.liveRuntime)(home);
|
|
135
|
+
if (!cur)
|
|
136
|
+
return;
|
|
137
|
+
let conn = null;
|
|
138
|
+
try {
|
|
139
|
+
conn = await connection_js_1.Connection.open(cur.port, CONNECT_TIMEOUT_MS);
|
|
140
|
+
await conn.request("hello", { token: cur.token, client, takeover: true });
|
|
141
|
+
conn.close();
|
|
142
|
+
if (await (0, discovery_js_1.waitExit)(cur.pid, EXIT_WAIT_MS))
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
conn?.close();
|
|
147
|
+
const code = err instanceof errors_js_1.SonaraError ? err.code : "";
|
|
148
|
+
if (code !== "E_BUSY" && code !== "E_CLOSED") {
|
|
149
|
+
throw new errors_js_1.SonaraError("E_INCOMPATIBLE", `the running Sonara cannot serve this client (${why}) and refused a takeover: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() >= deadline) {
|
|
153
|
+
throw new errors_js_1.SonaraError("E_INCOMPATIBLE", `the running Sonara cannot serve this client (${why}) and stayed busy; gave up the takeover`);
|
|
154
|
+
}
|
|
155
|
+
await (0, discovery_js_1.sleep)(retryMs);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Reply } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One TCP JSON-lines connection to the runtime. Requests carry an `id`;
|
|
4
|
+
* replies (with `ok`) settle the matching promise, events (with `event`) go
|
|
5
|
+
* to `onEvent`.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Connection {
|
|
8
|
+
onEvent: ((event: Record<string, unknown>) => void) | null;
|
|
9
|
+
onClose: (() => void) | null;
|
|
10
|
+
private readonly sock;
|
|
11
|
+
private buf;
|
|
12
|
+
private nextId;
|
|
13
|
+
private readonly pending;
|
|
14
|
+
private closedFlag;
|
|
15
|
+
private constructor();
|
|
16
|
+
/** Connect to `127.0.0.1:<port>`; rejects after `timeoutMs`. */
|
|
17
|
+
static open(port: number, timeoutMs: number): Promise<Connection>;
|
|
18
|
+
get closed(): boolean;
|
|
19
|
+
/** Send `{type, ...fields}`; resolves with the `ok: true` reply, rejects with a SonaraError. */
|
|
20
|
+
request(type: string, fields?: Record<string, unknown>): Promise<Reply>;
|
|
21
|
+
close(): void;
|
|
22
|
+
private onData;
|
|
23
|
+
private onLine;
|
|
24
|
+
private finish;
|
|
25
|
+
}
|