@laplace.live/persona-sdk 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 LAPLACE Live!
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
13
+ all 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
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @laplace.live/persona-sdk
2
+
3
+ TypeScript SDK and wire schema for the [LAPLACE Persona](https://github.com/laplace-live/persona)
4
+ plugin API — a token-authenticated WebSocket API for driving the avatar, scenes, expressions,
5
+ motions, hotkeys, and tracking parameters from outside the app.
6
+
7
+ Isomorphic: runs in Node ≥22, Bun, browsers, and OBS browser sources on the global `WebSocket`.
8
+ One runtime dependency ([`zod`](https://zod.dev), backing the wire schemas).
9
+
10
+ ## Setup
11
+
12
+ Enable the API in Persona under **Advanced → Plugin API**, create a key, and copy the token.
13
+
14
+ ```ts
15
+ import { PersonaClient } from '@laplace.live/persona-sdk'
16
+
17
+ const persona = new PersonaClient({ token: 'sk-lp-v1-…' })
18
+ await persona.connect()
19
+
20
+ // Typed request/response
21
+ const { scenes, activeSceneId } = await persona.call('scene.list')
22
+ await persona.call('expression.toggle', { name: 'Smile' })
23
+ await persona.call('scene.patch', { behavior: { lookAtCursor: true } })
24
+
25
+ // Events (subscriptions survive reconnects)
26
+ persona.on('motion.started', m => console.log('playing', m.group))
27
+
28
+ // Parameter leases: the value keeps applying until released; the SDK owns the
29
+ // heartbeat, and if your process dies the parameter reverts within ~1 s.
30
+ const mouth = persona.driveParameter('MouthOpen', 0.8)
31
+ mouth.set(0.3)
32
+ mouth.release()
33
+ ```
34
+
35
+ By default the token travels as `?token=` (works in browsers). From Node you can use an
36
+ `Authorization: Bearer` header instead by supplying a socket that can set headers, e.g. the
37
+ [`ws`](https://www.npmjs.com/package/ws) package (install it separately — it is not a
38
+ dependency of this SDK):
39
+
40
+ ```ts
41
+ import WebSocket from 'ws'
42
+
43
+ const persona = new PersonaClient({
44
+ token,
45
+ auth: 'header',
46
+ createWebSocket: (url, headers) => new WebSocket(url, { headers }) as unknown as globalThis.WebSocket,
47
+ })
48
+ ```
49
+
50
+ Treat the token like a password: it grants control of the app, including loading registered
51
+ models and web overlays. The server listens on loopback unless LAN access is enabled in
52
+ Persona's settings.
@@ -0,0 +1,95 @@
1
+ import type { EventData, EventName } from './events.ts';
2
+ import type { MethodName, MethodRequest, MethodResponse } from './methods.ts';
3
+ import type { InjectTarget } from './types.ts';
4
+ export type PersonaClientState = 'closed' | 'connecting' | 'open' | 'reconnecting';
5
+ export interface PersonaClientOptions {
6
+ /** An API key created in Persona's settings. */
7
+ token: string;
8
+ /** Default `ws://127.0.0.1:25034`. */
9
+ url?: string;
10
+ /**
11
+ * How the token travels: `query` (`?token=`, works everywhere including browsers)
12
+ * or `header` (`Authorization: Bearer`, needs a WebSocket impl that accepts
13
+ * headers — pass one via `createWebSocket`).
14
+ */
15
+ auth?: 'query' | 'header';
16
+ /** Reconnect automatically after an unexpected close. Default true. */
17
+ reconnect?: boolean;
18
+ reconnectDelayMs?: number;
19
+ reconnectDelayMaxMs?: number;
20
+ requestTimeoutMs?: number;
21
+ /** Custom socket factory — for `ws` with headers, or tests. `headers` is set only for `auth: 'header'`. */
22
+ createWebSocket?: (url: string, headers: Record<string, string> | undefined) => WebSocket;
23
+ onStateChange?: (state: PersonaClientState) => void;
24
+ /** Non-fatal notices (protocol version mismatch). Default `console.warn`. */
25
+ onWarning?: (message: string) => void;
26
+ }
27
+ /** One held parameter lease. Values keep applying until `release()` (or the socket dies). */
28
+ export interface InjectionHandle {
29
+ set(value: number): void;
30
+ release(): void;
31
+ }
32
+ /**
33
+ * Typed client for the Persona plugin API: correlated request/response, event
34
+ * subscriptions that survive reconnects, and parameter leases with an owned
35
+ * heartbeat so an injected value never silently expires while held.
36
+ */
37
+ export declare class PersonaClient {
38
+ private readonly opts;
39
+ private ws;
40
+ private state;
41
+ private readonly pending;
42
+ private readonly listeners;
43
+ private readonly leases;
44
+ private heartbeat;
45
+ private reconnectTimer;
46
+ private reconnectAttempt;
47
+ private closedByUser;
48
+ private flushQueued;
49
+ private nextId;
50
+ private readonly idPrefix;
51
+ /** The server's `hello`, once connected. */
52
+ serverInfo: {
53
+ protocol: number;
54
+ app: {
55
+ name: string;
56
+ version: string;
57
+ platform: string;
58
+ };
59
+ } | null;
60
+ constructor(options: PersonaClientOptions);
61
+ getState(): PersonaClientState;
62
+ /** Open the connection and wait for the server's `hello`. Rejects on first failure. */
63
+ connect(): Promise<void>;
64
+ /** Close for good: pending calls reject, leases drop server-side via the lease timeout. */
65
+ close(): void;
66
+ /** Send one request and await its typed response. */
67
+ call<M extends MethodName>(method: M, ...args: MethodRequest<M> extends Record<never, never> ? [request?: MethodRequest<M>] : [request: MethodRequest<M>]): Promise<MethodResponse<M>>;
68
+ /**
69
+ * Listen for a server event. The subscription is established on the server and
70
+ * re-established after reconnects. @returns Unsubscribe.
71
+ */
72
+ on<E extends EventName>(event: E, cb: (data: EventData<E>) => void): () => void;
73
+ /**
74
+ * Hold a parameter lease: the value keeps applying (heartbeat every
75
+ * {@link INJECT_HEARTBEAT_MS} ms) until `release()`. A string target is
76
+ * shorthand for `{ type: 'input', id }`.
77
+ */
78
+ driveParameter(target: string | InjectTarget, value: number, opts?: {
79
+ weight?: number;
80
+ onError?: (e: Error) => void;
81
+ }): InjectionHandle;
82
+ private setState;
83
+ private warn;
84
+ private buildUrl;
85
+ private open;
86
+ private onHello;
87
+ private onServerMessage;
88
+ private failPending;
89
+ private scheduleReconnect;
90
+ private startHeartbeat;
91
+ private stopHeartbeat;
92
+ /** Coalesce same-tick `set()` calls into one frame. */
93
+ private queueFlush;
94
+ private flushLeases;
95
+ }
package/dist/client.js ADDED
@@ -0,0 +1,312 @@
1
+ import { parseServerMessage } from "./envelope.js";
2
+ import { PersonaApiError } from "./errors.js";
3
+ import { DEFAULT_API_PORT, INJECT_HEARTBEAT_MS, injectTargetKey, PROTOCOL_VERSION } from "./protocol.js";
4
+ const DEFAULTS = {
5
+ url: `ws://127.0.0.1:${String(DEFAULT_API_PORT)}`,
6
+ reconnectDelayMs: 500,
7
+ reconnectDelayMaxMs: 10_000,
8
+ requestTimeoutMs: 10_000,
9
+ };
10
+ /**
11
+ * Typed client for the Persona plugin API: correlated request/response, event
12
+ * subscriptions that survive reconnects, and parameter leases with an owned
13
+ * heartbeat so an injected value never silently expires while held.
14
+ */
15
+ export class PersonaClient {
16
+ opts;
17
+ ws = null;
18
+ state = 'closed';
19
+ pending = new Map();
20
+ listeners = new Map();
21
+ leases = new Map();
22
+ heartbeat = null;
23
+ reconnectTimer = null;
24
+ reconnectAttempt = 0;
25
+ closedByUser = false;
26
+ flushQueued = false;
27
+ nextId = 1;
28
+ idPrefix = Math.random().toString(36).slice(2, 10);
29
+ /** The server's `hello`, once connected. */
30
+ serverInfo = null;
31
+ constructor(options) {
32
+ this.opts = {
33
+ url: DEFAULTS.url,
34
+ auth: 'query',
35
+ reconnect: true,
36
+ ...options,
37
+ };
38
+ }
39
+ getState() {
40
+ return this.state;
41
+ }
42
+ /** Open the connection and wait for the server's `hello`. Rejects on first failure. */
43
+ async connect() {
44
+ if (this.state === 'open' || this.state === 'connecting')
45
+ return;
46
+ this.closedByUser = false;
47
+ this.setState('connecting');
48
+ await this.open();
49
+ }
50
+ /** Close for good: pending calls reject, leases drop server-side via the lease timeout. */
51
+ close() {
52
+ this.closedByUser = true;
53
+ if (this.reconnectTimer !== null)
54
+ clearTimeout(this.reconnectTimer);
55
+ this.reconnectTimer = null;
56
+ this.stopHeartbeat();
57
+ this.failPending(new Error('client closed'));
58
+ const ws = this.ws;
59
+ this.ws = null;
60
+ ws?.close();
61
+ this.setState('closed');
62
+ }
63
+ /** Send one request and await its typed response. */
64
+ call(method, ...args) {
65
+ const params = args[0];
66
+ const ws = this.ws;
67
+ if (ws === null || ws.readyState !== 1 /* OPEN */) {
68
+ return Promise.reject(new Error('not connected'));
69
+ }
70
+ const id = `${this.idPrefix}-${String(this.nextId++)}`;
71
+ return new Promise((resolve, reject) => {
72
+ const timer = setTimeout(() => {
73
+ this.pending.delete(id);
74
+ reject(new Error(`request timed out: ${method}`));
75
+ }, this.opts.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs);
76
+ this.pending.set(id, { resolve: resolve, reject, timer });
77
+ ws.send(JSON.stringify({ kind: 'request', id, method, ...(params === undefined ? {} : { params }) }));
78
+ });
79
+ }
80
+ /**
81
+ * Listen for a server event. The subscription is established on the server and
82
+ * re-established after reconnects. @returns Unsubscribe.
83
+ */
84
+ on(event, cb) {
85
+ let set = this.listeners.get(event);
86
+ const isNew = set === undefined;
87
+ if (set === undefined) {
88
+ set = new Set();
89
+ this.listeners.set(event, set);
90
+ }
91
+ set.add(cb);
92
+ if (isNew && this.state === 'open') {
93
+ void this.call('events.subscribe', { events: [event] }).catch(() => undefined);
94
+ }
95
+ return () => {
96
+ const s = this.listeners.get(event);
97
+ if (!s)
98
+ return;
99
+ s.delete(cb);
100
+ if (s.size === 0) {
101
+ this.listeners.delete(event);
102
+ if (this.state === 'open')
103
+ void this.call('events.unsubscribe', { events: [event] }).catch(() => undefined);
104
+ }
105
+ };
106
+ }
107
+ /**
108
+ * Hold a parameter lease: the value keeps applying (heartbeat every
109
+ * {@link INJECT_HEARTBEAT_MS} ms) until `release()`. A string target is
110
+ * shorthand for `{ type: 'input', id }`.
111
+ */
112
+ driveParameter(target, value, opts) {
113
+ const t = typeof target === 'string' ? { type: 'input', id: target } : target;
114
+ const key = injectTargetKey(t);
115
+ const lease = {
116
+ entry: { ...t, value, ...(opts?.weight === undefined ? {} : { weight: opts.weight }) },
117
+ ...(opts?.onError === undefined ? {} : { onError: opts.onError }),
118
+ };
119
+ this.leases.set(key, lease);
120
+ this.startHeartbeat();
121
+ this.queueFlush();
122
+ return {
123
+ set: (v) => {
124
+ const held = this.leases.get(key);
125
+ if (held !== lease)
126
+ return; // released, or replaced by a newer handle
127
+ lease.entry.value = v;
128
+ this.queueFlush();
129
+ },
130
+ release: () => {
131
+ if (this.leases.get(key) !== lease)
132
+ return;
133
+ this.leases.delete(key);
134
+ if (this.leases.size === 0)
135
+ this.stopHeartbeat();
136
+ if (this.state === 'open') {
137
+ const target = { type: t.type, id: t.id, ...(t.instanceId ? { instanceId: t.instanceId } : {}) };
138
+ void this.call('param.release', { targets: [target] }).catch(() => undefined);
139
+ }
140
+ },
141
+ };
142
+ }
143
+ // ---- internals ----------------------------------------------------------------
144
+ setState(state) {
145
+ if (this.state === state)
146
+ return;
147
+ this.state = state;
148
+ this.opts.onStateChange?.(state);
149
+ }
150
+ warn(message) {
151
+ if (this.opts.onWarning)
152
+ this.opts.onWarning(message);
153
+ else
154
+ console.warn(`[persona-sdk] ${message}`);
155
+ }
156
+ buildUrl() {
157
+ if (this.opts.auth === 'header') {
158
+ return { url: this.opts.url, headers: { Authorization: `Bearer ${this.opts.token}` } };
159
+ }
160
+ const u = new URL(this.opts.url);
161
+ u.searchParams.set('token', this.opts.token);
162
+ return { url: u.toString(), headers: undefined };
163
+ }
164
+ open() {
165
+ const { url, headers } = this.buildUrl();
166
+ const ws = this.opts.createWebSocket
167
+ ? this.opts.createWebSocket(url, headers)
168
+ : headers === undefined
169
+ ? new WebSocket(url)
170
+ : (() => {
171
+ throw new Error("auth: 'header' needs a createWebSocket factory that can set headers");
172
+ })();
173
+ this.ws = ws;
174
+ return new Promise((resolve, reject) => {
175
+ let settled = false;
176
+ const settle = (err) => {
177
+ if (settled)
178
+ return;
179
+ settled = true;
180
+ if (err)
181
+ reject(err);
182
+ else
183
+ resolve();
184
+ };
185
+ ws.addEventListener('message', ev => {
186
+ const data = ev.data;
187
+ if (typeof data !== 'string')
188
+ return;
189
+ const msg = parseServerMessage(data);
190
+ if (msg === null)
191
+ return;
192
+ if (msg.kind === 'hello') {
193
+ this.onHello(msg.protocol, msg.app);
194
+ settle(null);
195
+ return;
196
+ }
197
+ this.onServerMessage(msg);
198
+ });
199
+ ws.addEventListener('close', () => {
200
+ if (this.ws !== ws)
201
+ return; // an intentional close already moved on
202
+ this.ws = null;
203
+ this.serverInfo = null;
204
+ this.failPending(new Error('connection closed'));
205
+ settle(new Error('connection closed'));
206
+ if (this.closedByUser) {
207
+ this.setState('closed');
208
+ return;
209
+ }
210
+ if (!this.opts.reconnect || this.state === 'connecting') {
211
+ // Initial connect failed: report to the caller instead of retrying forever.
212
+ this.setState('closed');
213
+ return;
214
+ }
215
+ this.scheduleReconnect();
216
+ });
217
+ ws.addEventListener('error', () => {
218
+ // The close event follows and carries the terminal handling.
219
+ });
220
+ });
221
+ }
222
+ onHello(protocol, app) {
223
+ this.serverInfo = { protocol, app };
224
+ if (protocol !== PROTOCOL_VERSION) {
225
+ this.warn(`protocol version mismatch: server speaks v${String(protocol)}, SDK speaks v${String(PROTOCOL_VERSION)}`);
226
+ }
227
+ this.reconnectAttempt = 0;
228
+ this.setState('open');
229
+ const events = [...this.listeners.keys()];
230
+ if (events.length > 0)
231
+ void this.call('events.subscribe', { events }).catch(() => undefined);
232
+ if (this.leases.size > 0)
233
+ this.queueFlush();
234
+ }
235
+ onServerMessage(msg) {
236
+ if (msg.kind === 'event') {
237
+ const set = this.listeners.get(msg.event);
238
+ if (!set)
239
+ return;
240
+ for (const cb of set)
241
+ cb(msg.data);
242
+ return;
243
+ }
244
+ const { id } = msg;
245
+ if (id === null)
246
+ return;
247
+ const p = this.pending.get(id);
248
+ if (!p)
249
+ return;
250
+ this.pending.delete(id);
251
+ clearTimeout(p.timer);
252
+ if (msg.kind === 'response')
253
+ p.resolve(msg.result);
254
+ else
255
+ p.reject(new PersonaApiError(msg.code, msg.message));
256
+ }
257
+ failPending(err) {
258
+ for (const p of this.pending.values()) {
259
+ clearTimeout(p.timer);
260
+ p.reject(err);
261
+ }
262
+ this.pending.clear();
263
+ }
264
+ scheduleReconnect() {
265
+ this.setState('reconnecting');
266
+ const base = this.opts.reconnectDelayMs ?? DEFAULTS.reconnectDelayMs;
267
+ const max = this.opts.reconnectDelayMaxMs ?? DEFAULTS.reconnectDelayMaxMs;
268
+ const delay = Math.min(base * 2 ** this.reconnectAttempt, max);
269
+ this.reconnectAttempt++;
270
+ this.reconnectTimer = setTimeout(() => {
271
+ this.reconnectTimer = null;
272
+ if (this.closedByUser)
273
+ return;
274
+ void this.open().catch(() => undefined); // close handler schedules the next attempt
275
+ }, delay);
276
+ }
277
+ // ---- lease heartbeat ----------------------------------------------------------
278
+ startHeartbeat() {
279
+ if (this.heartbeat !== null)
280
+ return;
281
+ this.heartbeat = setInterval(() => {
282
+ this.flushLeases();
283
+ }, INJECT_HEARTBEAT_MS);
284
+ }
285
+ stopHeartbeat() {
286
+ if (this.heartbeat === null)
287
+ return;
288
+ clearInterval(this.heartbeat);
289
+ this.heartbeat = null;
290
+ }
291
+ /** Coalesce same-tick `set()` calls into one frame. */
292
+ queueFlush() {
293
+ if (this.flushQueued)
294
+ return;
295
+ this.flushQueued = true;
296
+ queueMicrotask(() => {
297
+ this.flushQueued = false;
298
+ this.flushLeases();
299
+ });
300
+ }
301
+ flushLeases() {
302
+ if (this.leases.size === 0 || this.state !== 'open')
303
+ return;
304
+ const entries = [...this.leases.values()].map(l => l.entry);
305
+ const errorSinks = [...this.leases.values()];
306
+ void this.call('param.inject', { entries }).catch((e) => {
307
+ const err = e instanceof Error ? e : new Error(String(e));
308
+ for (const l of errorSinks)
309
+ l.onError?.(err);
310
+ });
311
+ }
312
+ }
@@ -0,0 +1,54 @@
1
+ import type { ApiErrorCode } from './errors.ts';
2
+ import type { EventData, EventName } from './events.ts';
3
+ import type { MethodName, MethodRequest } from './methods.ts';
4
+ export interface RequestMessage<M extends MethodName = MethodName> {
5
+ kind: 'request';
6
+ /** Correlation id, echoed on the response. Non-empty, client-chosen. */
7
+ id: string;
8
+ method: M;
9
+ params?: MethodRequest<M>;
10
+ }
11
+ /** First message on every connection; carries the protocol version to compare against. */
12
+ export interface HelloMessage {
13
+ kind: 'hello';
14
+ protocol: number;
15
+ app: {
16
+ name: string;
17
+ version: string;
18
+ platform: string;
19
+ };
20
+ }
21
+ export interface ResponseMessage {
22
+ kind: 'response';
23
+ id: string;
24
+ result: unknown;
25
+ }
26
+ /** `id` is null when the failure has no request to blame (unparseable frame). */
27
+ export interface ErrorMessage {
28
+ kind: 'error';
29
+ id: string | null;
30
+ code: ApiErrorCode;
31
+ message: string;
32
+ }
33
+ export interface EventMessage<E extends EventName = EventName> {
34
+ kind: 'event';
35
+ event: E;
36
+ data: EventData<E>;
37
+ }
38
+ export type ServerMessage = HelloMessage | ResponseMessage | ErrorMessage | EventMessage;
39
+ export type ClientMessage = RequestMessage;
40
+ /**
41
+ * Parse one inbound client frame. Returns the request, or an error code telling
42
+ * the server what to answer: `parse-error` for junk bytes, `invalid-request`
43
+ * (with the id when one was salvageable) for a malformed envelope.
44
+ */
45
+ export declare function parseClientMessage(raw: string): {
46
+ ok: true;
47
+ message: RequestMessage;
48
+ } | {
49
+ ok: false;
50
+ code: 'parse-error' | 'invalid-request';
51
+ id: string | null;
52
+ };
53
+ /** Parse one inbound server frame; null for anything that is not a known server message. */
54
+ export declare function parseServerMessage(raw: string): ServerMessage | null;
@@ -0,0 +1,71 @@
1
+ import { isApiErrorCode } from "./errors.js";
2
+ import { isEventName } from "./events.js";
3
+ function isRecord(v) {
4
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ }
6
+ /**
7
+ * Parse one inbound client frame. Returns the request, or an error code telling
8
+ * the server what to answer: `parse-error` for junk bytes, `invalid-request`
9
+ * (with the id when one was salvageable) for a malformed envelope.
10
+ */
11
+ export function parseClientMessage(raw) {
12
+ let v;
13
+ try {
14
+ v = JSON.parse(raw);
15
+ }
16
+ catch {
17
+ return { ok: false, code: 'parse-error', id: null };
18
+ }
19
+ if (!isRecord(v))
20
+ return { ok: false, code: 'invalid-request', id: null };
21
+ const id = typeof v.id === 'string' && v.id !== '' ? v.id : null;
22
+ if (v.kind !== 'request' || id === null)
23
+ return { ok: false, code: 'invalid-request', id };
24
+ if (typeof v.method !== 'string' || v.method === '')
25
+ return { ok: false, code: 'invalid-request', id };
26
+ if (v.params !== undefined && !isRecord(v.params))
27
+ return { ok: false, code: 'invalid-request', id };
28
+ return {
29
+ ok: true,
30
+ message: { kind: 'request', id, method: v.method, params: v.params },
31
+ };
32
+ }
33
+ /** Parse one inbound server frame; null for anything that is not a known server message. */
34
+ export function parseServerMessage(raw) {
35
+ let v;
36
+ try {
37
+ v = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ if (!isRecord(v))
43
+ return null;
44
+ switch (v.kind) {
45
+ case 'hello': {
46
+ if (typeof v.protocol !== 'number' || !isRecord(v.app))
47
+ return null;
48
+ const { name, version, platform } = v.app;
49
+ if (typeof name !== 'string' || typeof version !== 'string' || typeof platform !== 'string')
50
+ return null;
51
+ return { kind: 'hello', protocol: v.protocol, app: { name, version, platform } };
52
+ }
53
+ case 'response':
54
+ if (typeof v.id !== 'string' || v.id === '')
55
+ return null;
56
+ return { kind: 'response', id: v.id, result: v.result };
57
+ case 'error': {
58
+ if (v.id !== null && (typeof v.id !== 'string' || v.id === ''))
59
+ return null;
60
+ if (!isApiErrorCode(v.code))
61
+ return null;
62
+ return { kind: 'error', id: v.id, code: v.code, message: typeof v.message === 'string' ? v.message : '' };
63
+ }
64
+ case 'event':
65
+ if (!isEventName(v.event))
66
+ return null;
67
+ return { kind: 'event', event: v.event, data: v.data };
68
+ default:
69
+ return null;
70
+ }
71
+ }
@@ -0,0 +1,9 @@
1
+ /** Every error the server can return, as string discriminants (never numeric bands). */
2
+ export declare const API_ERROR_CODES: readonly ["parse-error", "invalid-request", "unknown-method", "invalid-params", "not-found", "unsupported-for-format", "conflict", "renderer-unavailable", "forbidden-path", "invalid-state", "internal"];
3
+ export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
4
+ export declare function isApiErrorCode(v: unknown): v is ApiErrorCode;
5
+ /** Thrown by {@link PersonaClient.call} when the server answers with an error envelope. */
6
+ export declare class PersonaApiError extends Error {
7
+ readonly code: ApiErrorCode;
8
+ constructor(code: ApiErrorCode, message: string);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ /** Every error the server can return, as string discriminants (never numeric bands). */
2
+ export const API_ERROR_CODES = [
3
+ 'parse-error',
4
+ 'invalid-request',
5
+ 'unknown-method',
6
+ 'invalid-params',
7
+ /** A referenced id (scene, instance, model, asset, hotkey) resolves to nothing. */
8
+ 'not-found',
9
+ /** The instance's model format cannot perform this method (e.g. MToon on Live2D). */
10
+ 'unsupported-for-format',
11
+ /** Another session holds the injection lease for this parameter. */
12
+ 'conflict',
13
+ /** The stage is not available to answer (no window, renderer reloading, or timeout). */
14
+ 'renderer-unavailable',
15
+ /** The path is outside every user-configured plugin folder. */
16
+ 'forbidden-path',
17
+ /** The operation is not allowed in the current state (e.g. deleting the last scene). */
18
+ 'invalid-state',
19
+ 'internal',
20
+ ];
21
+ export function isApiErrorCode(v) {
22
+ return typeof v === 'string' && API_ERROR_CODES.includes(v);
23
+ }
24
+ /** Thrown by {@link PersonaClient.call} when the server answers with an error envelope. */
25
+ export class PersonaApiError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.name = 'PersonaApiError';
30
+ this.code = code;
31
+ }
32
+ }