@captello/ulc-webview-sdk 1.1.0 → 1.2.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 +105 -0
- package/README.md +341 -1
- package/dist/app-host.d.ts +228 -0
- package/dist/app-host.js +170 -0
- package/dist/app-host.js.map +1 -0
- package/dist/{chunk-UUEUW2WL.js → chunk-ZPVXZW2B.js} +235 -19
- package/dist/chunk-ZPVXZW2B.js.map +1 -0
- package/dist/{chunk-4E7OW4RJ.js → chunk-ZX4AKPWF.js} +5 -3
- package/dist/chunk-ZX4AKPWF.js.map +1 -0
- package/dist/{client-cZpygJTD.d.ts → client-CAMlFA8s.d.ts} +359 -11
- package/dist/index.d.ts +16 -2
- package/dist/index.js +2 -2
- package/dist/promises.d.ts +3 -5
- package/dist/promises.js +2 -2
- package/dist/promises.js.map +1 -1
- package/dist/react.d.ts +12 -2
- package/dist/react.js +14 -3
- package/dist/react.js.map +1 -1
- package/package.json +83 -73
- package/dist/chunk-4E7OW4RJ.js.map +0 -1
- package/dist/chunk-UUEUW2WL.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- aab3383: New entry `@captello/ulc-webview-sdk/app` for pages that run **inside the Captello mobile
|
|
8
|
+
app** (the meeting platform, Connexions): the page-side client for the app's `postMessage`
|
|
9
|
+
channel, which until now every page hand-rolled.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { CaptelloAppHost } from "@captello/ulc-webview-sdk/app";
|
|
13
|
+
|
|
14
|
+
const app = new CaptelloAppHost();
|
|
15
|
+
const token = await app.requestAuthToken();
|
|
16
|
+
app.notifyReady();
|
|
17
|
+
|
|
18
|
+
const people = await app.openScanner(); // one per scanned person; [] on cancel
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **`openScanner()`** opens the app's badge scanner and resolves once the session ends with
|
|
22
|
+
every person it captured, in scan order — several for a group scan. Each is
|
|
23
|
+
`{ badgeId, fields }`; a badge with no lookup data still arrives with its `badgeId` and
|
|
24
|
+
empty `fields` so it can be linked to the attendee. Rejects with `ScannerError` when the
|
|
25
|
+
app reports a failure. Older app builds that post a single result and no close message
|
|
26
|
+
are handled transparently.
|
|
27
|
+
- **`requestAuthToken()`**, **`notifyReady()`**, **`notifyError()`**, **`navigateBack()`**
|
|
28
|
+
wrap the existing auth and navigation messages; `send()` posts any other `AppRequest`.
|
|
29
|
+
- **`AppRequestType` / `AppResponseType`** enumerate the channel's wire types (plain objects,
|
|
30
|
+
SCREAMING_CASE — unlike the capture-webview channel, which uses JSON strings), with
|
|
31
|
+
`parseAppResponse` and typed `on(type, listener)` subscriptions.
|
|
32
|
+
|
|
33
|
+
- 4d7cdd7: Adds `attach()` — a global entry point that needs no iframe reference, for hosts replacing a
|
|
34
|
+
hand-rolled `window.addEventListener("message", …)` dispatcher:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const webview = CaptelloSdk.attach({ targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE });
|
|
38
|
+
webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (m) => showError(m.data));
|
|
39
|
+
webview.onTranscribeScannerRequest((request, reply) => transcribe(request, reply));
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
There is nothing to query from the DOM, no ordering to get right relative to when the iframe is
|
|
43
|
+
created, and no teardown to remember.
|
|
44
|
+
|
|
45
|
+
The first inbound message that clears the origin check **and** parses as a well-formed Captello
|
|
46
|
+
message identifies the webview; that sender becomes the target for sends and the window every
|
|
47
|
+
later message is source-matched against, so after the first message this is exactly as strict as
|
|
48
|
+
passing the iframe. Teardown stays automatic: a removed iframe's window reports `closed`, which is
|
|
49
|
+
readable cross-origin, so the client notices its form going away and destroys itself.
|
|
50
|
+
|
|
51
|
+
`new CaptelloWebview(iframe, …)` is unchanged and remains the better choice when you hold the
|
|
52
|
+
element and drive the form unprompted from page load — until the form speaks once, `attach()` has
|
|
53
|
+
no window to post to (sends are buffered by `queueUntilReady` in the meantime).
|
|
54
|
+
|
|
55
|
+
Also adds a Playwright browser-test layer (`pnpm e2e`) that drives the built IIFE bundle against a
|
|
56
|
+
genuinely cross-origin iframe, covering what jsdom cannot: real origin filtering, `event.source`
|
|
57
|
+
identity, and the `closed`-based teardown.
|
|
58
|
+
|
|
59
|
+
- bc9baef: Three additions that let an existing hand-rolled `window.addEventListener("message")`
|
|
60
|
+
integration swap in the SDK without restructuring:
|
|
61
|
+
|
|
62
|
+
- **Transcribe handlers now get a reply handle** as their second argument, so a host that
|
|
63
|
+
can't hand back a promise never has to post a message itself:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
webview.onTranscribeScannerRequest((request, reply) => {
|
|
67
|
+
transcribeService(
|
|
68
|
+
request.image_urls,
|
|
69
|
+
(fields) => reply.resolve({ fields, submissionType: "ocr_transcription" }),
|
|
70
|
+
() => reply.reject("Transcription failed."),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`reply` is a plain `{ resolve, reject, answered }` object that can be passed straight to
|
|
76
|
+
whatever does the work, keeping that code free of any SDK reference. Returning the fields
|
|
77
|
+
still works exactly as before; a handler that returns nothing is taken to own the reply and
|
|
78
|
+
the request stays open until it answers. The first answer wins. Existing single-argument
|
|
79
|
+
handlers are unaffected.
|
|
80
|
+
|
|
81
|
+
- **`resolveTranscribeScannerRequest(request, data)` / `rejectTranscribeScannerRequest(request, error)`** —
|
|
82
|
+
the same two answers on the client, for when the reply happens outside the handler's
|
|
83
|
+
scope (a global bus, a module holding only the `request_id`). Both take the request or
|
|
84
|
+
its bare `request_id`, post immediately (never parked in the ready-queue), and are a
|
|
85
|
+
silent no-op once the client is destroyed or the iframe is detached.
|
|
86
|
+
- **The client tears itself down with its iframe.** The frame argument must be a mounted
|
|
87
|
+
iframe (the caller owns that ordering), and with the new `autoDestroy` option — default
|
|
88
|
+
`true` — the client calls `destroy()` on itself once that element leaves the document,
|
|
89
|
+
removing the `message` listener and dropping every subscription. Removal of an ancestor
|
|
90
|
+
counts, which is the common case: emptying a panel or modal takes the iframe with it.
|
|
91
|
+
Detection uses `MutationObserver`, so it is inert for a `{ contentWindow }` stand-in or
|
|
92
|
+
in a non-DOM environment. Pass `autoDestroy: false` to manage the lifetime yourself.
|
|
93
|
+
- **Every `on*` method now returns a subscription handle** — an object with
|
|
94
|
+
`unsubscribe()`:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
const sub = webview.on(OutboundMessageType.SubmissionBody, save);
|
|
98
|
+
sub.unsubscribe();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The handle is also directly callable, so existing `const off = ...; off()` code keeps
|
|
102
|
+
working unchanged.
|
|
103
|
+
|
|
104
|
+
- **`targetOrigin` accepts a full URL**, reduced to its origin via `new URL(value).origin`.
|
|
105
|
+
Hosts hold a webview base URL or embed URL rather than a bare origin, which never equals
|
|
106
|
+
`event.origin` — the usual reason a hand-rolled origin check ends up commented out.
|
|
107
|
+
|
|
3
108
|
## 1.1.0
|
|
4
109
|
|
|
5
110
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ 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
24
|
|
|
22
25
|
The core package is framework-agnostic with no runtime dependencies; React is an
|
|
23
26
|
optional peer dependency used only by the `/react` entry point.
|
|
@@ -28,6 +31,31 @@ optional peer dependency used only by the `/react` entry point.
|
|
|
28
31
|
pnpm add @captello/ulc-webview-sdk
|
|
29
32
|
```
|
|
30
33
|
|
|
34
|
+
### No bundler? Use the hosted script
|
|
35
|
+
|
|
36
|
+
The webview deployment also serves the SDK as a plain script — a single IIFE
|
|
37
|
+
bundle exposing everything (core + promise helpers, minus the React adapter)
|
|
38
|
+
on a `CaptelloSdk` global. Because it deploys with the webview, the hosted
|
|
39
|
+
file always matches the protocol of the webview at the same origin.
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<script src="https://capture.captello.com/sdk/v1/captello-sdk.js"></script>
|
|
43
|
+
<script>
|
|
44
|
+
const { buildEmbedUrl, CaptelloWebview, FormMode } = CaptelloSdk;
|
|
45
|
+
|
|
46
|
+
const src = buildEmbedUrl("https://capture.captello.com", {
|
|
47
|
+
formId: 1234,
|
|
48
|
+
mode: FormMode.Submit,
|
|
49
|
+
});
|
|
50
|
+
// ... point an iframe at `src` and wire up `new CaptelloWebview(iframe, ...)`
|
|
51
|
+
// exactly as in the quick start below.
|
|
52
|
+
</script>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use the same origin you embed (`capture.captello.com`, `capture-demo.…`, etc.).
|
|
56
|
+
The `v1` path segment only changes on breaking protocol changes. Teams with a
|
|
57
|
+
bundler should prefer the npm package for types and tree-shaking.
|
|
58
|
+
|
|
31
59
|
## Quick start
|
|
32
60
|
|
|
33
61
|
```ts
|
|
@@ -103,6 +131,89 @@ try {
|
|
|
103
131
|
}
|
|
104
132
|
```
|
|
105
133
|
|
|
134
|
+
### The transcribe button — host-side processing
|
|
135
|
+
|
|
136
|
+
Hosts that can transcribe scanned badges/cards can put a **Transcribe** button in
|
|
137
|
+
the form's badge element: enable it with `showTranscribeButton: true` in the embed
|
|
138
|
+
URL, and answer the requests with `onTranscribeScannerRequest`. The button renders
|
|
139
|
+
inside the badge/barcode element and only while the submission has no email (once
|
|
140
|
+
an email lands — from the badge lookup or from a transcription — it disappears). When pressed, the form sends a
|
|
141
|
+
`transcribe_scanner_request` carrying the scan's `element_id`, its `image_urls`, and
|
|
142
|
+
the `draft_submission_token` the webview was loaded with; the handler's resolved
|
|
143
|
+
fields are sent back and filled into the form (empty values are skipped, so they
|
|
144
|
+
never blank already-filled inputs). Only enable the button when a handler is
|
|
145
|
+
registered — otherwise it times out with an error for the user.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const src = buildEmbedUrl("https://capture.captello.com", {
|
|
149
|
+
formId: 1234,
|
|
150
|
+
mode: FormMode.Submit,
|
|
151
|
+
showTranscribeButton: true, // show_transcribe_button=1
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
webview.onTranscribeScannerRequest(async ({ image_urls, draft_submission_token }) => {
|
|
155
|
+
const fields = await transcribeService(image_urls, draft_submission_token);
|
|
156
|
+
// fields: [{ llFieldId: 16, llFieldIdentifier: "FirstName", value: "Patrick" }, ...]
|
|
157
|
+
return { fields, submissionType: "ocr_transcription" };
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A thrown `Error`'s message is shown to the user by the form, so throw display-ready
|
|
162
|
+
messages. The wire shapes are exported as `TranscribeScannerRequestMessage`,
|
|
163
|
+
`TranscribeScannerResultMessage`, `TranscribeScannerResultData`, and
|
|
164
|
+
`TranscribedScannerField`. Host processing is a convention, not a one-off: future
|
|
165
|
+
operations get their own `*_request` / `*_result` pairs shaped like this one. In
|
|
166
|
+
React, pass the handler as the `onTranscribeScannerRequest` option/prop instead.
|
|
167
|
+
|
|
168
|
+
#### Answering later, from callback-style code
|
|
169
|
+
|
|
170
|
+
The handler's second argument is a **reply handle** — call it whenever the work
|
|
171
|
+
finishes. Use it when the transcription can't hand back a promise: a callback-style
|
|
172
|
+
AJAX wrapper, an event bus, a method that returns `void`. Nothing else changes, and the
|
|
173
|
+
host never builds or posts a message itself:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
webview.onTranscribeScannerRequest((request, reply) => {
|
|
177
|
+
ll_ajax_manager.send_request(
|
|
178
|
+
"DraftedSubmissions",
|
|
179
|
+
"transcribeDraftScannerElement",
|
|
180
|
+
{ draft_submission_token: request.draft_submission_token, image_urls: request.image_urls },
|
|
181
|
+
(response) =>
|
|
182
|
+
response.success
|
|
183
|
+
? reply.resolve({ fields: response.fields, submissionType: "ocr_transcription" })
|
|
184
|
+
: reply.reject(response.error),
|
|
185
|
+
() => reply.reject("Transcription failed."),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Because `reply` is just an object with `resolve`/`reject`, you can hand it straight to
|
|
191
|
+
whatever does the work and keep that code free of any SDK reference:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
webview.onTranscribeScannerRequest((request, reply) => myTranscriber(request, reply));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A handler that returns nothing is taken to own the reply, so the request stays open
|
|
198
|
+
until `reply` answers it — returning the fields and answering through `reply` are both
|
|
199
|
+
first-class, and the **first answer wins** (a second is ignored, with a dev warning).
|
|
200
|
+
`reply.answered` tells you whether one already landed. `reply.reject`'s text is shown
|
|
201
|
+
to the user verbatim, so pass display-ready messages.
|
|
202
|
+
|
|
203
|
+
If the reply happens somewhere with no access to the handler's scope — a global bus, a
|
|
204
|
+
different module holding only the id — the client also exposes the same two answers
|
|
205
|
+
directly, taking the request or its bare `request_id`:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
webview.resolveTranscribeScannerRequest(request, { fields, submissionType: "ocr_transcription" });
|
|
209
|
+
webview.rejectTranscribeScannerRequest(request.request_id, "Transcription failed.");
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Either way the result posts immediately, never sitting in the ready-queue, so a client
|
|
213
|
+
that attached after the form loaded still replies. Answering after `destroy()`, or once
|
|
214
|
+
the iframe is detached, is a silent no-op rather than a throw: by then nobody is
|
|
215
|
+
listening, and the form times its own button out.
|
|
216
|
+
|
|
106
217
|
## React — `@captello/ulc-webview-sdk/react`
|
|
107
218
|
|
|
108
219
|
The React adapter is the smoothest way to integrate, in two flavors:
|
|
@@ -440,9 +551,19 @@ new CaptelloWebview(iframe, {
|
|
|
440
551
|
hostWindow?: Window; // defaults to global window
|
|
441
552
|
matchSource?: boolean; // default true: only accept messages from this iframe
|
|
442
553
|
queueUntilReady?: boolean; // default true: buffer sends until form_load_complete
|
|
554
|
+
autoDestroy?: boolean; // default true: destroy() when the iframe leaves the DOM
|
|
443
555
|
});
|
|
444
556
|
```
|
|
445
557
|
|
|
558
|
+
Every `on*` method returns a subscription handle — an object with `unsubscribe()`,
|
|
559
|
+
which is also directly callable:
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
const sub = webview.on(OutboundMessageType.SubmissionBody, save);
|
|
563
|
+
sub.unsubscribe(); // preferred
|
|
564
|
+
sub(); // equivalent, for the older off() style
|
|
565
|
+
```
|
|
566
|
+
|
|
446
567
|
- `isReady` — `true` once the form has reported `form_load_complete`.
|
|
447
568
|
- `on(type, listener) => unsubscribe` — subscribe to one outbound type.
|
|
448
569
|
- `once(type, listener) => unsubscribe` — fire at most once.
|
|
@@ -450,8 +571,28 @@ new CaptelloWebview(iframe, {
|
|
|
450
571
|
- `submit()`, `reset()`, `updateDraft()`, `triggerValidation(target)` — inbound helpers.
|
|
451
572
|
- `submitAndWait(timeoutMs?)` — submit and await `submission_body` / `form_error_message` (see above).
|
|
452
573
|
- `prefill({ submission?, info? })` — pre-fill from a submission body, transcription items, or both.
|
|
574
|
+
- `onTranscribeScannerRequest(handler) => unsubscribe` — answer transcribe requests. The
|
|
575
|
+
handler gets `(request, reply)`: return the fields, or call `reply.resolve(...)` /
|
|
576
|
+
`reply.reject(...)` when callback-style work finishes (see
|
|
577
|
+
[above](#the-transcribe-button--host-side-processing)).
|
|
578
|
+
- `resolveTranscribeScannerRequest(request, data)` / `rejectTranscribeScannerRequest(request, error)`
|
|
579
|
+
— answer from outside the handler's scope. Takes the request or its bare `request_id`.
|
|
453
580
|
- `send(message)` — low-level escape hatch for any `InboundMessage`.
|
|
454
|
-
- `destroy()` — detach the listener and drop subscriptions (idempotent).
|
|
581
|
+
- `destroy()` — detach the listener and drop subscriptions (idempotent; also runs itself when the iframe is removed).
|
|
582
|
+
|
|
583
|
+
**The frame argument** (`FrameLike`) is the `<iframe>` element, or anything exposing a
|
|
584
|
+
`contentWindow`. It must already be mounted — the caller owns that ordering. See
|
|
585
|
+
[The client needs a mounted iframe](#the-client-needs-a-mounted-iframe).
|
|
586
|
+
|
|
587
|
+
**Automatic teardown.** With `autoDestroy` (default `true`), the client calls
|
|
588
|
+
`destroy()` on itself once the iframe leaves the document — including when an ancestor
|
|
589
|
+
is removed — so a torn-down panel can't leak the `message` listener. Set it to `false`
|
|
590
|
+
to manage the lifetime yourself.
|
|
591
|
+
|
|
592
|
+
**`targetOrigin` accepts a full URL**, not just a bare origin: pass the embed URL or your
|
|
593
|
+
webview base URL and the SDK reduces it with `new URL(value).origin`. This is the usual
|
|
594
|
+
reason a hand-rolled origin check gets commented out — a base URL never equals
|
|
595
|
+
`event.origin`.
|
|
455
596
|
|
|
456
597
|
**Send queueing.** With `queueUntilReady` (default `true`), any send before the webview
|
|
457
598
|
reports `form_load_complete` is buffered and flushed, in order, on load — so calling
|
|
@@ -506,6 +647,53 @@ internal listener before settling, including on timeout.
|
|
|
506
647
|
> call `waitForFormLoad` (e.g. you attach late), create a long-lived `CaptelloWebview`
|
|
507
648
|
> before the iframe navigates instead.
|
|
508
649
|
|
|
650
|
+
## Inside the Captello app — `@captello/ulc-webview-sdk/app`
|
|
651
|
+
|
|
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
|
+
|
|
657
|
+
```ts
|
|
658
|
+
import { CaptelloAppHost, ScannerError } from "@captello/ulc-webview-sdk/app";
|
|
659
|
+
|
|
660
|
+
const app = new CaptelloAppHost(); // listens on window, posts to window.parent
|
|
661
|
+
|
|
662
|
+
const token = await app.requestAuthToken(); // exchange it for a session, then:
|
|
663
|
+
app.notifyReady(); // the app hides its spinner
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
const people = await app.openScanner(); // resolves when the scanner closes
|
|
667
|
+
const [first] = people; // undefined if the user cancelled
|
|
668
|
+
if (first) fillForm(first.fields, first.badgeId);
|
|
669
|
+
} catch (e) {
|
|
670
|
+
if (e instanceof ScannerError) showToast(e.message);
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
`openScanner()` resolves with every person the scanner captured, in scan order — several
|
|
675
|
+
for a group scan. Each `ScannedPerson` is `{ badgeId, fields }`, where `fields` is a
|
|
676
|
+
`PrefillInfoItem[]` keyed by `ll_field_unique_identifier`, so it can be fed straight into a
|
|
677
|
+
capture form's `prefill({ info })`. A badge with no lookup data still arrives with its
|
|
678
|
+
`badgeId` and empty `fields`.
|
|
679
|
+
|
|
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.
|
|
684
|
+
|
|
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 |
|
|
693
|
+
|
|
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.
|
|
696
|
+
|
|
509
697
|
## Migrating an existing integration
|
|
510
698
|
|
|
511
699
|
Host apps that integrated before this SDK typically hand-rolled the same three pieces:
|
|
@@ -549,6 +737,137 @@ const body = await client.submitAndWait(); // throws SubmissionError on form_err
|
|
|
549
737
|
Hold the `CaptelloWebview` instance in a ref/context and call `submit()` / `reset()` /
|
|
550
738
|
`prefill()` instead of re-querying the DOM and stringifying messages by hand.
|
|
551
739
|
|
|
740
|
+
### Worked example: swapping in a global `message` listener
|
|
741
|
+
|
|
742
|
+
The common pre-SDK shape is one listener registered at page setup that JSON-parses every
|
|
743
|
+
event and dispatches on `type`. It swaps in wholesale — with one structural change: the
|
|
744
|
+
client is created where the iframe is created, rather than once at page setup, since it
|
|
745
|
+
needs a mounted iframe. In exchange it tears itself down with that iframe, so there is no
|
|
746
|
+
teardown to remember.
|
|
747
|
+
|
|
748
|
+
```js
|
|
749
|
+
// before — one global listener, manual parse, if/else chain
|
|
750
|
+
const messageListener = (event) => {
|
|
751
|
+
let dataParsed = {};
|
|
752
|
+
try {
|
|
753
|
+
dataParsed = JSON.parse(event.data);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (dataParsed.type === "form_error_message") {
|
|
758
|
+
show_error_message(dataParsed.data);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (dataParsed.type === "transcribe_scanner_request") {
|
|
762
|
+
ll_form_submits_manager.transcribe_draft_scanner_element(dataParsed);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (dataParsed.type === "submission_body") {
|
|
766
|
+
saveSubmission(dataParsed.data);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
window.addEventListener("message", messageListener);
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
```js
|
|
773
|
+
// after — created alongside the iframe; dies with it
|
|
774
|
+
const webview = new CaptelloSdk.CaptelloWebview(panelIframe, {
|
|
775
|
+
targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE, // a full URL is fine — reduced to its origin
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (msg) => show_error_message(msg.data));
|
|
779
|
+
|
|
780
|
+
webview.on(CaptelloSdk.OutboundMessageType.TranscribeScannerRequest, (request) =>
|
|
781
|
+
ll_form_submits_manager.transcribe_draft_scanner_element(request),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
webview.on(CaptelloSdk.OutboundMessageType.SubmissionBody, (msg) => saveSubmission(msg.data));
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
What you get by dropping the hand-rolled listener: the origin check is enforced (the
|
|
788
|
+
one usually commented out because the constant is a base URL, not a bare origin — the
|
|
789
|
+
SDK reduces it for you), non-Captello and malformed `postMessage` traffic from other
|
|
790
|
+
scripts on the page is filtered out instead of hitting your `try/catch`, unknown `type`
|
|
791
|
+
values are ignored, and `webview.destroy()` removes everything in one call.
|
|
792
|
+
|
|
793
|
+
Where the old code posted a reply by hand, use the client so it is stringified and
|
|
794
|
+
origin-targeted for you — `transcribe_draft_scanner_element` ends with:
|
|
795
|
+
|
|
796
|
+
```js
|
|
797
|
+
webview.resolveTranscribeScannerRequest(request, { fields, submissionType: "ocr_transcription" });
|
|
798
|
+
// …or, on failure:
|
|
799
|
+
webview.rejectTranscribeScannerRequest(request, "Transcription failed.");
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### `attach()` — no iframe reference needed
|
|
803
|
+
|
|
804
|
+
If your integration is a global `message` listener rather than something that owns the
|
|
805
|
+
iframe element, `attach()` is the whole thing. Call it once, register handlers, done —
|
|
806
|
+
nothing to query from the DOM, no ordering to get right relative to when the iframe is
|
|
807
|
+
created, and no teardown to remember:
|
|
808
|
+
|
|
809
|
+
```js
|
|
810
|
+
const webview = CaptelloSdk.attach({ targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE });
|
|
811
|
+
|
|
812
|
+
webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (m) => showError(m.data));
|
|
813
|
+
webview.on(CaptelloSdk.OutboundMessageType.SubmissionBody, (m) => save(m.data));
|
|
814
|
+
webview.onTranscribeScannerRequest((request, reply) => transcribe(request, reply));
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
**How it finds the form.** The first inbound message that clears the origin check _and_
|
|
818
|
+
parses as a well-formed Captello message identifies the webview. That sender becomes the
|
|
819
|
+
target for sends and the window every later message is source-matched against — so after
|
|
820
|
+
the first message this is exactly as strict as passing the iframe. With `targetOrigin`
|
|
821
|
+
set to the webview's origin, only the webview can ever be adopted; under the default
|
|
822
|
+
`"*"` that gate is absent, which is one more reason to set it.
|
|
823
|
+
|
|
824
|
+
**Teardown is still automatic.** A removed iframe's window reports `closed`, and `closed`
|
|
825
|
+
is readable cross-origin, so the client notices its form going away and destroys itself.
|
|
826
|
+
(Verified in Chromium against a genuinely cross-origin iframe.) Pass `autoDestroy: false`
|
|
827
|
+
to own the lifetime yourself.
|
|
828
|
+
|
|
829
|
+
**The one trade-off.** Until the form speaks once there is no window to post to, so an
|
|
830
|
+
unprompted `submit()` or `prefill()` before then has nowhere to go. With the default
|
|
831
|
+
`queueUntilReady` those sends are buffered and flushed on `form_load_complete`, which
|
|
832
|
+
covers the normal case. If you drive the form unprompted from page load _and_ can hold
|
|
833
|
+
the element, prefer the explicit form below.
|
|
834
|
+
|
|
835
|
+
### Or pass the iframe directly
|
|
836
|
+
|
|
837
|
+
Construct the client once the iframe is in the DOM — the caller owns that ordering. The
|
|
838
|
+
client source-matches every inbound message against the iframe's `contentWindow` from
|
|
839
|
+
the very first one, and posts straight to it.
|
|
840
|
+
|
|
841
|
+
If your old listener was registered at page setup, before the panel's iframe existed,
|
|
842
|
+
move client creation to the moment you create the iframe:
|
|
843
|
+
|
|
844
|
+
```js
|
|
845
|
+
// when the panel opens
|
|
846
|
+
var panelIframe = document.createElement("iframe");
|
|
847
|
+
panelIframe.src = captureUrl;
|
|
848
|
+
panel.appendChild(panelIframe);
|
|
849
|
+
|
|
850
|
+
var webview = new CaptelloSdk.CaptelloWebview(panelIframe, { targetOrigin: BASE });
|
|
851
|
+
webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, showError);
|
|
852
|
+
// ...
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**You do not need a matching `destroy()`.** The client watches for its iframe leaving
|
|
856
|
+
the document and tears itself down when it does — removing the `message` listener and
|
|
857
|
+
dropping every subscription. An ancestor being removed counts, which is the usual case:
|
|
858
|
+
emptying a panel or modal takes the iframe with it, and the client goes too. Calling
|
|
859
|
+
`destroy()` yourself still works and is idempotent, so belt-and-braces teardown is fine.
|
|
860
|
+
|
|
861
|
+
Pass `autoDestroy: false` to own the lifetime entirely — e.g. if you deliberately detach
|
|
862
|
+
and re-insert the same iframe element and want the client to survive it. Detection uses
|
|
863
|
+
`MutationObserver`, so with a `{ contentWindow }` stand-in or in a non-DOM environment
|
|
864
|
+
it is simply inert.
|
|
865
|
+
|
|
866
|
+
If you'd rather keep your own listener for now, `parseOutboundMessage(event.data)` is
|
|
867
|
+
exported on its own: it replaces the `try { JSON.parse } catch` guard and returns a
|
|
868
|
+
typed message (or `null` for anything that isn't ours), so you can adopt the protocol
|
|
869
|
+
types without restructuring anything.
|
|
870
|
+
|
|
552
871
|
**Prefill notes for migrators.** `prefill({ info })` items are matched by
|
|
553
872
|
`ll_field_unique_identifier`; `ll_field_id` is optional (number or string) and `value`
|
|
554
873
|
may be a string or boolean — so existing payloads with numeric ids and boolean values
|
|
@@ -563,6 +882,27 @@ _its_ parent via `window.parent.postMessage` (e.g. relaying `email`/`clientId`,
|
|
|
563
882
|
`NAVIGATE_BACK` / scanner messages), that is a separate channel from the capture-form
|
|
564
883
|
contract — keep that code; this SDK only models host ↔ capture-webview messaging.
|
|
565
884
|
|
|
885
|
+
## Testing
|
|
886
|
+
|
|
887
|
+
Two layers, deliberately split:
|
|
888
|
+
|
|
889
|
+
- **`pnpm test`** — vitest unit tests against a fake window. Fast, covers the client's
|
|
890
|
+
logic, queueing, and every branch of the message protocol.
|
|
891
|
+
- **`pnpm e2e`** — Playwright browser tests that load the **built IIFE bundle** into a real
|
|
892
|
+
page and talk to a real iframe on a **different origin** (`localhost` and `127.0.0.1` on
|
|
893
|
+
one server). This is the only layer that can exercise genuine `postMessage` semantics:
|
|
894
|
+
origin filtering, `event.source` identity, and a removed iframe's `closed` flag — jsdom
|
|
895
|
+
reports `closed` as `undefined`, so `attach()`'s teardown is unprovable without a browser.
|
|
896
|
+
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.
|
|
899
|
+
- **`e2e/sdk-integration.spec.ts` in the app repo** (`pnpm exec playwright test e2e/sdk-integration.spec.ts`)
|
|
900
|
+
— the SDK against the **real webview**, cross-origin. This is the layer that catches
|
|
901
|
+
protocol drift between the two, because neither side is a stub: it waits for a real
|
|
902
|
+
`form_load_complete`, prefills and asserts the values land in real form inputs, and runs
|
|
903
|
+
`submitAndWait()` through to a real `submission_body` (and, separately, to a real
|
|
904
|
+
validation failure surfacing as `SubmissionError`).
|
|
905
|
+
|
|
566
906
|
## Development
|
|
567
907
|
|
|
568
908
|
```bash
|