@touchcastllc/napster-companion-api-dev 1.0.0-alpha.82 → 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.
@@ -340,12 +375,33 @@ export interface NapsterCompanionApiConfig {
340
375
  /** @internal */
341
376
  iceServers?: RTCIceServer[];
342
377
  /**
343
- * Read WebMCP tools from a same-origin child iframe's `document.modelContext` instead of
344
- * this window's. Set by the persistent-agent shell (`mountShell` with `persist`) so the
345
- * avatar in the top-level document operates the SITE running inside the wrapped iframe.
346
- * Leave unset for a normal same-page integration.
378
+ * `targetOrigin` used when the SDK relays data-channel messages to
379
+ * `window.parent` (the `"avatar-data-channel-message"` event).
380
+ *
381
+ * Defaults to the current origin, so private session data (chat transcripts,
382
+ * avatar state) is only ever delivered to a same-origin parent. When the SDK
383
+ * is embedded cross-origin, set this to the trusted embedder's origin (e.g.
384
+ * `"https://app.example.com"`). Setting it to `"*"` broadcasts to any parent
385
+ * origin and is insecure — use only if you understand the exposure.
347
386
  */
348
- edgeMcpIframe?: HTMLIFrameElement;
387
+ parentOrigin?: string;
388
+ /**
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.
398
+ */
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
+ };
349
405
  /** Analytics configuration controls analytics delivery and tracking. */
350
406
  analytics?: AnalyticsConfig;
351
407
  /** Lifecycle callbacks. All are optional. */
@@ -383,14 +439,6 @@ export interface NapsterCompanionApiInstance {
383
439
  avatarIsVisible: () => boolean;
384
440
  /** Tear down the SDK, remove DOM nodes and clean up resources. */
385
441
  destroy: () => void;
386
- /**
387
- * Re-target the Edge MCP bridge at runtime: read the site's WebMCP tools from the given
388
- * same-origin iframe's `document.modelContext`, or pass `null` to read them from this
389
- * window's document. Used by the persistent-agent shell's frame-on-navigate mode, where
390
- * the site is only wrapped in an iframe on the first navigation. No-op if the bridge is
391
- * inactive.
392
- */
393
- setEdgeMcpIframe: (iframe: HTMLIFrameElement | null) => void;
394
442
  /**
395
443
  * The current session identifier. Available after a connection is established
396
444
  * and reset to `undefined` once the connection is closed.
@@ -476,11 +524,12 @@ export interface NapsterCompanionApiSDK {
476
524
  */
477
525
  init(token: string, config?: Partial<NapsterCompanionApiConfig>): Promise<NapsterCompanionApiInstance>;
478
526
  /**
479
- * Mount a persistent-agent shell for multi-page apps: renders the site in a
480
- * same-origin iframe with the avatar in the top-level shell, so the agent
481
- * 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.
482
531
  */
483
- mountShell(options: import("../shell/mount-shell").ShellOptions): import("../shell/mount-shell").ShellController;
532
+ initWithButton(getToken: () => Promise<string>, config?: Partial<NapsterCompanionApiConfig>): import("../button").NapsterCompanionApiController;
484
533
  /** The SDK version string (useful for diagnostics). */
485
534
  version: string;
486
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.82",
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 {};