@captello/ulc-webview-sdk 1.2.0 → 1.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e7ba33: The mobile-app channel now has both sides, under names that say who hosts whom.
8
+
9
+ The SDK covers two different embedding directions, and 1.2.0's `/app` entry blurred
10
+ them. They are now named explicitly:
11
+
12
+ | Who embeds whom | Client |
13
+ | --------------------------------------- | ------------------------------------------------------------------------------ |
14
+ | Your page embeds the capture webview | `CaptelloWebview` (unchanged) |
15
+ | The Captello mobile app embeds your app | `MobileHostClient` in your app · **new** `EmbeddedAppClient` in the mobile app |
16
+
17
+ - **`@captello/ulc-webview-sdk/mobile-host`** — `MobileHostClient` replaces 1.2.0's
18
+ `CaptelloAppHost`; `MobileHostRequestType` / `MobileHostResponseType` replace
19
+ `AppRequestType` / `AppResponseType`. Options are `mobileHostWindow` / `embeddedWindow`.
20
+ Behaviour is unchanged.
21
+ - **`@captello/ulc-webview-sdk/embedded-app`** — new. `EmbeddedAppClient(iframe)` is the
22
+ mobile app's side: typed `onRequest(type, listener)` / `onAnyRequest`, and
23
+ `sendAuthToken`, `sendScannerResult`, `sendScannerError`, `sendScannerClosed`, or
24
+ `send(response)` for the rest. Requests are accepted only from the bound iframe and,
25
+ once known, its origin; the target origin is read from `iframe.src` unless given, and
26
+ the client refuses to send with neither rather than fall back to `"*"`.
27
+ - **`@captello/ulc-webview-sdk/app`** (1.2.0) is removed. It shipped hours earlier with no
28
+ consumer, so the rename is not carried as aliases.
29
+
3
30
  ## 1.2.0
4
31
 
5
32
  ### Minor Changes
package/README.md CHANGED
@@ -18,9 +18,13 @@ It provides:
18
18
  utilities like `submitForm(iframe)` and `waitForFormLoad(iframe)`.
19
19
  6. **React adapter** (`@captello/ulc-webview-sdk/react`) — a `useCaptelloWebview` hook that
20
20
  manages the client lifecycle and returns an iframe ref plus typed senders.
21
- 7. **App-host client** (`@captello/ulc-webview-sdk/app`) for pages that run _inside_ the
22
- Captello mobile app: `CaptelloAppHost` talks to the app for an auth token, the badge
23
- scanner, and navigation.
21
+ 7. **The mobile-app channel** — the SDK's second, separate protocol, for when the
22
+ **Captello mobile app** is the host and embeds an application:
23
+ - `@captello/ulc-webview-sdk/mobile-host` — `MobileHostClient`, used by the _embedded
24
+ application_ (the meeting platform, Connexions) to reach the mobile app for an auth
25
+ token, the badge scanner, and navigation.
26
+ - `@captello/ulc-webview-sdk/embedded-app` — `EmbeddedAppClient`, used by the _mobile
27
+ app_ to serve those requests through the iframe it hosts.
24
28
 
25
29
  The core package is framework-agnostic with no runtime dependencies; React is an
26
30
  optional peer dependency used only by the `/react` entry point.
@@ -647,23 +651,27 @@ internal listener before settling, including on timeout.
647
651
  > call `waitForFormLoad` (e.g. you attach late), create a long-lived `CaptelloWebview`
648
652
  > before the iframe navigates instead.
649
653
 
650
- ## Inside the Captello app `@captello/ulc-webview-sdk/app`
654
+ ## The mobile-app channel`/mobile-host` and `/embedded-app`
651
655
 
652
- The mobile app embeds web pages (the meeting platform, Connexions) and offers them native
653
- services over `postMessage`. `CaptelloAppHost` is the page-side client for that channel —
654
- the mirror image of `CaptelloWebview`: here the Captello **app** is the host and your page
655
- is the child.
656
+ The SDK covers two embedding directions, and they are different protocols. Keep them apart:
657
+
658
+ | Who embeds whom | Host | Embedded | Client to use | Wire |
659
+ | ------------------------------------------- | ------------------- | --------------------- | ------------------------------------------------------------------------- | ------------------------------- |
660
+ | **Your page embeds the capture webview** | your page | Captello capture form | `CaptelloWebview` (everything above this section) | JSON strings, `snake_case` |
661
+ | **The Captello mobile app embeds your app** | Captello mobile app | your application | `MobileHostClient` (in your app), `EmbeddedAppClient` (in the mobile app) | plain objects, `SCREAMING_CASE` |
662
+
663
+ ### In the embedded application — `MobileHostClient`
656
664
 
