@gcm-cz/dmon-switcher 0.1.0 → 1.0.3

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/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # @gcm-cz/dmon-switcher
2
+
3
+ Headless JavaScript library for integrating the DMon user switcher into a relying party (RP).
4
+
5
+ Lets your application discover which users the browser has already authenticated with DMon and
6
+ switch between them — without leaving your page, without re-implementing the picker UI, and
7
+ without touching DMon's cookies directly.
8
+
9
+ Internally the library embeds a hidden `<iframe>` that loads a DMon-hosted bridge page. The
10
+ bridge runs in DMon's first-party context, reads the `dmon_auth` session cookie, and exposes a
11
+ `postMessage` RPC interface. Your application calls `listSessions()`, renders its own picker,
12
+ and calls `switchTo()`. **The library renders no UI of its own.**
13
+
14
+ Zero runtime dependencies. Ships as ESM + CJS with full TypeScript declarations.
15
+
16
+ ---
17
+
18
+ ## Prerequisites
19
+
20
+ - A DMon instance with the embed bridge feature enabled.
21
+ - A registered DMon client with **`embed_origins`** configured to include your application's
22
+ origin (e.g. `https://app.example.com`). Without this, the bridge iframe will be blocked by
23
+ CSP and the library will not function. See the
24
+ [RP Integration Guide](../../docs/integrations/RP_USER_SWITCHER.md#4-client-registration)
25
+ for the admin UI walk-through.
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ```sh
32
+ npm install @gcm-cz/dmon-switcher
33
+ # or
34
+ pnpm add @gcm-cz/dmon-switcher
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Quickstart
40
+
41
+ ```typescript
42
+ import { DmonSwitcher } from "@gcm-cz/dmon-switcher";
43
+ import type { Session } from "@gcm-cz/dmon-switcher";
44
+
45
+ // Instantiate once at component mount. A hidden iframe is created immediately.
46
+ const switcher = new DmonSwitcher({
47
+ dmonOrigin: "https://accounts.example.com",
48
+ realmSlug: "default",
49
+ clientId: "my-rp",
50
+ mode: "emit", // "emit" (default) or "redirect" — controls switchTo() behaviour
51
+ });
52
+
53
+ // Register a switch handler before awaiting ready().
54
+ switcher.on("switch", ({ sub }) => {
55
+ if (sub === "__new__") {
56
+ // Force interactive login to add a new session.
57
+ window.location.href = buildAuthorizeUrl({ prompt: "login" });
58
+ return;
59
+ }
60
+ // Attempt silent re-auth; fall back to interactive on login_required.
61
+ performSilentAuth({ loginHint: sub, prompt: "none" })
62
+ .catch(() => { window.location.href = buildAuthorizeUrl({ loginHint: sub }); });
63
+ });
64
+
65
+ // Wait for the bridge to signal ready, then fetch the session list.
66
+ await switcher.ready();
67
+ const sessions: Session[] = await switcher.listSessions();
68
+ renderMyUserMenu(sessions);
69
+
70
+ // Switch to a user when selected in your menu.
71
+ await switcher.switchTo(selectedSub);
72
+
73
+ // Build the account-page URL for an anchor — the user decides how to open it.
74
+ const accountHref = switcher.accountPageUrl(currentUserSub);
75
+ // <a href={accountHref}>Account settings</a>
76
+
77
+ // Clean up when the component unmounts.
78
+ switcher.destroy();
79
+ ```
80
+
81
+ ### React example
82
+
83
+ ```tsx
84
+ import { useEffect, useRef, useState } from "react";
85
+ import { DmonSwitcher } from "@gcm-cz/dmon-switcher";
86
+ import type { Session } from "@gcm-cz/dmon-switcher";
87
+
88
+ function UserMenu({ currentSub }: { currentSub: string }) {
89
+ const switcherRef = useRef<DmonSwitcher | null>(null);
90
+ const [sessions, setSessions] = useState<Session[]>([]);
91
+
92
+ useEffect(() => {
93
+ const switcher = new DmonSwitcher({
94
+ dmonOrigin: "https://accounts.example.com",
95
+ realmSlug: "default",
96
+ clientId: "my-rp",
97
+ });
98
+ switcherRef.current = switcher;
99
+
100
+ switcher.on("switch", ({ sub }) => {
101
+ // your re-auth logic
102
+ });
103
+
104
+ switcher.ready()
105
+ .then(() => switcher.listSessions())
106
+ .then(setSessions);
107
+
108
+ return () => switcher.destroy();
109
+ }, []);
110
+
111
+ return (
112
+ <menu>
113
+ {sessions.map(s => (
114
+ <li key={s.sub} onClick={() => switcherRef.current?.switchTo(s.sub)}>
115
+ {s.name}
116
+ </li>
117
+ ))}
118
+ <li>
119
+ <a href={switcherRef.current?.accountPageUrl(currentSub)}>
120
+ Account settings
121
+ </a>
122
+ </li>
123
+ </menu>
124
+ );
125
+ }
126
+ ```
127
+
128
+ ---
129
+
130
+ ## API Reference
131
+
132
+ ### `Session`
133
+
134
+ ```typescript
135
+ interface Session {
136
+ sub: string; // Stable user identifier (OIDC "sub" claim)
137
+ name: string; // Display name
138
+ given_name?: string;
139
+ family_name?: string;
140
+ picture?: string; // Profile picture URL
141
+ }
142
+ ```
143
+
144
+ `listSessions()` returns only sessions the realm's `can_authorize` policy permits. Users
145
+ authenticated in DMon but blocked by policy are silently omitted.
146
+
147
+ ### `DmonSwitcherOptions`
148
+
149
+ ```typescript
150
+ interface DmonSwitcherOptions {
151
+ dmonOrigin: string; // Base URL of your DMon instance
152
+ realmSlug: string; // Realm slug the RP is registered in
153
+ clientId: string; // OAuth 2.0 client_id of the RP
154
+ mode?: "emit" | "redirect"; // default "emit"
155
+ debug?: boolean; // Log internal steps via console.debug; default false
156
+ }
157
+ ```
158
+
159
+ **`debug`** — when `true`, every internal step is logged via `console.debug` under the
160
+ `[DmonSwitcher]` prefix: construction, ready handshake, RPC send/receive, dropped messages,
161
+ switch, account-page URL construction, and teardown. Off by default. Useful during integration
162
+ and troubleshooting; do not enable in production.
163
+
164
+ ### `DmonSwitcher`
165
+
166
+ #### `constructor(opts: DmonSwitcherOptions)`
167
+
168
+ Creates a hidden `<iframe>`, appends it to `document.body`, and starts the bridge handshake.
169
+ Does not throw — errors are deferred and surfaced via `ready()` never resolving.
170
+
171
+ #### `ready(): Promise<void>`
172
+
173
+ Resolves when the bridge iframe sends its `ready` message. Also resolves immediately if
174
+ construction failed (e.g. DOM unavailable) or after `destroy()` is called — in both cases the
175
+ instance is inoperative and subsequent calls no-op.
176
+
177
+ `listSessions()` and `switchTo()` in `"redirect"` mode wait for `ready()` internally before
178
+ sending an RPC, so you do not have to await `ready()` before calling them. `switchTo()` in
179
+ `"emit"` mode fires local listeners immediately without waiting for the bridge. Awaiting
180
+ `ready()` explicitly is recommended when you want timeout-based error detection:
181
+
182
+ ```typescript
183
+ const TIMEOUT = 5000;
184
+ await Promise.race([
185
+ switcher.ready(),
186
+ new Promise((_, reject) => setTimeout(() => reject(new Error("bridge timeout")), TIMEOUT)),
187
+ ]);
188
+ ```
189
+
190
+ #### `listSessions(): Promise<Session[]>`
191
+
192
+ Sends a `list_sessions` RPC to the bridge. Returns `[]` when there are no authenticated
193
+ sessions, when the cookie is absent, or when all sessions are policy-blocked.
194
+
195
+ #### `switchTo(sub: string, opts?: { returnTo?: string }): Promise<void>`
196
+
197
+ Initiates a user switch.
198
+
199
+ - **`"emit"` mode** — fires all `switch` listeners with `{ sub }` and resolves immediately.
200
+ The RP is responsible for triggering OIDC re-authentication.
201
+ - **`"redirect"` mode** — sends `redirect_authorize` to the bridge, which top-level-navigates
202
+ to the DMon `/authorize` endpoint with `login_hint=sub`, `prompt=none`, and
203
+ `redirect_uri=opts.returnTo ?? window.location.href`.
204
+
205
+ **`__new__` sentinel:** `switchTo("__new__")` uses `prompt=login` to force fresh credential
206
+ entry and append a new session to the cookie.
207
+
208
+ #### `accountPageUrl(sub: string): string`
209
+
210
+ Returns (does **not** open) the DMon account-page URL for `sub`:
211
+
212
+ ```
213
+ {dmonOrigin}/account?login_hint={encodeURIComponent(sub)}
214
+ ```
215
+
216
+ Pure and side-effect free — safe to call during render. Put the result in an anchor `href` so
217
+ the user controls how it opens. DMon will silently mount the account page if the cookie already
218
+ contains the session for `sub`; otherwise it falls back to the standard login flow.
219
+
220
+ #### `destroy(): void`
221
+
222
+ Removes the iframe, deregisters `window` message listeners, rejects all pending RPC promises
223
+ with `AbortError`, and resolves any pending `ready()` waiters so they do not hang. Idempotent —
224
+ safe to call multiple times, subsequent calls are no-ops. Call at component unmount.
225
+
226
+ #### `on(event: "switch", handler: (payload: { sub: string }) => void): void`
227
+
228
+ Registers a handler fired by `switchTo()` in `"emit"` mode.
229
+
230
+ #### `on(event: "ready", handler: () => void): void`
231
+
232
+ Registers a handler called when the bridge sends its `ready` message.
233
+
234
+ ---
235
+
236
+ ## `switchTo` modes
237
+
238
+ | Mode | What `switchTo()` does | Who triggers re-auth |
239
+ |---|---|---|
240
+ | `"emit"` (default) | Fires the `switch` event | Your code |
241
+ | `"redirect"` | Bridge navigates the page to DMon `/authorize` | DMon → OIDC code callback |
242
+
243
+ `"emit"` is recommended for SPAs — it keeps your application in control of the authorization
244
+ flow and lets you discard tokens before starting the new grant.
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function h(s){if(typeof s!="object"||s===null)return!1;const i=s;return!(i.dmon_bridge!==!0||typeof i.type!="string")}function d(s){return typeof s.request_id=="string"}class l{constructor(i){this.iframe=null,this.nextRequestId=1,this.destroyed=!1,this.failed=!1,this.pending=new Map,this.switchListeners=[],this.readyListeners=[],this.handleMessage=e=>{if(this.iframe==null||!h(e.data))return;if(e.source!==this.iframe.contentWindow){this.log("message dropped: wrong source",{eventOrigin:e.origin,expectedOrigin:this.origin,hasSource:e.source!=null,sourceIsExpectedIframe:!1});return}if(e.origin!==this.origin){this.log("message dropped: wrong origin",{eventOrigin:e.origin,expectedOrigin:this.origin});return}const t=e.data;if(t.type==="ready"){this.log("ready received"),this.resolveReady();for(const o of this.readyListeners)try{o()}catch(n){this.logError("ready handler threw",n)}return}if(!d(t)){this.log("message dropped: no request_id");return}const r=this.pending.get(t.request_id);r!==void 0?(this.log("rpc reply",{request_id:t.request_id,type:t.type}),this.pending.delete(t.request_id),r.resolve(t)):this.log("rpc reply for unknown request_id — ignored",{request_id:t.request_id})},this.origin=i.dmonOrigin,this.mode=i.mode??"emit",this.debug=i.debug??!1,this.readyPromise=new Promise(e=>{this.resolveReady=e});try{const e=`${i.dmonOrigin}/api/v1/authorize/${i.realmSlug}/embed/bridge?client_id=${encodeURIComponent(i.clientId)}`,t=document.createElement("iframe");t.setAttribute("aria-hidden","true"),t.setAttribute("sandbox","allow-scripts allow-same-origin"),t.style.display="none",t.src=e,document.body.appendChild(t),this.iframe=t,window.addEventListener("message",this.handleMessage),this.log("constructed",{origin:this.origin,mode:this.mode,src:e})}catch(e){this.failed=!0,this.resolveReady(),this.logError("construction failed — switcher disabled",e)}}ready(){return this.readyPromise}async listSessions(){if(this.failed||this.destroyed||this.iframe==null)return this.log("listSessions skipped — switcher unavailable"),[];try{const e=(await this.rpc({type:"list_sessions"})).sessions;return Array.isArray(e)?(this.log("listSessions resolved",{count:e.length}),e):(this.log("listSessions resolved with no sessions array"),[])}catch(i){return this.logError("listSessions failed — returning empty list",i),[]}}async switchTo(i,e){if(this.failed||this.destroyed||this.iframe==null){this.log("switchTo skipped — switcher unavailable",{sub:i});return}try{if(this.mode==="emit"){this.log("switchTo emit",{sub:i,listeners:this.switchListeners.length}),this.emitSwitch(i);return}const t=i==="__new__"?"login":"none",r=e?.returnTo??window.location.href;this.log("switchTo redirect",{sub:i,prompt:t,return_to:r}),await this.rpc({type:"redirect_authorize",login_hint:i,prompt:t,return_to:r})}catch(t){this.logError("switchTo failed",t)}}accountPageUrl(i){const e=`${this.origin}/account?login_hint=${encodeURIComponent(i)}`;return this.log("accountPageUrl",{sub:i,url:e}),e}destroy(){if(this.destroyed){this.log("destroy called again — no-op");return}this.destroyed=!0;try{window.removeEventListener("message",this.handleMessage);const i=new DOMException("DmonSwitcher destroyed","AbortError");this.log("destroy",{rejectingPending:this.pending.size}),this.pending.forEach(({reject:e})=>e(i)),this.pending.clear(),this.iframe?.remove(),this.resolveReady()}catch(i){this.logError("destroy encountered an error",i)}}on(i,e){i==="switch"?this.switchListeners.push(e):this.readyListeners.push(e)}log(i,e){this.debug&&(e===void 0?console.debug(`[DmonSwitcher] ${i}`):console.debug(`[DmonSwitcher] ${i}`,e))}logError(i,e){e===void 0?console.error(`[DmonSwitcher] ${i}`):console.error(`[DmonSwitcher] ${i}`,e)}emitSwitch(i){for(const e of this.switchListeners)try{e({sub:i})}catch(t){this.logError("switch handler threw",t)}}async rpc(i){if(this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");if(await this.readyPromise,this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");const e=String(this.nextRequestId++);this.log("rpc send",{request_id:e,...i});const t=this.iframe;return new Promise((r,o)=>{this.pending.set(e,{resolve:r,reject:o}),t.contentWindow?.postMessage({...i,request_id:e},this.origin)})}}exports.DmonSwitcher=l;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function d(s){if(typeof s!="object"||s===null)return!1;const r=s;return!(r.dmon_bridge!==!0||typeof r.type!="string")}function h(s){return typeof s.request_id=="string"}class a{constructor(r){this.iframe=null,this.nextRequestId=1,this.destroyed=!1,this.failed=!1,this.bridgeFailed=!1,this.readyReceived=!1,this.loadGraceTimer=null,this.pending=new Map,this.switchListeners=[],this.readyListeners=[],this.errorListeners=[],this.handleIframeLoad=()=>{this.log("iframe load event"),!(this.readyReceived||this.destroyed||this.failed||this.bridgeFailed)&&(this.loadGraceTimer=setTimeout(()=>{this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)")},this.postLoadGraceMs))},this.handleIframeError=()=>{this.log("iframe error event"),this.rejectBridge("DmonSwitcher: bridge iframe failed to load (network error)")},this.handleMessage=e=>{if(this.iframe==null||!d(e.data))return;if(e.source!==this.iframe.contentWindow){this.log("message dropped: wrong source",{eventOrigin:e.origin,expectedOrigin:this.origin,hasSource:e.source!=null,sourceIsExpectedIframe:!1});return}if(e.origin!==this.origin){this.log("message dropped: wrong origin",{eventOrigin:e.origin,expectedOrigin:this.origin});return}const i=e.data;if(i.type==="ready"){this.log("ready received"),this.readyReceived=!0,this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),this.resolveReady();for(const o of this.readyListeners)try{o()}catch(n){this.logError("ready handler threw",n)}return}if(!h(i)){this.log("message dropped: no request_id");return}const t=this.pending.get(i.request_id);t!==void 0?(this.log("rpc reply",{request_id:i.request_id,type:i.type}),this.pending.delete(i.request_id),t.resolve(i)):this.log("rpc reply for unknown request_id — ignored",{request_id:i.request_id})},this.origin=r.dmonOrigin,this.mode=r.mode??"emit",this.debug=r.debug??!1,this.postLoadGraceMs=r.postLoadGraceMs??200,this.readyPromise=new Promise((e,i)=>{this.resolveReady=e,this.rejectReady=i});try{const e=`${r.dmonOrigin}/api/v1/authorize/${r.realmSlug}/embed/bridge?client_id=${encodeURIComponent(r.clientId)}`,i=document.createElement("iframe");i.setAttribute("aria-hidden","true"),i.setAttribute("sandbox","allow-scripts allow-same-origin"),i.style.display="none",i.src=e,document.body.appendChild(i),i.onload=this.handleIframeLoad,i.onerror=this.handleIframeError,this.iframe=i,window.addEventListener("message",this.handleMessage),this.log("constructed",{origin:this.origin,mode:this.mode,src:e})}catch(e){this.failed=!0,this.resolveReady(),this.logError("construction failed — switcher disabled",e)}}ready(){return this.readyPromise}async listSessions(){if(this.failed||this.destroyed||this.iframe==null)return this.log("listSessions skipped — switcher unavailable"),[];try{const e=(await this.rpc({type:"list_sessions"})).sessions;return Array.isArray(e)?(this.log("listSessions resolved",{count:e.length}),e):(this.log("listSessions resolved with no sessions array"),[])}catch{return[]}}async switchTo(r,e){if(this.failed||this.destroyed||this.iframe==null){this.log("switchTo skipped — switcher unavailable",{sub:r});return}try{if(this.mode==="emit"){this.log("switchTo emit",{sub:r,listeners:this.switchListeners.length}),this.emitSwitch(r);return}const i=r==="__new__"?"login":"none",t=e?.returnTo??window.location.href;this.log("switchTo redirect",{sub:r,prompt:i,return_to:t}),await this.rpc({type:"redirect_authorize",login_hint:r,prompt:i,return_to:t})}catch(i){this.logError("switchTo failed",i)}}accountPageUrl(r){const e=`${this.origin}/account?login_hint=${encodeURIComponent(r)}`;return this.log("accountPageUrl",{sub:r,url:e}),e}destroy(){if(this.destroyed){this.log("destroy called again — no-op");return}this.destroyed=!0;try{this.loadGraceTimer!=null&&(clearTimeout(this.loadGraceTimer),this.loadGraceTimer=null),window.removeEventListener("message",this.handleMessage);const r=new DOMException("DmonSwitcher destroyed","AbortError");this.log("destroy",{rejectingPending:this.pending.size}),this.pending.forEach(({reject:e})=>e(r)),this.pending.clear(),this.iframe?.remove(),this.resolveReady()}catch(r){this.logError("destroy encountered an error",r)}}on(r,e){r==="switch"?this.switchListeners.push(e):r==="ready"?this.readyListeners.push(e):this.errorListeners.push(e)}rejectBridge(r){if(this.bridgeFailed||this.destroyed||this.failed)return;this.bridgeFailed=!0;const e=new Error(r);this.log(`bridge failure: ${r}`),this.rejectReady(e),this.pending.forEach(({reject:i})=>{i(e)}),this.pending.clear();for(const i of this.errorListeners)try{i(e)}catch(t){this.logError("error handler threw",t)}}log(r,e){this.debug&&(e===void 0?console.debug(`[DmonSwitcher] ${r}`):console.debug(`[DmonSwitcher] ${r}`,e))}logError(r,e){e===void 0?console.error(`[DmonSwitcher] ${r}`):console.error(`[DmonSwitcher] ${r}`,e)}emitSwitch(r){for(const e of this.switchListeners)try{e({sub:r})}catch(i){this.logError("switch handler threw",i)}}async rpc(r){if(this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");if(await this.readyPromise,this.destroyed||this.iframe==null)throw new DOMException("DmonSwitcher destroyed","AbortError");const e=String(this.nextRequestId++);this.log("rpc send",{request_id:e,...r});const i=this.iframe;return new Promise((t,o)=>{this.pending.set(e,{resolve:t,reject:o}),i.contentWindow?.postMessage({...r,request_id:e},this.origin)})}}exports.DmonSwitcher=a;
package/dist/index.d.ts CHANGED
@@ -17,11 +17,20 @@ export interface DmonSwitcherOptions {
17
17
  * Off by default.
18
18
  */
19
19
  debug?: boolean;
20
+ /**
21
+ * Grace period (ms) to wait for the bridge "ready" message after the iframe `load`
22
+ * event fires. The iframe `load` event fires even when CSP `frame-ancestors` blocks
23
+ * rendering — the bridge JS never runs, so no handshake arrives. After this window
24
+ * expires without a "ready" the promise is rejected. Defaults to 200 ms.
25
+ * Set to 0 to reject synchronously on load (only safe in tests).
26
+ */
27
+ postLoadGraceMs?: number;
20
28
  }
21
29
  type SwitchHandler = (payload: {
22
30
  sub: string;
23
31
  }) => void;
24
32
  type ReadyHandler = () => void;
33
+ type ErrorHandler = (err: Error) => void;
25
34
  export declare class DmonSwitcher {
26
35
  private readonly origin;
27
36
  private readonly mode;
@@ -29,12 +38,18 @@ export declare class DmonSwitcher {
29
38
  private readonly iframe;
30
39
  private readonly readyPromise;
31
40
  private resolveReady;
41
+ private rejectReady;
32
42
  private nextRequestId;
33
43
  private destroyed;
34
44
  private failed;
45
+ private bridgeFailed;
46
+ private readyReceived;
47
+ private loadGraceTimer;
48
+ private readonly postLoadGraceMs;
35
49
  private readonly pending;
36
50
  private readonly switchListeners;
37
51
  private readonly readyListeners;
52
+ private readonly errorListeners;
38
53
  constructor(opts: DmonSwitcherOptions);
39
54
  ready(): Promise<void>;
40
55
  listSessions(): Promise<Session[]>;
@@ -53,6 +68,10 @@ export declare class DmonSwitcher {
53
68
  destroy(): void;
54
69
  on(event: "switch", handler: SwitchHandler): void;
55
70
  on(event: "ready", handler: ReadyHandler): void;
71
+ on(event: "error", handler: ErrorHandler): void;
72
+ private rejectBridge;
73
+ private readonly handleIframeLoad;
74
+ private readonly handleIframeError;
56
75
  private log;
57
76
  private logError;
58
77
  private emitSwitch;
package/dist/index.js CHANGED
@@ -1,15 +1,21 @@
1
- function h(s) {
1
+ function d(s) {
2
2
  if (typeof s != "object" || s === null) return !1;
3
- const i = s;
4
- return !(i.dmon_bridge !== !0 || typeof i.type != "string");
3
+ const r = s;
4
+ return !(r.dmon_bridge !== !0 || typeof r.type != "string");
5
5
  }
6
- function d(s) {
6
+ function h(s) {
7
7
  return typeof s.request_id == "string";
8
8
  }
9
- class l {
10
- constructor(i) {
11
- this.iframe = null, this.nextRequestId = 1, this.destroyed = !1, this.failed = !1, this.pending = /* @__PURE__ */ new Map(), this.switchListeners = [], this.readyListeners = [], this.handleMessage = (e) => {
12
- if (this.iframe == null || !h(e.data)) return;
9
+ class a {
10
+ constructor(r) {
11
+ this.iframe = null, this.nextRequestId = 1, this.destroyed = !1, this.failed = !1, this.bridgeFailed = !1, this.readyReceived = !1, this.loadGraceTimer = null, this.pending = /* @__PURE__ */ new Map(), this.switchListeners = [], this.readyListeners = [], this.errorListeners = [], this.handleIframeLoad = () => {
12
+ this.log("iframe load event"), !(this.readyReceived || this.destroyed || this.failed || this.bridgeFailed) && (this.loadGraceTimer = setTimeout(() => {
13
+ this.rejectBridge("DmonSwitcher: bridge iframe loaded but sent no ready message (CSP frame-ancestors block?)");
14
+ }, this.postLoadGraceMs));
15
+ }, this.handleIframeError = () => {
16
+ this.log("iframe error event"), this.rejectBridge("DmonSwitcher: bridge iframe failed to load (network error)");
17
+ }, this.handleMessage = (e) => {
18
+ if (this.iframe == null || !d(e.data)) return;
13
19
  if (e.source !== this.iframe.contentWindow) {
14
20
  this.log("message dropped: wrong source", {
15
21
  eventOrigin: e.origin,
@@ -23,9 +29,9 @@ class l {
23
29
  this.log("message dropped: wrong origin", { eventOrigin: e.origin, expectedOrigin: this.origin });
24
30
  return;
25
31
  }
26
- const t = e.data;
27
- if (t.type === "ready") {
28
- this.log("ready received"), this.resolveReady();
32
+ const i = e.data;
33
+ if (i.type === "ready") {
34
+ this.log("ready received"), this.readyReceived = !0, this.loadGraceTimer != null && (clearTimeout(this.loadGraceTimer), this.loadGraceTimer = null), this.resolveReady();
29
35
  for (const o of this.readyListeners)
30
36
  try {
31
37
  o();
@@ -34,18 +40,18 @@ class l {
34
40
  }
35
41
  return;
36
42
  }
37
- if (!d(t)) {
43
+ if (!h(i)) {
38
44
  this.log("message dropped: no request_id");
39
45
  return;
40
46
  }
41
- const r = this.pending.get(t.request_id);
42
- r !== void 0 ? (this.log("rpc reply", { request_id: t.request_id, type: t.type }), this.pending.delete(t.request_id), r.resolve(t)) : this.log("rpc reply for unknown request_id — ignored", { request_id: t.request_id });
43
- }, this.origin = i.dmonOrigin, this.mode = i.mode ?? "emit", this.debug = i.debug ?? !1, this.readyPromise = new Promise((e) => {
44
- this.resolveReady = e;
47
+ const t = this.pending.get(i.request_id);
48
+ t !== void 0 ? (this.log("rpc reply", { request_id: i.request_id, type: i.type }), this.pending.delete(i.request_id), t.resolve(i)) : this.log("rpc reply for unknown request_id — ignored", { request_id: i.request_id });
49
+ }, this.origin = r.dmonOrigin, this.mode = r.mode ?? "emit", this.debug = r.debug ?? !1, this.postLoadGraceMs = r.postLoadGraceMs ?? 200, this.readyPromise = new Promise((e, i) => {
50
+ this.resolveReady = e, this.rejectReady = i;
45
51
  });
46
52
  try {
47
- const e = `${i.dmonOrigin}/api/v1/authorize/${i.realmSlug}/embed/bridge?client_id=${encodeURIComponent(i.clientId)}`, t = document.createElement("iframe");
48
- t.setAttribute("aria-hidden", "true"), t.setAttribute("sandbox", "allow-scripts allow-same-origin"), t.style.display = "none", t.src = e, document.body.appendChild(t), this.iframe = t, window.addEventListener("message", this.handleMessage), this.log("constructed", { origin: this.origin, mode: this.mode, src: e });
53
+ const e = `${r.dmonOrigin}/api/v1/authorize/${r.realmSlug}/embed/bridge?client_id=${encodeURIComponent(r.clientId)}`, i = document.createElement("iframe");
54
+ i.setAttribute("aria-hidden", "true"), i.setAttribute("sandbox", "allow-scripts allow-same-origin"), i.style.display = "none", i.src = e, document.body.appendChild(i), i.onload = this.handleIframeLoad, i.onerror = this.handleIframeError, this.iframe = i, window.addEventListener("message", this.handleMessage), this.log("constructed", { origin: this.origin, mode: this.mode, src: e });
49
55
  } catch (e) {
50
56
  this.failed = !0, this.resolveReady(), this.logError("construction failed — switcher disabled", e);
51
57
  }
@@ -59,29 +65,29 @@ class l {
59
65
  try {
60
66
  const e = (await this.rpc({ type: "list_sessions" })).sessions;
61
67
  return Array.isArray(e) ? (this.log("listSessions resolved", { count: e.length }), e) : (this.log("listSessions resolved with no sessions array"), []);
62
- } catch (i) {
63
- return this.logError("listSessions failed — returning empty list", i), [];
68
+ } catch {
69
+ return [];
64
70
  }
65
71
  }
66
- async switchTo(i, e) {
72
+ async switchTo(r, e) {
67
73
  if (this.failed || this.destroyed || this.iframe == null) {
68
- this.log("switchTo skipped — switcher unavailable", { sub: i });
74
+ this.log("switchTo skipped — switcher unavailable", { sub: r });
69
75
  return;
70
76
  }
71
77
  try {
72
78
  if (this.mode === "emit") {
73
- this.log("switchTo emit", { sub: i, listeners: this.switchListeners.length }), this.emitSwitch(i);
79
+ this.log("switchTo emit", { sub: r, listeners: this.switchListeners.length }), this.emitSwitch(r);
74
80
  return;
75
81
  }
76
- const t = i === "__new__" ? "login" : "none", r = e?.returnTo ?? window.location.href;
77
- this.log("switchTo redirect", { sub: i, prompt: t, return_to: r }), await this.rpc({
82
+ const i = r === "__new__" ? "login" : "none", t = e?.returnTo ?? window.location.href;
83
+ this.log("switchTo redirect", { sub: r, prompt: i, return_to: t }), await this.rpc({
78
84
  type: "redirect_authorize",
79
- login_hint: i,
80
- prompt: t,
81
- return_to: r
85
+ login_hint: r,
86
+ prompt: i,
87
+ return_to: t
82
88
  });
83
- } catch (t) {
84
- this.logError("switchTo failed", t);
89
+ } catch (i) {
90
+ this.logError("switchTo failed", i);
85
91
  }
86
92
  }
87
93
  /**
@@ -92,9 +98,9 @@ class l {
92
98
  * so the user can choose how to open it (left click = same tab, middle/ctrl click =
93
99
  * new tab). Pure and side-effect free; safe to call at any time.
94
100
  */
95
- accountPageUrl(i) {
96
- const e = `${this.origin}/account?login_hint=${encodeURIComponent(i)}`;
97
- return this.log("accountPageUrl", { sub: i, url: e }), e;
101
+ accountPageUrl(r) {
102
+ const e = `${this.origin}/account?login_hint=${encodeURIComponent(r)}`;
103
+ return this.log("accountPageUrl", { sub: r, url: e }), e;
98
104
  }
99
105
  destroy() {
100
106
  if (this.destroyed) {
@@ -103,47 +109,61 @@ class l {
103
109
  }
104
110
  this.destroyed = !0;
105
111
  try {
106
- window.removeEventListener("message", this.handleMessage);
107
- const i = new DOMException("DmonSwitcher destroyed", "AbortError");
108
- this.log("destroy", { rejectingPending: this.pending.size }), this.pending.forEach(({ reject: e }) => e(i)), this.pending.clear(), this.iframe?.remove(), this.resolveReady();
109
- } catch (i) {
110
- this.logError("destroy encountered an error", i);
112
+ this.loadGraceTimer != null && (clearTimeout(this.loadGraceTimer), this.loadGraceTimer = null), window.removeEventListener("message", this.handleMessage);
113
+ const r = new DOMException("DmonSwitcher destroyed", "AbortError");
114
+ this.log("destroy", { rejectingPending: this.pending.size }), this.pending.forEach(({ reject: e }) => e(r)), this.pending.clear(), this.iframe?.remove(), this.resolveReady();
115
+ } catch (r) {
116
+ this.logError("destroy encountered an error", r);
111
117
  }
112
118
  }
113
- on(i, e) {
114
- i === "switch" ? this.switchListeners.push(e) : this.readyListeners.push(e);
119
+ on(r, e) {
120
+ r === "switch" ? this.switchListeners.push(e) : r === "ready" ? this.readyListeners.push(e) : this.errorListeners.push(e);
121
+ }
122
+ rejectBridge(r) {
123
+ if (this.bridgeFailed || this.destroyed || this.failed) return;
124
+ this.bridgeFailed = !0;
125
+ const e = new Error(r);
126
+ this.log(`bridge failure: ${r}`), this.rejectReady(e), this.pending.forEach(({ reject: i }) => {
127
+ i(e);
128
+ }), this.pending.clear();
129
+ for (const i of this.errorListeners)
130
+ try {
131
+ i(e);
132
+ } catch (t) {
133
+ this.logError("error handler threw", t);
134
+ }
115
135
  }
116
- log(i, e) {
117
- this.debug && (e === void 0 ? console.debug(`[DmonSwitcher] ${i}`) : console.debug(`[DmonSwitcher] ${i}`, e));
136
+ log(r, e) {
137
+ this.debug && (e === void 0 ? console.debug(`[DmonSwitcher] ${r}`) : console.debug(`[DmonSwitcher] ${r}`, e));
118
138
  }
119
139
  // Errors are always surfaced (not gated by `debug`) but never thrown — the library
120
140
  // must not crash the consuming application.
121
- logError(i, e) {
122
- e === void 0 ? console.error(`[DmonSwitcher] ${i}`) : console.error(`[DmonSwitcher] ${i}`, e);
141
+ logError(r, e) {
142
+ e === void 0 ? console.error(`[DmonSwitcher] ${r}`) : console.error(`[DmonSwitcher] ${r}`, e);
123
143
  }
124
144
  // Invoke each consumer "switch" handler in isolation: a throwing handler is logged and
125
145
  // skipped, never allowed to break the loop or propagate out of the library.
126
- emitSwitch(i) {
146
+ emitSwitch(r) {
127
147
  for (const e of this.switchListeners)
128
148
  try {
129
- e({ sub: i });
130
- } catch (t) {
131
- this.logError("switch handler threw", t);
149
+ e({ sub: r });
150
+ } catch (i) {
151
+ this.logError("switch handler threw", i);
132
152
  }
133
153
  }
134
- async rpc(i) {
154
+ async rpc(r) {
135
155
  if (this.destroyed || this.iframe == null)
136
156
  throw new DOMException("DmonSwitcher destroyed", "AbortError");
137
157
  if (await this.readyPromise, this.destroyed || this.iframe == null)
138
158
  throw new DOMException("DmonSwitcher destroyed", "AbortError");
139
159
  const e = String(this.nextRequestId++);
140
- this.log("rpc send", { request_id: e, ...i });
141
- const t = this.iframe;
142
- return new Promise((r, o) => {
143
- this.pending.set(e, { resolve: r, reject: o }), t.contentWindow?.postMessage({ ...i, request_id: e }, this.origin);
160
+ this.log("rpc send", { request_id: e, ...r });
161
+ const i = this.iframe;
162
+ return new Promise((t, o) => {
163
+ this.pending.set(e, { resolve: t, reject: o }), i.contentWindow?.postMessage({ ...r, request_id: e }, this.origin);
144
164
  });
145
165
  }
146
166
  }
147
167
  export {
148
- l as DmonSwitcher
168
+ a as DmonSwitcher
149
169
  };
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "@gcm-cz/dmon-switcher",
3
- "version": "0.1.0",
3
+ "version": "1.0.3",
4
+ "repository": "gitlab:gcm-cz/dmon",
5
+ "license": "MIT",
4
6
  "type": "module",
5
7
  "main": "./dist/index.cjs",
6
8
  "module": "./dist/index.js",
@@ -12,7 +14,10 @@
12
14
  "require": "./dist/index.cjs"
13
15
  }
14
16
  },
15
- "files": ["dist"],
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
16
21
  "scripts": {
17
22
  "build": "vite build && tsc --emitDeclarationOnly",
18
23
  "prepare": "vite build && tsc --emitDeclarationOnly",