@touchcastllc/napster-companion-api-dev 1.0.0-alpha.81 → 1.0.0-alpha.83

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.
@@ -0,0 +1,59 @@
1
+ import "./Persistence.css";
2
+ import type { PersistenceOptions } from "../types";
3
+ /**
4
+ * Well-known event a site (or its WebMCP tools) dispatches on `window` to request an
5
+ * IN-SESSION navigation under `iframeOnNavigate` — instead of a full top-level load that
6
+ * would end the session. The engine listens while native (before the first wrap); on the
7
+ * event it wraps into the iframe and loads `detail.url`, then calls `preventDefault()` so
8
+ * the caller knows it was handled and skips its own `location.assign`. Dispatch it
9
+ * cancelable and check `defaultPrevented`:
10
+ *
11
+ * const e = new CustomEvent('napster:persist:navigate', { detail: { url }, cancelable: true });
12
+ * window.dispatchEvent(e);
13
+ * if (!e.defaultPrevented) location.assign(url); // nothing handled it — navigate normally
14
+ *
15
+ * Only meaningful top-level (a tool inside the wrapped iframe should navigate the iframe
16
+ * directly). Excluded URLs (persistence.exclude) are ignored so they still break out.
17
+ */
18
+ export declare const PERSIST_NAVIGATE_EVENT = "napster:persist:navigate";
19
+ /**
20
+ * Class marking the same-origin iframe the site is wrapped into under persistence. Owned
21
+ * here (this module creates the frame); the frame guard below and both entry points key
22
+ * off it, so the marker is defined in exactly one place.
23
+ */
24
+ export declare const SITE_FRAME_CLASS = "np_site-frame";
25
+ /**
26
+ * True when the current window is running INSIDE our own persistence site frame (the
27
+ * `.np_site-frame` iframe) rather than the top document or a third-party embed. Both entry
28
+ * points call it to no-op the framed copy of the snippet. Guarded against a cross-origin
29
+ * parent, where reading `frameElement` throws.
30
+ */
31
+ export declare function isInOwnSiteFrame(): boolean;
32
+ /** What the persistence engine needs from whoever drives the session. */
33
+ export interface PersistenceHooks {
34
+ /** The avatar root (`#np_companion-sdk-root`). The iframe is inserted before it, and it's kept visible while the rest of the page is hidden. */
35
+ root: HTMLElement;
36
+ /** Re-target the Edge MCP bridge onto the site iframe (or `null` for the top document). */
37
+ setEdgeMcpIframe: (iframe: HTMLIFrameElement | null) => void;
38
+ /** End the live session — called when a break-out link forces the tab out of the persisted site. */
39
+ endSession: () => void;
40
+ }
41
+ export interface PersistenceController {
42
+ /** The site iframe while active, else `null`. */
43
+ getIframe(): HTMLIFrameElement | null;
44
+ /** Eager mode: wrap the current page into the iframe now (before the session connects). */
45
+ wrapNow(): void;
46
+ /** On-navigate mode: arm the interceptor so the first allowed link wraps the site. */
47
+ armOnNavigate(): void;
48
+ /**
49
+ * Session ended: unwrap onto the page the user is on (a real top-level navigation, so
50
+ * the page reloads without the iframe). Returns `true` if it navigated; `false` if
51
+ * there was nothing to unwrap (no iframe, or unreadable), so the caller can reveal
52
+ * whatever it had underneath.
53
+ */
54
+ end(): boolean;
55
+ /** Remove the iframe + listeners WITHOUT navigating (for a full teardown/destroy). */
56
+ teardown(): void;
57
+ }
58
+ /** Build the persistence engine. `config.enabled` is assumed `true` by the caller. */
59
+ export declare function createPersistence(config: PersistenceOptions, hooks: PersistenceHooks): PersistenceController;
@@ -0,0 +1,15 @@
1
+ export declare function shortId(id?: string): string;
2
+ export declare function kindLabel(kind: string): string;
3
+ export declare function describeDevice(device: MediaDeviceInfo): string;
4
+ export interface DeviceChangeReport {
5
+ /**
6
+ * Groups touched by an addition in this pass. A group lands here when any of
7
+ * its entries is new, so a headset whose input and output show up in separate
8
+ * events is reported on whichever pass completes it.
9
+ */
10
+ connectedGroupIds: string[];
11
+ }
12
+ export interface DeviceChangeReporter {
13
+ report(devices: MediaDeviceInfo[]): DeviceChangeReport;
14
+ }
15
+ export declare function createDeviceChangeReporter(): DeviceChangeReporter;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which microphone the session should be capturing from, decided purely from a
3
+ * device list snapshot. No IO and no state, so the whole decision table is
4
+ * testable with plain data — see audioDeviceSelection.test.ts.
5
+ */
6
+ export declare function isExplicitDevice(deviceId?: string): deviceId is string;
7
+ export declare function defaultOutputGroupId(devices: MediaDeviceInfo[]): string | undefined;
8
+ export declare function inputInGroup(devices: MediaDeviceInfo[], groupId: string): string | undefined;
9
+ export declare function outputInGroup(devices: MediaDeviceInfo[], groupId: string): string | undefined;
10
+ export type MicSelectionReason = "already-on-selected-device" | "selected-device-available" | "selected-device-gone-keeping-live-mic" | "selected-device-gone-and-mic-dead" | "already-paired-with-playback" | "pair-with-playback" | "pair-with-newly-connected-device" | "mic-dead-no-pairing-available" | "playback-device-has-no-input" | "default-device-not-identifiable";
11
+ export interface MicSelection {
12
+ action: "keep" | "acquire";
13
+ /** Only set for "acquire"; undefined means "follow the system default". */
14
+ deviceId?: string;
15
+ reason: MicSelectionReason;
16
+ /** Group of the current default output, when the browser exposes it. */
17
+ playbackGroupId?: string;
18
+ }
19
+ export interface MicSelectionInput {
20
+ devices: MediaDeviceInfo[];
21
+ /** Explicit user choice, if any. Outranks the system default. */
22
+ pinnedDeviceId?: string;
23
+ /** Device the live track is currently capturing from. */
24
+ currentDeviceId?: string;
25
+ /** False once the device behind the track disappeared. */
26
+ trackLive: boolean;
27
+ /** The "ended" event already told us the device is gone. */
28
+ trackEnded: boolean;
29
+ /**
30
+ * Groups that gained a device in this pass. Only consulted when the browser
31
+ * exposes no "default" pseudo-entries to read the system default from.
32
+ */
33
+ connectedGroupIds?: string[];
34
+ }
35
+ /**
36
+ * Priority: an explicit user choice, then the input paired with the device the
37
+ * user is listening through, then the raw system default. A dead track is always
38
+ * replaced; a live one is only replaced when a better device is identifiable.
39
+ */
40
+ export declare function selectMicrophoneInput(input: MicSelectionInput): MicSelection;
@@ -40,6 +40,7 @@ export declare class EdgeMcpBridge {
40
40
  private lastInlineSig;
41
41
  private sessionReady;
42
42
  private disposed;
43
+ private targetEpoch;
43
44
  private readonly options;
44
45
  constructor(options: EdgeMcpBridgeOptions);
45
46
  /** The document whose `modelContext` we read — the target iframe's, or this window's. */
@@ -292,6 +292,41 @@ export interface avatarStyleConfig {
292
292
  * Allows both string and number values for CSS properties.
293
293
  */
294
294
  export type StyleObject = Partial<CSSStyleDeclaration>;
295
+ /**
296
+ * Cross-page persistence options. Works with BOTH entry points — `init` and
297
+ * `initWithButton` share the same persistence engine.
298
+ */
299
+ export interface PersistenceOptions {
300
+ /** Turn persistence on. Required — there is no boolean shorthand. */
301
+ enabled: boolean;
302
+ /**
303
+ * Defer the iframe wrap until the user first navigates. The landing page stays
304
+ * fully native — no iframe — and the wrap happens on the first allowed link click.
305
+ * Default: `false` (eager wrap on connect). Off by default because on-navigate only
306
+ * catches anchor clicks: a programmatic redirect on the landing page would fall
307
+ * through and end the session.
308
+ */
309
+ iframeOnNavigate?: boolean;
310
+ /**
311
+ * URLs/domains excluded from persistence: instead of loading inside the iframe they
312
+ * open as a normal top-level navigation (which ends the session). Use for pages that
313
+ * can't be framed (auth / SSO) or shouldn't be (checkout, signout). Cross-origin
314
+ * links always open top-level by default, so this only matters for same-origin.
315
+ */
316
+ exclude?: {
317
+ /** Hostnames to exclude (exact host or a parent domain, e.g. `"napster.com"`). */
318
+ domains?: string[];
319
+ /** URLs to exclude: an absolute `"https://…"` (exact/prefix) or a same-origin path prefix. */
320
+ urls?: string[];
321
+ };
322
+ /** Value for the site iframe's `allow` attribute (Permissions Policy). Default: `"autoplay; clipboard-write"`. */
323
+ iframeAllowAttribute?: string;
324
+ /**
325
+ * While a persisted session is active, mirror the iframe's path into the address bar
326
+ * and follow parent back/forward into the iframe. Same-origin only. Default: `true`.
327
+ */
328
+ syncHistory?: boolean;
329
+ }
295
330
  /**
296
331
  * Main configuration object passed to `init(token, config)`.
297
332
  * All fields are optional; sensible defaults are used by the SDK.
@@ -337,13 +372,7 @@ export interface NapsterCompanionApiConfig {
337
372
  functions?: CompanionFunction[];
338
373
  /** Enable debug logging throughout the SDK. When enabled, detailed logs will be output to the console. */
339
374
  debug?: boolean;
340
- /**
341
- * ICE servers (STUN/TURN) for the underlying WebRTC connection. When set,
342
- * these override the SDK's built-in defaults. Provide deployment-specific or,
343
- * preferably, short-lived TURN credentials here instead of relying on static
344
- * ones shipped in client code. The connection token, when it carries its own
345
- * ICE servers, takes precedence over this value.
346
- */
375
+ /** @internal */
347
376
  iceServers?: RTCIceServer[];
348
377
  /**
349
378
  * `targetOrigin` used when the SDK relays data-channel messages to
@@ -357,12 +386,22 @@ export interface NapsterCompanionApiConfig {
357
386
  */
358
387
  parentOrigin?: string;
359
388
  /**
360
- * Read WebMCP tools from a same-origin child iframe's `document.modelContext` instead of
361
- * this window's. Set by the persistent-agent shell (`mountShell` with `persist`) so the
362
- * avatar in the top-level document operates the SITE running inside the wrapped iframe.
363
- * Leave unset for a normal same-page integration.
389
+ * Keep the session alive across page navigation. When enabled, the page is wrapped in
390
+ * a same-origin iframe so navigation happens inside it while the avatar stays in the
391
+ * top document, which never reloads. Works with both `init` and `initWithButton`.
392
+ * When on, `mountContainer` is ignored — the avatar must live in the top document.
393
+ */
394
+ persistence?: PersistenceOptions;
395
+ /**
396
+ * The click-to-start button's appearance. **Only used by `initWithButton()`** —
397
+ * passing it to `init()` (which connects immediately, with no button) throws.
364
398
  */
365
- edgeMcpIframe?: HTMLIFrameElement;
399
+ button?: {
400
+ /** Button text. Default: `"Talk to an agent"`. */
401
+ label?: string;
402
+ /** Optional companion picture shown on the button. */
403
+ avatarUrl?: string;
404
+ };
366
405
  /** Analytics configuration controls analytics delivery and tracking. */
367
406
  analytics?: AnalyticsConfig;
368
407
  /** Lifecycle callbacks. All are optional. */
@@ -400,14 +439,6 @@ export interface NapsterCompanionApiInstance {
400
439
  avatarIsVisible: () => boolean;
401
440
  /** Tear down the SDK, remove DOM nodes and clean up resources. */
402
441
  destroy: () => void;
403
- /**
404
- * Re-target the Edge MCP bridge at runtime: read the site's WebMCP tools from the given
405
- * same-origin iframe's `document.modelContext`, or pass `null` to read them from this
406
- * window's document. Used by the persistent-agent shell's frame-on-navigate mode, where
407
- * the site is only wrapped in an iframe on the first navigation. No-op if the bridge is
408
- * inactive.
409
- */
410
- setEdgeMcpIframe: (iframe: HTMLIFrameElement | null) => void;
411
442
  /**
412
443
  * The current session identifier. Available after a connection is established
413
444
  * and reset to `undefined` once the connection is closed.
@@ -493,11 +524,12 @@ export interface NapsterCompanionApiSDK {
493
524
  */
494
525
  init(token: string, config?: Partial<NapsterCompanionApiConfig>): Promise<NapsterCompanionApiInstance>;
495
526
  /**
496
- * Mount a persistent-agent shell for multi-page apps: renders the site in a
497
- * same-origin iframe with the avatar in the top-level shell, so the agent
498
- * session survives navigation. No-op when run as the framed content.
527
+ * Render a button and connect on click (instead of connecting immediately like
528
+ * `init`). Returns a `NapsterCompanionApiController` synchronously — nothing connects
529
+ * until the user clicks. With `persistence`, the session survives navigation. No-op
530
+ * when run inside the persistence site frame.
499
531
  */
500
- mountShell(options: import("../shell/mount-shell").ShellOptions): import("../shell/mount-shell").ShellController;
532
+ initWithButton(getToken: () => Promise<string>, config?: Partial<NapsterCompanionApiConfig>): import("../button").NapsterCompanionApiController;
501
533
  /** The SDK version string (useful for diagnostics). */
502
534
  version: string;
503
535
  }
@@ -17,7 +17,10 @@ declare class DebugLogger {
17
17
  */
18
18
  private isDebugEnabled;
19
19
  /**
20
- * Generic log method that checks debug mode
20
+ * Generic log method. Every level — including warn and error — is gated behind
21
+ * debug mode, so a published SDK stays silent on host pages unless the integrator
22
+ * opts in with `debug: true`. Messages that must ALWAYS surface (genuine integrator
23
+ * misconfiguration) go through `critical_warn` / `critical_error`, which bypass this.
21
24
  */
22
25
  private log;
23
26
  /**
@@ -52,6 +55,11 @@ declare class DebugLogger {
52
55
  * Log Avatar-related debug information
53
56
  */
54
57
  avatar(...args: unknown[]): void;
58
+ /**
59
+ * Log audio device plug/unplug and the resulting input/output selection.
60
+ * Separate from `avatar`/`webrtc` so device churn can be read on its own.
61
+ */
62
+ devices(...args: unknown[]): void;
55
63
  /**
56
64
  * Log connection-related debug information
57
65
  */
@@ -61,7 +69,7 @@ declare class DebugLogger {
61
69
  */
62
70
  features(...args: unknown[]): void;
63
71
  /**
64
- * Log data/messaging-related debug information
72
+ * Log WebRTC data-channel messaging (send/receive over the peer connection).
65
73
  */
66
74
  data(...args: unknown[]): void;
67
75
  /**
@@ -72,6 +80,18 @@ declare class DebugLogger {
72
80
  * Log cleanup-related debug information
73
81
  */
74
82
  cleanup(...args: unknown[]): void;
83
+ /**
84
+ * Log Edge MCP bridge information
85
+ */
86
+ edgeMcp(...args: unknown[]): void;
87
+ /**
88
+ * Log button-entry-point information
89
+ */
90
+ button(...args: unknown[]): void;
91
+ /**
92
+ * Log cross-page persistence information (iframe wrap/unwrap, break-out, history sync).
93
+ */
94
+ persistence(...args: unknown[]): void;
75
95
  }
76
96
  /**
77
97
  * Global debug logger instance
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Name of the original DOMException behind a media failure. The microphone
3
+ * manager wraps it in a MediaError, so the discriminator an app actually needs
4
+ * — "NotAllowedError" (user blocked) vs "NotFoundError" (no device) — sits one
5
+ * level down in `context.error`.
6
+ */
7
+ export declare function rootCauseName(error: unknown): string | undefined;
8
+ /**
9
+ * Whether a media failure leaves the user with a recoverable permission
10
+ * problem. Both flavors land on the same "access blocked" screen: a denied
11
+ * prompt is fixable in site settings, and a missing device is equally fatal to
12
+ * a voice session, so neither may negotiate a session the agent cannot hear.
13
+ */
14
+ export declare function isMicUnavailable(error: unknown): boolean;
@@ -0,0 +1,2 @@
1
+ import type { NapsterCompanionApiConfig } from "../types";
2
+ export declare function normalizeConfig(partial: Partial<NapsterCompanionApiConfig>): NapsterCompanionApiConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@touchcastllc/napster-companion-api-dev",
3
- "version": "1.0.0-alpha.81",
3
+ "version": "1.0.0-alpha.83",
4
4
  "keywords": [
5
5
  "napster",
6
6
  "companion-api",
@@ -1,102 +0,0 @@
1
- import type { NapsterCompanionApiConfig, NapsterCompanionApiInstance } from "../types";
2
- export interface ShellOptions {
3
- /**
4
- * Returns a fresh connection token. The site implements this (typically a POST to
5
- * its own endpoint that mints a token server-side). Required.
6
- */
7
- getToken: () => Promise<string>;
8
- /**
9
- * Persist the session across page navigation by wrapping the current page in a
10
- * same-origin iframe on connect (needed for multi-page apps). Default: `false` —
11
- * the avatar mounts inline in the current page and a full page navigation ends the
12
- * session (a normal in-page widget). Turn on only when you need cross-navigation
13
- * persistence.
14
- */
15
- persist?: boolean;
16
- /**
17
- * Defer the iframe wrap until the user first navigates (requires `persist`). Instead of
18
- * wrapping the current page on connect, the landing page stays fully native — no iframe —
19
- * and the avatar mounts in the top-level document. The shell intercepts the first in-site
20
- * link click and, if the target is allowed (not matched by `persistConfig`), wraps the site
21
- * into an iframe THEN, loading the clicked page, so the session persists from that point on.
22
- *
23
- * Only anchor clicks are caught. Programmatic navigation (`location.assign`, `location.href=`),
24
- * address-bar/bookmark loads, modified/`_blank` clicks, and links matched by `persistConfig`
25
- * fall through to a normal top-level navigation, which ends the session (as a plain widget would).
26
- *
27
- * Default: `false` (wrap eagerly on connect). Ignored unless `persist` is on.
28
- */
29
- frameOnNavigate?: boolean;
30
- /**
31
- * The ONLY URLs that break out of the iframe (open at the top window and end the session).
32
- * A link is opened inside the iframe unless it matches this denylist — use it for URLs that
33
- * can't be framed, e.g. an identity / SSO subdomain, or same-origin paths that redirect
34
- * off-site. Everything not listed (including other cross-origin links) navigates in the iframe.
35
- */
36
- persistConfig?: {
37
- /**
38
- * Hostnames to force out of the iframe. Matched as the exact host OR a parent domain —
39
- * e.g. `"napster.com"` also matches `"identity.napster.com"`.
40
- */
41
- domains?: string[];
42
- /**
43
- * URLs to force out of the iframe: an absolute `"https://…"` (exact or prefix match), or a
44
- * same-origin path prefix like `"/signout"`.
45
- */
46
- urls?: string[];
47
- };
48
- /**
49
- * SECOND persistence variant (no iframe). When a session is active and the user navigates
50
- * (a normal full page load), automatically start a NEW session on the next page — so the
51
- * avatar "follows" the user across pages. Each page is a fresh connection (the backend can
52
- * resume the conversation by externalClientId). The intent is remembered in sessionStorage
53
- * and cleared when the user ends the session. Ignored when `persist` (the iframe variant)
54
- * is on. Default: `false`.
55
- */
56
- reconnectOnNavigation?: boolean;
57
- /** When `persist` is on, the URL to load in the iframe. Default: the current page (`location.href`). */
58
- siteUrl?: string;
59
- /** Element to build the launcher/iframe into. Default: `document.body`. */
60
- mount?: HTMLElement;
61
- /** Avatar config forwarded to `init()`. `mountContainer`/`layout` are managed by the shell. */
62
- avatar?: Partial<NapsterCompanionApiConfig>;
63
- /** The iframe `allow` attribute (Permissions Policy for the site). Default: `"autoplay; clipboard-write"`. */
64
- iframeAllow?: string;
65
- /** Launcher button label. Default: `"Talk to an agent"`. */
66
- launcherLabel?: string;
67
- /** Start a session immediately instead of showing a launcher. Default: `false`. */
68
- autoConnect?: boolean;
69
- /**
70
- * While a session is active, mirror the iframe's path into the address bar (so inner
71
- * pages stay shareable/bookmarkable) and follow parent back/forward into the iframe.
72
- * Same-origin only. Default: `true`.
73
- */
74
- syncHistory?: boolean;
75
- /** Log shell lifecycle to the console. Default: `false`. */
76
- debug?: boolean;
77
- }
78
- export interface ShellController {
79
- /** `true` if this call built the launcher (top-level); `false` if it no-oped (framed content). */
80
- isShell: boolean;
81
- /** The live avatar instance once connected, else `null`. */
82
- getInstance(): NapsterCompanionApiInstance | null;
83
- /** The site iframe while a session is active, else `null`. */
84
- getIframe(): HTMLIFrameElement | null;
85
- /** Start a session: connect the avatar (and, with `persist`, wrap the current page in the iframe). */
86
- connect(): Promise<void>;
87
- /** End the session (and, if wrapped, unwrap back to the plain site). */
88
- disconnect(): void;
89
- /** Remove the launcher and any active session/iframe. */
90
- destroy(): void;
91
- }
92
- type InitFn = (token: string, config?: Partial<NapsterCompanionApiConfig>) => Promise<NapsterCompanionApiInstance>;
93
- /**
94
- * Build the lazy persistent-agent shell. Returns a {@link ShellController}.
95
- *
96
- * A no-op (returns a controller with `isShell: false`) when run inside the shell
97
- * iframe (framed content), so the same snippet is safe to include on every page.
98
- *
99
- * `init` is the SDK's `init` method, wired by the SDK.
100
- */
101
- export declare function mountShell(init: InitFn, options: ShellOptions): ShellController;
102
- export {};