657
665
  ```ts
658
- import { CaptelloAppHost, ScannerError } from "@captello/ulc-webview-sdk/app";
666
+ import { MobileHostClient, ScannerError } from "@captello/ulc-webview-sdk/mobile-host";
659
667
 
660
- const app = new CaptelloAppHost(); // listens on window, posts to window.parent
668
+ const mobileHost = new MobileHostClient(); // listens on window, posts to window.parent
661
669
 
662
- const token = await app.requestAuthToken(); // exchange it for a session, then:
663
- app.notifyReady(); // the app hides its spinner
670
+ const token = await mobileHost.requestAuthToken(); // exchange it for a session, then:
671
+ mobileHost.notifyReady(); // the mobile app hides its spinner
664
672
 
665
673
  try {
666
- const people = await app.openScanner(); // resolves when the scanner closes
674
+ const people = await mobileHost.openScanner(); // resolves when the scanner closes
667
675
  const [first] = people; // undefined if the user cancelled
668
676
  if (first) fillForm(first.fields, first.badgeId);
669
677
  } catch (e) {
@@ -675,24 +683,46 @@ try {
675
683
  for a group scan. Each `ScannedPerson` is `{ badgeId, fields }`, where `fields` is a
676
684
  `PrefillInfoItem[]` keyed by `ll_field_unique_identifier`, so it can be fed straight into a
677
685
  capture form's `prefill({ info })`. A badge with no lookup data still arrives with its
678
- `badgeId` and empty `fields`.
686
+ `badgeId` and empty `fields`. `send(request)` posts any other `MobileHostRequest`;
687
+ `on(MobileHostResponseType.X, listener)` subscribes to any response. `targetOrigin`
688
+ defaults to `"*"` here: the mobile app's webview origin differs per platform and an
689
+ application only uses this channel when it is running inside the app.
690
+
691
+ ### In the mobile app — `EmbeddedAppClient`
692
+
693
+ ```ts
694
+ import { EmbeddedAppClient, MobileHostRequestType } from "@captello/ulc-webview-sdk/embedded-app";
695
+
696
+ const embedded = new EmbeddedAppClient(iframe); // target origin read from iframe.src
697
+
698
+ embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {
699
+ embedded.sendAuthToken(await mintMagicToken());
700
+ });
701
+ embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {
702
+ for (const person of await runScanner()) embedded.sendScannerResult(person);
703
+ embedded.sendScannerClosed();
704
+ });
705
+ embedded.onRequest(MobileHostRequestType.NavigateBack, () => modal.dismiss());
706
+
707
+ // when the iframe goes away:
708
+ embedded.destroy();
709
+ ```
679
710
 
680
- The wire format differs from the capture-webview channel: messages are **plain objects**
681
- (not JSON strings) with SCREAMING_CASE types. The client handles both directions; for
682
- anything without a dedicated method, `send(request)` posts any `AppRequest` and
683
- `on(AppResponseType.X, listener)` subscribes to any response.
711
+ Requests are only accepted from the bound iframe's window (`matchSource`) and, once a
712
+ target origin is known, from that origin. Responses can carry an auth token, so with no
713
+ `targetOrigin` option and no parsable `iframe.src` the client refuses to send rather than
714
+ fall back to `"*"`.
684
715
 
685
- | Page → app (`AppRequestType`) | Method | App → page (`AppResponseType`) |
686
- | ------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------- |
687
- | `APP_READY` | `notifyReady()` | — |
688
- | `ERROR` | `notifyError(message)` | — |
689
- | `NAVIGATE_BACK` | `navigateBack()` | — |
690
- | `REQUEST_AUTH_TOKEN` | `requestAuthToken()` | `AUTH_TOKEN { token }` |
691
- | `OPEN_ULC_FORM_SCANNER` | `openScanner()` | `ULC_FORM_SCANNER_RESULT { badgeId, result }` ×N, then `ULC_FORM_SCANNER_CLOSED` |
692
- | `OPEN_URL`, `COPY_TEXT`, `SHARE_URL`, `SAVE_VCARD`, `ADD_TO_WALLET`, `SYNC_APP` | `send({ type, ... })` | `COPIED_*`, `SAVE_VCARD_*`, `ADD_TO_WALLET_*` acks |
716
+ ### Wire reference
693
717
 
694
- `targetOrigin` defaults to `"*"` here: the app's webview origin differs per platform and a
695
- page only uses this channel when it is running inside the app.
718
+ | Embedded app → mobile app (`MobileHostRequestType`) | `MobileHostClient` | Mobile app embedded app (`MobileHostResponseType`) | `EmbeddedAppClient` |
719
+ | ------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
720
+ | `APP_READY` | `notifyReady()` | — | — |
721
+ | `ERROR { message }` | `notifyError(message)` | — | — |
722
+ | `NAVIGATE_BACK` | `navigateBack()` | — | — |
723
+ | `REQUEST_AUTH_TOKEN` | `requestAuthToken()` | `AUTH_TOKEN { token }` | `sendAuthToken(token)` |
724
+ | `OPEN_ULC_FORM_SCANNER` | `openScanner()` | `ULC_FORM_SCANNER_RESULT { badgeId, result }` ×N, then `ULC_FORM_SCANNER_CLOSED` | `sendScannerResult(person)`, `sendScannerError(msg)`, `sendScannerClosed()` |
725
+ | `OPEN_URL`, `COPY_TEXT`, `SHARE_URL`, `SAVE_VCARD`, `ADD_TO_WALLET`, `SYNC_APP` | `send({ type, ... })` | `COPIED_*`, `SAVE_VCARD_*`, `ADD_TO_WALLET_*` acks | `send({ type })` |
696
726
 
697
727
  ## Migrating an existing integration
698
728
 
@@ -894,8 +924,9 @@ Two layers, deliberately split:
894
924
  origin filtering, `event.source` identity, and a removed iframe's `closed` flag — jsdom
895
925
  reports `closed` as `undefined`, so `attach()`'s teardown is unprovable without a browser.
896
926
  The webview here is a stub, so this proves the client's behaviour, not that both sides
897
- agree. `e2e/app-host.spec.ts` does the same for `CaptelloAppHost`: a stub app on one
898
- origin embeds a page on the other and the two exchange plain-object messages.
927
+ agree. `e2e/mobile-app-channel.spec.ts` does the same for the mobile-app channel: a stub
928
+ mobile app on one origin (`EmbeddedAppClient`) embeds an application on the other
929
+ (`MobileHostClient`), so both shipped clients are proven against each other.
899
930
  - **`e2e/sdk-integration.spec.ts` in the app repo** (`pnpm exec playwright test e2e/sdk-integration.spec.ts`)
900
931
  — the SDK against the **real webview**, cross-origin. This is the layer that catches
901
932
  protocol drift between the two, because neither side is a stub: it waits for a real
@@ -0,0 +1,60 @@
1
+ // src/mobile-app-protocol.ts
2
+ var MobileHostRequestType = /* @__PURE__ */ ((MobileHostRequestType2) => {
3
+ MobileHostRequestType2["AppReady"] = "APP_READY";
4
+ MobileHostRequestType2["Error"] = "ERROR";
5
+ MobileHostRequestType2["NavigateBack"] = "NAVIGATE_BACK";
6
+ MobileHostRequestType2["RequestAuthToken"] = "REQUEST_AUTH_TOKEN";
7
+ MobileHostRequestType2["OpenScanner"] = "OPEN_ULC_FORM_SCANNER";
8
+ MobileHostRequestType2["OpenUrl"] = "OPEN_URL";
9
+ MobileHostRequestType2["CopyText"] = "COPY_TEXT";
10
+ MobileHostRequestType2["ShareUrl"] = "SHARE_URL";
11
+ MobileHostRequestType2["SaveVCard"] = "SAVE_VCARD";
12
+ MobileHostRequestType2["AddToWallet"] = "ADD_TO_WALLET";
13
+ MobileHostRequestType2["SyncApp"] = "SYNC_APP";
14
+ return MobileHostRequestType2;
15
+ })(MobileHostRequestType || {});
16
+ var MobileHostResponseType = /* @__PURE__ */ ((MobileHostResponseType2) => {
17
+ MobileHostResponseType2["AuthToken"] = "AUTH_TOKEN";
18
+ MobileHostResponseType2["ScannerResult"] = "ULC_FORM_SCANNER_RESULT";
19
+ MobileHostResponseType2["ScannerClosed"] = "ULC_FORM_SCANNER_CLOSED";
20
+ MobileHostResponseType2["CopiedSuccess"] = "COPIED_SUCCESS";
21
+ MobileHostResponseType2["CopiedError"] = "COPIED_ERROR";
22
+ MobileHostResponseType2["SaveVCardSuccess"] = "SAVE_VCARD_SUCCESS";
23
+ MobileHostResponseType2["SaveVCardError"] = "SAVE_VCARD_ERROR";
24
+ MobileHostResponseType2["AddToWalletSuccess"] = "ADD_TO_WALLET_SUCCESS";
25
+ MobileHostResponseType2["AddToWalletError"] = "ADD_TO_WALLET_ERROR";
26
+ return MobileHostResponseType2;
27
+ })(MobileHostResponseType || {});
28
+ var REQUEST_TYPES = new Set(Object.values(MobileHostRequestType));
29
+ var RESPONSE_TYPES = new Set(Object.values(MobileHostResponseType));
30
+ function parseTyped(data, types) {
31
+ let value = data;
32
+ if (typeof value === "string") {
33
+ try {
34
+ value = JSON.parse(value);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
40
+ const record = value;
41
+ if (typeof record["type"] !== "string" || !types.has(record["type"])) return null;
42
+ return record;
43
+ }
44
+ function parseMobileHostRequest(data) {
45
+ return parseTyped(data, REQUEST_TYPES);
46
+ }
47
+ function parseMobileHostResponse(data) {
48
+ return parseTyped(data, RESPONSE_TYPES);
49
+ }
50
+ function makeSubscription(off) {
51
+ const handle = () => {
52
+ off();
53
+ };
54
+ handle.unsubscribe = off;
55
+ return handle;
56
+ }
57
+
58
+ export { MobileHostRequestType, MobileHostResponseType, makeSubscription, parseMobileHostRequest, parseMobileHostResponse };
59
+ //# sourceMappingURL=chunk-XI5MIDBA.js.map
60
+ //# sourceMappingURL=chunk-XI5MIDBA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mobile-app-protocol.ts"],"names":["MobileHostRequestType","MobileHostResponseType"],"mappings":";AA2BO,IAAK,qBAAA,qBAAAA,sBAAAA,KAAL;AAEH,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,OAAA,CAAA,GAAQ,OAAA;AAER,EAAAA,uBAAA,cAAA,CAAA,GAAe,eAAA;AAEf,EAAAA,uBAAA,kBAAA,CAAA,GAAmB,oBAAA;AAMnB,EAAAA,uBAAA,aAAA,CAAA,GAAc,uBAAA;AAEd,EAAAA,uBAAA,SAAA,CAAA,GAAU,UAAA;AAEV,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,WAAA,CAAA,GAAY,YAAA;AAEZ,EAAAA,uBAAA,aAAA,CAAA,GAAc,eAAA;AAEd,EAAAA,uBAAA,SAAA,CAAA,GAAU,UAAA;AA1BF,EAAA,OAAAA,sBAAAA;AAAA,CAAA,EAAA,qBAAA,IAAA,EAAA;AAgGL,IAAK,sBAAA,qBAAAC,uBAAAA,KAAL;AAEH,EAAAA,wBAAA,WAAA,CAAA,GAAY,YAAA;AAEZ,EAAAA,wBAAA,eAAA,CAAA,GAAgB,yBAAA;AAEhB,EAAAA,wBAAA,eAAA,CAAA,GAAgB,yBAAA;AAChB,EAAAA,wBAAA,eAAA,CAAA,GAAgB,gBAAA;AAChB,EAAAA,wBAAA,aAAA,CAAA,GAAc,cAAA;AACd,EAAAA,wBAAA,kBAAA,CAAA,GAAmB,oBAAA;AACnB,EAAAA,wBAAA,gBAAA,CAAA,GAAiB,kBAAA;AACjB,EAAAA,wBAAA,oBAAA,CAAA,GAAqB,uBAAA;AACrB,EAAAA,wBAAA,kBAAA,CAAA,GAAmB,qBAAA;AAZX,EAAA,OAAAA,uBAAAA;AAAA,CAAA,EAAA,sBAAA,IAAA,EAAA;AAoFZ,IAAM,gBAAqC,IAAI,GAAA,CAAI,MAAA,CAAO,MAAA,CAAO,qBAAqB,CAAC,CAAA;AACvF,IAAM,iBAAsC,IAAI,GAAA,CAAI,MAAA,CAAO,MAAA,CAAO,sBAAsB,CAAC,CAAA;AAEzF,SAAS,UAAA,CAAW,MAAe,KAAA,EAA4D;AAC3F,EAAA,IAAI,KAAA,GAAiB,IAAA;AACrB,EAAA,IAAI,OAAO,UAAU,QAAA,EAAU;AAC3B,IAAA,IAAI;AACA,MAAA,KAAA,GAAQ,IAAA,CAAK,MAAM,KAAK,CAAA;AAAA,IAC5B,CAAA,CAAA,MAAQ;AACJ,MAAA,OAAO,IAAA;AAAA,IACX;AAAA,EACJ;AACA,EAAA,IAAI,OAAO,UAAU,QAAA,IAAY,KAAA,KAAU,QAAQ,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,EAAG,OAAO,IAAA;AAChF,EAAA,MAAM,MAAA,GAAS,KAAA;AACf,EAAA,IAAI,OAAO,MAAA,CAAO,MAAM,CAAA,KAAM,QAAA,IAAY,CAAC,KAAA,CAAM,GAAA,CAAI,MAAA,CAAO,MAAM,CAAC,CAAA,EAAG,OAAO,IAAA;AAC7E,EAAA,OAAO,MAAA;AACX;AAOO,SAAS,uBAAuB,IAAA,EAAyC;AAC5E,EAAA,OAAO,UAAA,CAAW,MAAM,aAAa,CAAA;AACzC;AAOO,SAAS,wBAAwB,IAAA,EAA0C;AAC9E,EAAA,OAAO,UAAA,CAAW,MAAM,cAAc,CAAA;AAC1C;AAGO,SAAS,iBAAiB,GAAA,EAA8B;AAC3D,EAAA,MAAM,SAAU,MAAM;AAClB,IAAA,GAAA,EAAI;AAAA,EACR,CAAA;AACA,EAAA,MAAA,CAAO,WAAA,GAAc,GAAA;AACrB,EAAA,OAAO,MAAA;AACX","file":"chunk-XI5MIDBA.js","sourcesContent":["/**\n * The message protocol between the **Captello mobile app** and an **application it\n * embeds** (the meeting platform, Connexions, …).\n *\n * This is the second of the SDK's two channels, and the roles are the reverse of the\n * capture-webview channel:\n *\n * | Channel | Host | Embedded | Client on each side |\n * | ----------------------------------------- | ------------------- | ------------------- | ---------------------------------------------------- |\n * | A host page embeds the capture webview | your page | capture webview | `CaptelloWebview` (host side) |\n * | The mobile app embeds an application | Captello mobile app | your application | `EmbeddedAppClient` (app side), `MobileHostClient` (embedded side) |\n *\n * Wire format (match it exactly — it differs from the capture-webview channel):\n * - Messages are posted as **plain objects**, not JSON strings.\n * - `type` values are SCREAMING_CASE.\n * - {@link MobileHostRequestType} (embedded application → mobile app) and\n * {@link MobileHostResponseType} (mobile app → embedded application) are disjoint.\n */\n\nimport type { Unsubscribe } from \"./client\";\nimport type { PrefillInfoItem } from \"./messages\";\n\n/* ------------------------------------------------------------------ *\n * Requests: embedded application → mobile app\n * ------------------------------------------------------------------ */\n\n/** Message `type` values an embedded application sends to the Captello mobile app. */\nexport enum MobileHostRequestType {\n /** The application finished loading; the app hides its loading spinner. */\n AppReady = \"APP_READY\",\n /** A user-facing error; the app shows `message`. */\n Error = \"ERROR\",\n /** Close the application (the app dismisses the modal or pops the route). */\n NavigateBack = \"NAVIGATE_BACK\",\n /** Ask for a magic token; answered with {@link MobileHostResponseType.AuthToken}. */\n RequestAuthToken = \"REQUEST_AUTH_TOKEN\",\n /**\n * Open the app's badge scanner. Answered with one\n * {@link MobileHostResponseType.ScannerResult} per scanned person, then\n * {@link MobileHostResponseType.ScannerClosed}.\n */\n OpenScanner = \"OPEN_ULC_FORM_SCANNER\",\n /** Open `url` in the system browser. */\n OpenUrl = \"OPEN_URL\",\n /** Copy `text` to the clipboard; acknowledged with `COPIED_SUCCESS` / `COPIED_ERROR`. */\n CopyText = \"COPY_TEXT\",\n /** Open the native share sheet. */\n ShareUrl = \"SHARE_URL\",\n /** Save a vCard to the contacts; acknowledged with `SAVE_VCARD_SUCCESS` / `SAVE_VCARD_ERROR`. */\n SaveVCard = \"SAVE_VCARD\",\n /** Add a wallet pass; acknowledged with `ADD_TO_WALLET_SUCCESS` / `ADD_TO_WALLET_ERROR`. */\n AddToWallet = \"ADD_TO_WALLET\",\n /** Ask the app to synchronize its data. */\n SyncApp = \"SYNC_APP\",\n}\n\nexport interface AppReadyRequest {\n type: MobileHostRequestType.AppReady;\n}\nexport interface ErrorRequest {\n type: MobileHostRequestType.Error;\n message: string;\n}\nexport interface NavigateBackRequest {\n type: MobileHostRequestType.NavigateBack;\n}\nexport interface RequestAuthTokenRequest {\n type: MobileHostRequestType.RequestAuthToken;\n}\nexport interface OpenScannerRequest {\n type: MobileHostRequestType.OpenScanner;\n}\nexport interface OpenUrlRequest {\n type: MobileHostRequestType.OpenUrl;\n url: string;\n}\nexport interface CopyTextRequest {\n type: MobileHostRequestType.CopyText;\n text: string;\n}\nexport interface ShareUrlRequest {\n type: MobileHostRequestType.ShareUrl;\n title: string;\n text: string;\n}\nexport interface SaveVCardRequest {\n type: MobileHostRequestType.SaveVCard;\n /** vCard text. */\n text: string;\n}\nexport interface AddToWalletRequest {\n type: MobileHostRequestType.AddToWallet;\n /** Base64 pass data, with or without a `data:` prefix. */\n text: string;\n}\nexport interface SyncAppRequest {\n type: MobileHostRequestType.SyncApp;\n}\n\n/** Discriminated union of every message an embedded application can send to the mobile app. */\nexport type MobileHostRequest =\n | AppReadyRequest\n | ErrorRequest\n | NavigateBackRequest\n | RequestAuthTokenRequest\n | OpenScannerRequest\n | OpenUrlRequest\n | CopyTextRequest\n | ShareUrlRequest\n | SaveVCardRequest\n | AddToWalletRequest\n | SyncAppRequest;\n\n/** Maps each request `type` to its full message shape (used by {@link EmbeddedAppClient.onRequest}). */\nexport type MobileHostRequestMap = {\n [M in MobileHostRequest as M[\"type\"]]: M;\n};\n\n/* ------------------------------------------------------------------ *\n * Responses: mobile app → embedded application\n * ------------------------------------------------------------------ */\n\n/** Message `type` values the Captello mobile app sends to an embedded application. */\nexport enum MobileHostResponseType {\n /** Answer to {@link MobileHostRequestType.RequestAuthToken}. */\n AuthToken = \"AUTH_TOKEN\",\n /** One scanned person (or a scanner failure) after {@link MobileHostRequestType.OpenScanner}. */\n ScannerResult = \"ULC_FORM_SCANNER_RESULT\",\n /** The scanner session ended: every result was posted, or the user cancelled. */\n ScannerClosed = \"ULC_FORM_SCANNER_CLOSED\",\n CopiedSuccess = \"COPIED_SUCCESS\",\n CopiedError = \"COPIED_ERROR\",\n SaveVCardSuccess = \"SAVE_VCARD_SUCCESS\",\n SaveVCardError = \"SAVE_VCARD_ERROR\",\n AddToWalletSuccess = \"ADD_TO_WALLET_SUCCESS\",\n AddToWalletError = \"ADD_TO_WALLET_ERROR\",\n}\n\n/** Mobile app → embedded application: the magic token requested with {@link MobileHostRequestType.RequestAuthToken}. */\nexport interface AuthTokenMessage {\n type: MobileHostResponseType.AuthToken;\n token: string;\n}\n\n/**\n * Mobile app → embedded application: one scanned person, or a scanner failure.\n *\n * The app posts one of these per person the scanner captured — several for a group\n * scan — then a {@link ScannerClosedMessage}. `result` holds the looked-up attendee\n * fields in the same shape {@link PrefillInfoItem} uses, so they can be fed straight to\n * a capture form's `prefill({ info })`. It is empty (not absent) when the badge had no\n * lookup data; the badge can still be linked via `badgeId`.\n *\n * On failure `result` is absent and `message` carries the error text.\n */\nexport interface ScannerResultMessage {\n type: MobileHostResponseType.ScannerResult;\n /**\n * Badge ID the person was scanned from. Empty for business cards and manual search.\n * Absent altogether on app builds that predate multi-person sessions — those post a\n * single result and never a {@link ScannerClosedMessage}.\n */\n badgeId?: string;\n /** Looked-up attendee fields (success). */\n result?: PrefillInfoItem[];\n /** Error text (failure) — `result` is absent. */\n message?: string;\n}\n\n/** Mobile app → embedded application: the scanner session ended. Follows the last {@link ScannerResultMessage}. */\nexport interface ScannerClosedMessage {\n type: MobileHostResponseType.ScannerClosed;\n}\n\ninterface AckMessage<T extends MobileHostResponseType> {\n type: T;\n}\n\n/** Discriminated union of every message the mobile app can send to an embedded application. */\nexport type MobileHostResponse =\n | AuthTokenMessage\n | ScannerResultMessage\n | ScannerClosedMessage\n | AckMessage<MobileHostResponseType.CopiedSuccess>\n | AckMessage<MobileHostResponseType.CopiedError>\n | AckMessage<MobileHostResponseType.SaveVCardSuccess>\n | AckMessage<MobileHostResponseType.SaveVCardError>\n | AckMessage<MobileHostResponseType.AddToWalletSuccess>\n | AckMessage<MobileHostResponseType.AddToWalletError>;\n\n/** Maps each response `type` to its full message shape (used by {@link MobileHostClient.on}). */\nexport type MobileHostResponseMap = {\n [M in MobileHostResponse as M[\"type\"]]: M;\n};\n\n/** One person captured by the mobile app's scanner. */\nexport interface ScannedPerson {\n /** Badge ID the person was scanned from; empty for business cards and manual search. */\n badgeId: string;\n /** Looked-up attendee fields; empty when the badge had no lookup data. */\n fields: PrefillInfoItem[];\n}\n\n/* ------------------------------------------------------------------ *\n * Runtime guards / parsing\n * ------------------------------------------------------------------ */\n\nconst REQUEST_TYPES: ReadonlySet<string> = new Set(Object.values(MobileHostRequestType));\nconst RESPONSE_TYPES: ReadonlySet<string> = new Set(Object.values(MobileHostResponseType));\n\nfunction parseTyped(data: unknown, types: ReadonlySet<string>): Record<string, unknown> | null {\n let value: unknown = data;\n if (typeof value === \"string\") {\n try {\n value = JSON.parse(value);\n } catch {\n return null;\n }\n }\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return null;\n const record = value as Record<string, unknown>;\n if (typeof record[\"type\"] !== \"string\" || !types.has(record[\"type\"])) return null;\n return record;\n}\n\n/**\n * Parses a raw `MessageEvent.data` value into a typed {@link MobileHostRequest}, or\n * returns `null` if it is not a recognized request. Plain objects are the wire format;\n * a JSON string is accepted too for robustness.\n */\nexport function parseMobileHostRequest(data: unknown): MobileHostRequest | null {\n return parseTyped(data, REQUEST_TYPES) as unknown as MobileHostRequest | null;\n}\n\n/**\n * Parses a raw `MessageEvent.data` value into a typed {@link MobileHostResponse}, or\n * returns `null` if it is not a recognized response. Plain objects are the wire format;\n * a JSON string is accepted too for robustness.\n */\nexport function parseMobileHostResponse(data: unknown): MobileHostResponse | null {\n return parseTyped(data, RESPONSE_TYPES) as unknown as MobileHostResponse | null;\n}\n\n/** @internal Builds the callable-with-`unsubscribe()` handle every `on*` method returns. */\nexport function makeSubscription(off: () => void): Unsubscribe {\n const handle = (() => {\n off();\n }) as Unsubscribe;\n handle.unsubscribe = off;\n return handle;\n}\n"]}
@@ -0,0 +1,93 @@
1
+ import { F as FrameLike, U as Unsubscribe } from './client-CAMlFA8s.js';
2
+ import { M as MobileHostRequest, a as MobileHostRequestType, b as MobileHostRequestMap, c as MobileHostResponse, S as ScannedPerson } from './mobile-app-protocol-Bt-UDbu5.js';
3
+ export { A as AuthTokenMessage, d as MobileHostResponseMap, e as MobileHostResponseType, f as ScannerClosedMessage, g as ScannerResultMessage, p as parseMobileHostRequest, h as parseMobileHostResponse } from './mobile-app-protocol-Bt-UDbu5.js';
4
+
5
+ /**
6
+ * `@captello/ulc-webview-sdk/embedded-app` — for the **Captello mobile app** (or any
7
+ * host playing its role) that embeds an application in an iframe and serves it native
8
+ * services over `postMessage`.
9
+ *
10
+ * {@link EmbeddedAppClient} wraps the iframe: it delivers the embedded application's
11
+ * requests to typed handlers and posts the app's responses back. The embedded
12
+ * application's own side is `MobileHostClient` (`@captello/ulc-webview-sdk/mobile-host`).
13
+ *
14
+ * Not to be confused with `CaptelloWebview`, which wraps an iframe of the *capture
15
+ * webview* — a different protocol (JSON strings, snake_case types).
16
+ *
17
+ * @example
18
+ * import { EmbeddedAppClient, MobileHostRequestType } from "@captello/ulc-webview-sdk/embedded-app";
19
+ *
20
+ * const embedded = new EmbeddedAppClient(iframe, { targetOrigin: new URL(iframe.src).origin });
21
+ * embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {
22
+ * embedded.sendAuthToken(await mintMagicToken());
23
+ * });
24
+ * embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {
25
+ * for (const person of await scan()) embedded.sendScannerResult(person);
26
+ * embedded.sendScannerClosed();
27
+ * });
28
+ */
29
+
30
+ /** Listener for a specific request type from the embedded application. */
31
+ type MobileHostRequestListener<T extends MobileHostRequestType> = (request: MobileHostRequestMap[T]) => void;
32
+ /** Listener for every request from the embedded application (used by {@link EmbeddedAppClient.onAnyRequest}). */
33
+ type AnyMobileHostRequestListener = (request: MobileHostRequest) => void;
34
+ interface EmbeddedAppClientOptions {
35
+ /**
36
+ * Origin to validate incoming requests against and to target outgoing responses.
37
+ * A full URL is accepted and reduced to its origin.
38
+ *
39
+ * Defaults to the iframe's `src` origin, read fresh on every send so a `src` bound
40
+ * after construction still works. When neither is available the client refuses to
41
+ * send rather than fall back to `"*"` — a response can carry an auth token, and
42
+ * `"*"` would hand it to whatever the frame navigated to.
43
+ */
44
+ targetOrigin?: string;
45
+ /** Window to listen on. Defaults to the global `window`. */
46
+ hostWindow?: Window;
47
+ /**
48
+ * Only accept requests whose `event.source` is the bound iframe's window. Defaults
49
+ * to `true`. Set `false` if the application relays through another window.
50
+ */
51
+ matchSource?: boolean;
52
+ }
53
+ /**
54
+ * Mobile-app-side client for an application embedded in an iframe.
55
+ *
56
+ * Attaches a single `message` listener on construction; call {@link destroy} when the
57
+ * iframe goes away.
58
+ */
59
+ declare class EmbeddedAppClient {
60
+ private readonly frame;
61
+ private readonly hostWindow;
62
+ private readonly explicitOrigin;
63
+ private readonly matchSource;
64
+ private readonly listeners;
65
+ private readonly anyListeners;
66
+ private readonly boundHandler;
67
+ private destroyed;
68
+ constructor(frame: FrameLike, options?: EmbeddedAppClientOptions);
69
+ /** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */
70
+ onRequest<T extends MobileHostRequestType>(type: T, listener: MobileHostRequestListener<T>): Unsubscribe;
71
+ /** Subscribe to every request. */
72
+ onAnyRequest(listener: AnyMobileHostRequestListener): Unsubscribe;
73
+ /**
74
+ * Post a response into the embedded application. Returns `false` when nothing was
75
+ * sent: the client is destroyed, the iframe has no window, or no target origin could
76
+ * be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).
77
+ */
78
+ send(response: MobileHostResponse): boolean;
79
+ /** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */
80
+ sendAuthToken(token: string): boolean;
81
+ /** Post one scanned person of the current scanner session. */
82
+ sendScannerResult(person: ScannedPerson): boolean;
83
+ /** Fail the current scanner session with a display-ready message. */
84
+ sendScannerError(message: string): boolean;
85
+ /** End the current scanner session; send after the last result, or on cancel. */
86
+ sendScannerClosed(): boolean;
87
+ /** Remove the `message` listener and drop every subscription. Safe to call more than once. */
88
+ destroy(): void;
89
+ private resolveOrigin;
90
+ private handleMessage;
91
+ }
92
+
93
+ export { type AnyMobileHostRequestListener, EmbeddedAppClient, type EmbeddedAppClientOptions, FrameLike, MobileHostRequest, type MobileHostRequestListener, MobileHostRequestMap, MobileHostRequestType, MobileHostResponse, ScannedPerson };
@@ -0,0 +1,119 @@
1
+ import { makeSubscription, parseMobileHostRequest } from './chunk-XI5MIDBA.js';
2
+ export { MobileHostRequestType, MobileHostResponseType, parseMobileHostRequest, parseMobileHostResponse } from './chunk-XI5MIDBA.js';
3
+
4
+ // src/embedded-app.ts
5
+ function toOrigin(value) {
6
+ if (!value) return void 0;
7
+ if (value === "*") return value;
8
+ try {
9
+ return new URL(value).origin;
10
+ } catch {
11
+ return void 0;
12
+ }
13
+ }
14
+ var EmbeddedAppClient = class {
15
+ constructor(frame, options = {}) {
16
+ this.listeners = /* @__PURE__ */ new Map();
17
+ this.anyListeners = /* @__PURE__ */ new Set();
18
+ this.boundHandler = (event) => this.handleMessage(event);
19
+ this.destroyed = false;
20
+ if (!frame) {
21
+ throw new Error("EmbeddedAppClient: a mounted iframe element (or { contentWindow }) is required.");
22
+ }
23
+ const hostWindow = options.hostWindow ?? (typeof window !== "undefined" ? window : void 0);
24
+ if (!hostWindow) {
25
+ throw new Error(
26
+ "EmbeddedAppClient: no host window available. Pass `hostWindow` when constructing outside a browser."
27
+ );
28
+ }
29
+ this.frame = frame;
30
+ this.hostWindow = hostWindow;
31
+ this.explicitOrigin = toOrigin(options.targetOrigin);
32
+ this.matchSource = options.matchSource ?? true;
33
+ this.hostWindow.addEventListener("message", this.boundHandler);
34
+ }
35
+ /** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */
36
+ onRequest(type, listener) {
37
+ let set = this.listeners.get(type);
38
+ if (!set) {
39
+ set = /* @__PURE__ */ new Set();
40
+ this.listeners.set(type, set);
41
+ }
42
+ set.add(listener);
43
+ return makeSubscription(() => {
44
+ set?.delete(listener);
45
+ });
46
+ }
47
+ /** Subscribe to every request. */
48
+ onAnyRequest(listener) {
49
+ this.anyListeners.add(listener);
50
+ return makeSubscription(() => {
51
+ this.anyListeners.delete(listener);
52
+ });
53
+ }
54
+ /**
55
+ * Post a response into the embedded application. Returns `false` when nothing was
56
+ * sent: the client is destroyed, the iframe has no window, or no target origin could
57
+ * be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).
58
+ */
59
+ send(response) {
60
+ if (this.destroyed) return false;
61
+ const target = this.frame.contentWindow;
62
+ const origin = this.resolveOrigin();
63
+ if (!target || !origin) return false;
64
+ target.postMessage(response, origin);
65
+ return true;
66
+ }
67
+ /** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */
68
+ sendAuthToken(token) {
69
+ return this.send({ type: "AUTH_TOKEN" /* AuthToken */, token });
70
+ }
71
+ /** Post one scanned person of the current scanner session. */
72
+ sendScannerResult(person) {
73
+ return this.send({
74
+ type: "ULC_FORM_SCANNER_RESULT" /* ScannerResult */,
75
+ badgeId: person.badgeId,
76
+ result: person.fields
77
+ });
78
+ }
79
+ /** Fail the current scanner session with a display-ready message. */
80
+ sendScannerError(message) {
81
+ return this.send({ type: "ULC_FORM_SCANNER_RESULT" /* ScannerResult */, message });
82
+ }
83
+ /** End the current scanner session; send after the last result, or on cancel. */
84
+ sendScannerClosed() {
85
+ return this.send({ type: "ULC_FORM_SCANNER_CLOSED" /* ScannerClosed */ });
86
+ }
87
+ /** Remove the `message` listener and drop every subscription. Safe to call more than once. */
88
+ destroy() {
89
+ if (this.destroyed) return;
90
+ this.destroyed = true;
91
+ this.hostWindow.removeEventListener("message", this.boundHandler);
92
+ this.listeners.clear();
93
+ this.anyListeners.clear();
94
+ }
95
+ resolveOrigin() {
96
+ if (this.explicitOrigin) return this.explicitOrigin;
97
+ const src = "src" in this.frame ? this.frame.src : void 0;
98
+ return toOrigin(src);
99
+ }
100
+ handleMessage(event) {
101
+ if (this.matchSource && event.source !== this.frame.contentWindow) return;
102
+ const origin = this.resolveOrigin();
103
+ if (origin && origin !== "*" && event.origin !== origin) return;
104
+ const request = parseMobileHostRequest(event.data);
105
+ if (!request) return;
106
+ for (const listener of Array.from(this.anyListeners)) {
107
+ listener(request);
108
+ }
109
+ const set = this.listeners.get(request.type);
110
+ if (!set) return;
111
+ for (const listener of Array.from(set)) {
112
+ listener(request);
113
+ }
114
+ }
115
+ };
116
+
117
+ export { EmbeddedAppClient };
118
+ //# sourceMappingURL=embedded-app.js.map
119
+ //# sourceMappingURL=embedded-app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/embedded-app.ts"],"names":[],"mappings":"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","file":"embedded-app.js","sourcesContent":["/**\n * `@captello/ulc-webview-sdk/embedded-app` — for the **Captello mobile app** (or any\n * host playing its role) that embeds an application in an iframe and serves it native\n * services over `postMessage`.\n *\n * {@link EmbeddedAppClient} wraps the iframe: it delivers the embedded application's\n * requests to typed handlers and posts the app's responses back. The embedded\n * application's own side is `MobileHostClient` (`@captello/ulc-webview-sdk/mobile-host`).\n *\n * Not to be confused with `CaptelloWebview`, which wraps an iframe of the *capture\n * webview* — a different protocol (JSON strings, snake_case types).\n *\n * @example\n * import { EmbeddedAppClient, MobileHostRequestType } from \"@captello/ulc-webview-sdk/embedded-app\";\n *\n * const embedded = new EmbeddedAppClient(iframe, { targetOrigin: new URL(iframe.src).origin });\n * embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {\n * embedded.sendAuthToken(await mintMagicToken());\n * });\n * embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {\n * for (const person of await scan()) embedded.sendScannerResult(person);\n * embedded.sendScannerClosed();\n * });\n */\n\nimport type { FrameLike, Unsubscribe } from \"./client\";\nimport {\n makeSubscription,\n MobileHostRequestType,\n MobileHostResponseType,\n parseMobileHostRequest,\n} from \"./mobile-app-protocol\";\nimport type { MobileHostRequest, MobileHostRequestMap, MobileHostResponse, ScannedPerson } from \"./mobile-app-protocol\";\n\nexport {\n MobileHostRequestType,\n MobileHostResponseType,\n parseMobileHostRequest,\n parseMobileHostResponse,\n} from \"./mobile-app-protocol\";\nexport type {\n MobileHostRequest,\n MobileHostRequestMap,\n MobileHostResponse,\n MobileHostResponseMap,\n AuthTokenMessage,\n ScannerResultMessage,\n ScannerClosedMessage,\n ScannedPerson,\n} from \"./mobile-app-protocol\";\nexport type { FrameLike } from \"./client\";\n\n/** Listener for a specific request type from the embedded application. */\nexport type MobileHostRequestListener<T extends MobileHostRequestType> = (request: MobileHostRequestMap[T]) => void;\n\n/** Listener for every request from the embedded application (used by {@link EmbeddedAppClient.onAnyRequest}). */\nexport type AnyMobileHostRequestListener = (request: MobileHostRequest) => void;\n\nexport interface EmbeddedAppClientOptions {\n /**\n * Origin to validate incoming requests against and to target outgoing responses.\n * A full URL is accepted and reduced to its origin.\n *\n * Defaults to the iframe's `src` origin, read fresh on every send so a `src` bound\n * after construction still works. When neither is available the client refuses to\n * send rather than fall back to `\"*\"` — a response can carry an auth token, and\n * `\"*\"` would hand it to whatever the frame navigated to.\n */\n targetOrigin?: string;\n /** Window to listen on. Defaults to the global `window`. */\n hostWindow?: Window;\n /**\n * Only accept requests whose `event.source` is the bound iframe's window. Defaults\n * to `true`. Set `false` if the application relays through another window.\n */\n matchSource?: boolean;\n}\n\nfunction toOrigin(value: string | undefined): string | undefined {\n if (!value) return undefined;\n if (value === \"*\") return value;\n try {\n return new URL(value).origin;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Mobile-app-side client for an application embedded in an iframe.\n *\n * Attaches a single `message` listener on construction; call {@link destroy} when the\n * iframe goes away.\n */\nexport class EmbeddedAppClient {\n private readonly frame: FrameLike;\n private readonly hostWindow: Window;\n private readonly explicitOrigin: string | undefined;\n private readonly matchSource: boolean;\n private readonly listeners = new Map<\n MobileHostRequestType,\n Set<MobileHostRequestListener<MobileHostRequestType>>\n >();\n private readonly anyListeners = new Set<AnyMobileHostRequestListener>();\n private readonly boundHandler = (event: MessageEvent) => this.handleMessage(event);\n private destroyed = false;\n\n constructor(frame: FrameLike, options: EmbeddedAppClientOptions = {}) {\n if (!frame) {\n throw new Error(\"EmbeddedAppClient: a mounted iframe element (or { contentWindow }) is required.\");\n }\n const hostWindow = options.hostWindow ?? (typeof window !== \"undefined\" ? window : undefined);\n if (!hostWindow) {\n throw new Error(\n \"EmbeddedAppClient: no host window available. Pass `hostWindow` when constructing outside a browser.\",\n );\n }\n this.frame = frame;\n this.hostWindow = hostWindow;\n this.explicitOrigin = toOrigin(options.targetOrigin);\n this.matchSource = options.matchSource ?? true;\n this.hostWindow.addEventListener(\"message\", this.boundHandler);\n }\n\n /** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */\n onRequest<T extends MobileHostRequestType>(type: T, listener: MobileHostRequestListener<T>): Unsubscribe {\n let set = this.listeners.get(type);\n if (!set) {\n set = new Set();\n this.listeners.set(type, set);\n }\n set.add(listener as MobileHostRequestListener<MobileHostRequestType>);\n return makeSubscription(() => {\n set?.delete(listener as MobileHostRequestListener<MobileHostRequestType>);\n });\n }\n\n /** Subscribe to every request. */\n onAnyRequest(listener: AnyMobileHostRequestListener): Unsubscribe {\n this.anyListeners.add(listener);\n return makeSubscription(() => {\n this.anyListeners.delete(listener);\n });\n }\n\n /**\n * Post a response into the embedded application. Returns `false` when nothing was\n * sent: the client is destroyed, the iframe has no window, or no target origin could\n * be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).\n */\n send(response: MobileHostResponse): boolean {\n if (this.destroyed) return false;\n const target = this.frame.contentWindow;\n const origin = this.resolveOrigin();\n if (!target || !origin) return false;\n target.postMessage(response, origin);\n return true;\n }\n\n /** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */\n sendAuthToken(token: string): boolean {\n return this.send({ type: MobileHostResponseType.AuthToken, token });\n }\n\n /** Post one scanned person of the current scanner session. */\n sendScannerResult(person: ScannedPerson): boolean {\n return this.send({\n type: MobileHostResponseType.ScannerResult,\n badgeId: person.badgeId,\n result: person.fields,\n });\n }\n\n /** Fail the current scanner session with a display-ready message. */\n sendScannerError(message: string): boolean {\n return this.send({ type: MobileHostResponseType.ScannerResult, message });\n }\n\n /** End the current scanner session; send after the last result, or on cancel. */\n sendScannerClosed(): boolean {\n return this.send({ type: MobileHostResponseType.ScannerClosed });\n }\n\n /** Remove the `message` listener and drop every subscription. Safe to call more than once. */\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.hostWindow.removeEventListener(\"message\", this.boundHandler);\n this.listeners.clear();\n this.anyListeners.clear();\n }\n\n private resolveOrigin(): string | undefined {\n if (this.explicitOrigin) return this.explicitOrigin;\n const src = \"src\" in this.frame ? (this.frame as { src?: string }).src : undefined;\n return toOrigin(src);\n }\n\n private handleMessage(event: MessageEvent): void {\n if (this.matchSource && event.source !== this.frame.contentWindow) return;\n const origin = this.resolveOrigin();\n if (origin && origin !== \"*\" && event.origin !== origin) return;\n const request = parseMobileHostRequest(event.data);\n if (!request) return;\n for (const listener of Array.from(this.anyListeners)) {\n listener(request);\n }\n const set = this.listeners.get(request.type);\n if (!set) return;\n for (const listener of Array.from(set)) {\n listener(request);\n }\n }\n}\n"]}