@arkyc/widget 1.2.10 → 2.0.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.
@@ -1 +1 @@
1
- {"version":3,"file":"controller.mjs","names":[],"sources":["../src/controller.ts"],"sourcesContent":["import type {\n DocumentType,\n LivenessChallenge,\n LivenessMode,\n SessionTransitionEvent,\n VerificationStatus,\n WidgetResult,\n WidgetStep,\n WorkflowConfig,\n} from '@arkyc/types'\nimport { REALTIME_EVENT, workflowAddressConfig } from '@arkyc/types'\nimport { ArkycClient, type ClientHandoff, type ClientRealtime, type ClientSession, WidgetApiError } from './client'\nimport { Flow } from './flow'\nimport { Theme } from './theme'\nimport { WidgetView, type AddressFormData } from './ui'\nimport { isDesktopDevice } from './device'\nimport { renderQrSvg } from './qr'\nimport { createWidgetRealtimeClient, type WidgetRealtimeClient } from './realtime'\nimport type { BaseWidgetOptions, ViewHandlers, WidgetControllerConfig, WidgetEventListener } from './types'\n\n/** Per-step lead-in copy shown before each capture/liveness step. */\nconst STEP_INSTRUCTIONS: Partial<Record<WidgetStep, { title: string; body: string; cta?: string }>> = {\n front_capture: {\n title: 'Front of your document',\n body: 'Place the front of your ID flat and fully inside the frame. We’ll capture it automatically.',\n },\n back_capture: {\n title: 'Back of your document',\n body: 'Now turn your ID over and show the back, fully inside the frame.',\n },\n selfie_capture: {\n title: 'Take a selfie',\n body: 'Look straight at the camera in good light and hold still — we’ll capture it automatically.',\n },\n active_liveness: {\n title: 'Quick liveness check',\n body: 'Follow the on-screen prompts (such as turn your head, blink or smile). This confirms you’re really here.',\n },\n}\n\n/**\n * Drives the full verification flow: renders each screen via {@link WidgetView},\n * advances through {@link flow}, and calls the Client API at each step. Cosmetic\n * processing screens auto-advance; capture screens wait for an image. On a\n * terminal result it surfaces the outcome and (in hosted mode) posts\n * `arkyc:complete` / `arkyc:error` / `arkyc:close` to the parent window.\n */\n/**\n * Re-base the server-resolved project logo onto the API origin the widget is\n * actually talking to. The stored `logo_url` carries the API's own host, which a\n * handed-off phone cannot reach — it reaches the API through the absolute\n * `baseUrl` passed in the handoff link. Rewriting the origin (keeping the path)\n * makes the logo load on whatever device runs the widget. Only applied when\n * `baseUrl` is absolute; the integrator's own branding is never re-based.\n */\nfunction rebaseBrandingLogo<T extends { logo_url?: string | null }>(branding: T, baseUrl: string | undefined): T {\n const logo = branding.logo_url\n if (!logo || !baseUrl || !/^https?:\\/\\//i.test(baseUrl)) return branding\n try {\n const base = new URL(baseUrl)\n const resolved = new URL(logo, base)\n if (resolved.origin === base.origin) return branding\n return { ...branding, logo_url: `${base.origin}${resolved.pathname}${resolved.search}${resolved.hash}` }\n } catch {\n return branding\n }\n}\n\nexport class WidgetController {\n private readonly client: ArkycClient\n private readonly view: WidgetView\n private readonly win: Window\n private readonly nav: Navigator\n private readonly postToParent: boolean\n private readonly scheduler: (fn: () => void, ms: number) => void\n private readonly transientMs: number\n private readonly pollMs: number\n private readonly maxPolls: number\n private readonly maxHandoffPolls: number\n\n /** Cross-device handoff: project config + whether the offer/QR is showing. */\n private handoffConfig: ClientHandoff = { enabled: false, allow_desktop: true, url: '' }\n private handoffReady = false\n private handoffActive = false\n\n /** This session's id + how to watch it live (push transport vs. polling). */\n private sessionId = ''\n private realtimeConfig: ClientRealtime | null = null\n private rtClient: WidgetRealtimeClient | null = null\n private rtResolved = false\n /** The last status observed, so a transition only emits once per change. */\n private lastStatus: VerificationStatus | null = null\n /** Per-name event listeners registered via {@link on}. */\n private listeners = new Map<string, Set<WidgetEventListener>>()\n /** Cancels the active session watch (e.g. continue-on-this-device). */\n private stopWatch: (() => void) | undefined\n\n private step: WidgetStep = 'welcome'\n private documentType: DocumentType | null = null\n private country: string | null = null\n private selfie: Blob | null = null\n private livenessMode: LivenessMode = 'passive'\n /** The session's capture model; `active` mandates a live camera (no fallback). */\n private captureModel: 'passive' | 'active' | 'both' = 'passive'\n private livenessChallenges: LivenessChallenge[] = []\n /** The address-entry form data, captured before the address submission. */\n private addressData: AddressFormData | null = null\n /** Device coordinates resolved on the address step (when the user opts in). */\n private addressCoords: { latitude: number; longitude: number } | null = null\n /** The applied workflow (orders/toggles stages, skip_ocr), or null for the default flow. */\n private workflow: WorkflowConfig | null = null\n private result: WidgetResult | null = null\n /** The session was already terminal when the widget loaded (show a close-only notice). */\n private terminalOnLoad = false\n /** Whether the current step's instruction interstitial has been acknowledged. */\n private instructionAcked = false\n private pendingError: Error | null = null\n private settled = false\n\n constructor(private readonly config: WidgetControllerConfig) {\n const doc = config.doc ?? globalThis.document\n const win = config.win ?? globalThis.window\n if (!doc || !win) throw new Error('The Arkyc widget must run in a browser environment.')\n this.win = win\n this.nav = config.nav ?? globalThis.navigator\n\n this.client = new ArkycClient({\n token: config.token,\n baseUrl: config.baseUrl,\n fetch: config.fetch,\n })\n const theme = new Theme(config.branding)\n this.view = new WidgetView(\n doc,\n theme,\n this.handlers(),\n config.nav ?? globalThis.navigator,\n config.faceAnalyzer,\n config.faceTuning,\n config.documentAnalyzer,\n config.documentTuning,\n )\n\n this.postToParent = config.postToParent ?? (!!win.parent && win.parent !== win)\n this.scheduler = config.scheduler ?? ((fn, ms) => setTimeout(fn, ms))\n this.transientMs = config.transientMs ?? 700\n this.pollMs = config.pollMs ?? 1500\n this.maxPolls = config.maxPolls ?? 40\n // A handed-off session is bounded by its TTL (~15 min); poll generously.\n this.maxHandoffPolls = config.maxHandoffPolls ?? 600\n }\n\n /** The widget's root element — append it to an overlay or inline container. */\n get element(): HTMLElement {\n return this.view.element\n }\n\n /** Connect to the session, then route to the result, the QR, or the welcome flow. */\n start(): void {\n this.view.renderLoading()\n void this.run(() => this.bootstrap())\n }\n\n /** Tear down the view and release the camera (does not fire callbacks). */\n destroy(): void {\n this.view.destroy()\n }\n\n /** Close the widget as if the user dismissed it (fires `onClose`/`onSettle`). */\n close(): void {\n this.finishClose()\n }\n\n /**\n * Subscribe to a named widget event; returns an unsubscribe function. Having a\n * listener (here or via `onEvent`) is what activates the event stream.\n */\n on(event: string, listener: WidgetEventListener): () => void {\n const set = this.listeners.get(event) ?? new Set<WidgetEventListener>()\n set.add(listener)\n this.listeners.set(event, set)\n\n return () => set.delete(listener)\n }\n\n /** Whether anyone is listening for `name` (the firehose or a named listener). */\n private hasListener(name: string): boolean {\n if (this.config.onEvent) return true\n const set = this.listeners.get(name)\n\n return !!set && set.size > 0\n }\n\n /**\n * Surface an event: to local listeners (firehose + `on`), and — in hosted\n * iframe mode — to the embedding parent as `arkyc:event` so the SDK's\n * `handle.on(...)` works across the frame.\n */\n private dispatch(name: string, data?: unknown): void {\n this.emit(name, data)\n // The parent window is the consumer in iframe mode, so forwarding there isn't\n // gated on a local listener (post() already no-ops when not embedding).\n this.post('arkyc:event', { name, data })\n }\n\n /** Emit an event to the firehose + named listeners — but only if one is active. */\n private emit(name: string, data?: unknown): void {\n if (!this.hasListener(name)) return\n try {\n this.config.onEvent?.({ name, data })\n } catch {\n // A consumer callback threw — never let it break the flow.\n }\n const set = this.listeners.get(name)\n if (set) {\n for (const listener of [...set]) {\n try {\n listener(data)\n } catch {\n // ditto\n }\n }\n }\n }\n\n /** Record an observed status, emitting `session.transition` on a real change. */\n private observe(status: VerificationStatus): void {\n if (status === this.lastStatus) return\n const previous = this.lastStatus\n this.lastStatus = status\n this.dispatch(REALTIME_EVENT.sessionTransition, {\n session_id: this.sessionId,\n status,\n previous_status: previous,\n } satisfies Partial<SessionTransitionEvent>)\n }\n\n /**\n * Lazily connect the push transport for this session (once). Returns null for\n * `polling`/`off`/`memory` or when the transport SDK can't load — the caller\n * then polls instead.\n */\n private async resolveRealtime(): Promise<WidgetRealtimeClient | null> {\n if (this.rtResolved) return this.rtClient\n this.rtResolved = true\n const cfg = this.realtimeConfig\n if (!cfg || cfg.transport === 'polling' || cfg.transport === 'off' || cfg.transport === 'memory') return null\n\n const factory = this.config.realtimeFactory ?? createWidgetRealtimeClient\n // Build from the client's *resolved* base (absolute/proxy value, or the\n // hosted default when `baseUrl` was omitted) — never the raw option, which\n // is empty in the zero-config case and would resolve against the page origin.\n const apiBase = this.client.base\n try {\n this.rtClient = await factory(cfg, {\n authEndpoint: `${apiBase}/realtime/auth`,\n token: this.config.token,\n })\n } catch {\n this.rtClient = null\n }\n\n return this.rtClient\n }\n\n private handlers(): ViewHandlers {\n return {\n onClose: () => this.finishClose(),\n // The session was already fetched + resolved during bootstrap; just advance.\n onStart: () => void this.run(() => this.enter(this.next())),\n onDocumentSelected: (type, country) => {\n this.documentType = type\n this.country = country || null\n void this.run(() => this.enter('front_capture'))\n },\n onImage: (blob) => void this.run(() => this.onImage(blob)),\n onActiveLiveness: (video, performed, selfie) =>\n void this.run(async () => {\n await this.client.submitLiveness({\n selfie,\n video,\n mode: 'active',\n challenges: performed,\n signals: this.config.signals,\n })\n await this.enter(this.next())\n }),\n onAddress: (data) => void this.run(() => this.onAddress(data)),\n onShareLocation: () => this.shareLocation(),\n onAcknowledge: () => this.finishResult(),\n onUsePhone: () => void this.run(() => this.startHandoff()),\n onContinueHere: () => {\n this.handoffActive = false\n this.stopWatch?.()\n this.step = 'welcome'\n this.render()\n },\n }\n }\n\n /**\n * Fetch the session and route: an already-terminal session (e.g. a stale handoff\n * link, or one finished on another device) shows a close-only notice; on a\n * desktop with handoff enabled we lead with the QR; otherwise the welcome flow.\n */\n private async bootstrap(): Promise<void> {\n if (this.settled) return\n const session = await this.client.getSession()\n if (this.settled) return\n this.sessionId = session.id\n this.realtimeConfig = session.realtime ?? null\n this.workflow = session.workflow ?? null\n // Theme from the project's branding (server-resolved) unless the integrator\n // passed branding explicitly — their value wins.\n if (this.config.branding == null && session.branding)\n this.view.applyBranding(rebaseBrandingLogo(session.branding, this.config.baseUrl))\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) return this.showTerminal(session.status)\n this.resolveLiveness(session)\n if (session.handoff) this.handoffConfig = session.handoff\n const canHandoff =\n this.config.handoff !== false && isDesktopDevice(this.nav) && this.handoffConfig.enabled && !!this.handoffTarget()\n if (canHandoff) {\n this.handoffReady = true\n await this.startHandoff()\n } else {\n this.step = 'welcome'\n this.render()\n }\n }\n\n /** The hosted handoff page URL: a consumer override, else the server-provided one. */\n private handoffTarget(): string {\n return (this.config.handoffUrl ?? this.handoffConfig.url ?? '').trim()\n }\n\n /** Render the QR for this session and wait for the other device to finish. */\n private async startHandoff(): Promise<void> {\n const target = this.handoffTarget()\n if (!target) return\n this.handoffActive = true\n // Offer \"continue on this device\" only when the project permits it.\n this.view.renderHandoff(renderQrSvg(this.buildHandoffUrl(target)), this.handoffConfig.allow_desktop)\n await this.pollHandoff()\n }\n\n /** Build the hosted-page URL the QR encodes (carries the session token).\n *\n * @param target\n * @returns\n */\n private buildHandoffUrl(target: string): string {\n const pageOrigin = this.win.location?.origin ?? globalThis.location?.origin ?? ''\n return composeHandoffUrl(target, this.config.token, this.client.base, pageOrigin)\n }\n\n /**\n * Watch the session while the user verifies on the other\n * device; mirror the result.\n *\n * @returns\n */\n private async pollHandoff(): Promise<void> {\n const rt = await this.resolveRealtime()\n if (rt) {\n const status = await this.pushWatch(rt, this.maxHandoffPolls, () => this.handoffActive && !this.settled)\n if (status) {\n this.handoffActive = false\n return this.showResult(status)\n }\n\n return // cancelled (continue here) or budget elapsed → the QR persists\n }\n\n let errors = 0\n for (let i = 0; i < this.maxHandoffPolls && this.handoffActive && !this.settled; i++) {\n await this.delay(this.pollMs)\n if (!this.handoffActive || this.settled) return\n try {\n const session = await this.client.getSession()\n errors = 0\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) {\n this.handoffActive = false\n return this.showResult(session.status)\n }\n } catch (error) {\n // The session/token can expire mid-wait (401) — stop and reflect that.\n if (error instanceof WidgetApiError && error.status === 401) {\n this.handoffActive = false\n return this.showResult('expired')\n }\n if (++errors >= 5) return\n }\n }\n }\n\n /**\n * Await a terminal status over the push transport. Resolves to the terminal\n * status, or null if `active()` went false or the tick budget elapsed (push can\n * miss events / disconnect; the budget bounds the wait). An initial fetch\n * catches a session that finished before we subscribed.\n *\n * @param rt\n * @param budget\n * @param active\n * @returns\n */\n private pushWatch(\n rt: WidgetRealtimeClient,\n budget: number,\n active: () => boolean,\n ): Promise<VerificationStatus | null> {\n return new Promise((resolve) => {\n let done = false\n const finish = (status: VerificationStatus | null) => {\n if (done) return\n done = true\n this.stopWatch = undefined\n unsubscribe()\n resolve(status)\n }\n const channel = this.realtimeConfig!.channel\n const unsubscribe = rt.subscribe(channel, (event, data) => {\n if (event !== REALTIME_EVENT.sessionTransition) return\n const status = (data as SessionTransitionEvent).status\n this.observe(status)\n if (Flow.isTerminal(status)) finish(status)\n })\n this.stopWatch = () => finish(null)\n // Catch a race where the session already finished before we subscribed.\n void this.client\n .getSession()\n .then((session) => {\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) finish(session.status)\n })\n .catch((error) => {\n if (error instanceof WidgetApiError && error.status === 401) finish('expired')\n })\n // Bound the wait by the same tick budget the poller would use.\n const countdown = async (n: number): Promise<void> => {\n if (done) return\n if (!active() || n >= budget) return finish(null)\n await this.delay(this.pollMs)\n void countdown(n + 1)\n }\n void countdown(0)\n })\n }\n\n /**\n * Resolve which liveness flow to run from the session's capture model.\n *\n * @param session\n */\n private resolveLiveness(session: ClientSession): void {\n this.livenessChallenges = session.liveness_challenges ?? []\n const model = session.capture_model ?? 'passive'\n this.captureModel = model\n const wantsActive = model === 'active' || model === 'both'\n // `active` is honoured even without a live camera — the active-liveness\n // screen then shows the device as unsupported (no fallback). `both` falls\n // back to passive when the camera/recorder isn't available.\n this.livenessMode = wantsActive && (model === 'active' || this.view.cameraSupported) ? 'active' : 'passive'\n }\n\n private async onImage(blob: Blob | null): Promise<void> {\n const signals = this.config.signals\n switch (this.step) {\n case 'front_capture':\n await this.client.submitDocumentFront({\n image: blob,\n country: this.country,\n documentType: this.documentType,\n signals,\n })\n return this.enter(this.next())\n case 'back_capture':\n await this.client.submitDocumentBack({\n image: blob,\n country: this.country,\n documentType: this.documentType,\n })\n return this.enter('ocr_processing')\n case 'selfie_capture':\n this.selfie = blob\n return this.enter('passive_liveness')\n }\n }\n\n /**\n * Enter a step: render it, and drive any automatic (processing) work.\n *\n * @param step\n * @returns\n */\n private async enter(step: WidgetStep): Promise<void> {\n if (this.settled) return\n this.step = step\n\n // Lead each capture/liveness step with an instruction screen so the user\n // knows what's next and starts it deliberately. The Continue button re-enters\n // the same step with the instruction acknowledged.\n const instruction = STEP_INSTRUCTIONS[step]\n if (instruction && !this.instructionAcked) {\n this.view.renderInstruction(step, instruction.title, instruction.body, instruction.cta ?? 'Continue', () => {\n this.instructionAcked = true\n void this.run(() => this.enter(step))\n })\n return\n }\n this.instructionAcked = false\n this.render()\n\n switch (step) {\n case 'ocr_processing':\n await this.delay(this.transientMs)\n return this.enter(this.next())\n case 'address_processing':\n await this.submitAddress()\n return this.enter(this.next())\n case 'passive_liveness':\n await this.client.submitLiveness({ selfie: this.selfie, signals: this.config.signals })\n return this.enter(this.next())\n case 'face_match':\n await this.client.complete({ signals: this.config.signals })\n return this.enter(this.next())\n case 'processing':\n return this.poll()\n }\n }\n\n /** Watch the session until it reaches a terminal status (then show the result).\n *\n * @returns\n */\n private async poll(): Promise<void> {\n const rt = await this.resolveRealtime()\n if (rt) {\n const status = await this.pushWatch(rt, this.maxPolls, () => !this.settled)\n\n return this.showResult(status ?? 'requires_review')\n }\n\n for (let i = 0; i < this.maxPolls && !this.settled; i++) {\n const session = await this.client.getSession()\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) return this.showResult(session.status)\n await this.delay(this.pollMs)\n }\n // Still processing after the poll budget — surface as pending review.\n this.showResult('requires_review')\n }\n\n private showResult(status: VerificationStatus): void {\n this.result = { status, decision: Flow.statusToDecision(status) }\n this.step = 'result'\n this.render()\n }\n\n /**\n * A session that was already terminal on load: a notice with only a Close button.\n *\n * @param status\n */\n private showTerminal(status: VerificationStatus): void {\n this.result = { status, decision: Flow.statusToDecision(status) }\n this.step = 'result'\n this.terminalOnLoad = true\n this.render()\n }\n\n private render(): void {\n this.view.render({\n step: this.step,\n documentType: this.documentType,\n decision: this.result?.decision,\n status: this.result?.status,\n terminalNotice: this.terminalOnLoad,\n allowSkip: !!this.config.signals,\n livenessChallenges: this.livenessChallenges,\n requireLiveCamera: this.captureModel === 'active',\n // The active flow is strict end-to-end: documents must be detected, not\n // manually waved through.\n strictCapture: this.livenessMode === 'active',\n handoffAvailable: this.handoffReady,\n addressMethods: workflowAddressConfig(this.workflow)?.methods ?? [],\n })\n }\n\n /**\n * Store the entered address, resolve device location if opted in, then verify.\n *\n * @param data\n * @returns\n */\n private async onAddress(data: AddressFormData): Promise<void> {\n this.addressData = data\n // Coordinates are normally resolved up front by `shareLocation` when the user\n // ticks \"use my location\" (so the prompt is immediate); geolocate here only as\n // a fallback, and clear them if they opted out.\n if (!data.useLocation) this.addressCoords = null\n else if (!this.addressCoords) this.addressCoords = await this.geolocate()\n\n return this.enter('address_processing')\n }\n\n /** Request the device's location (from the address-entry \"use my location\" opt-in). */\n private async shareLocation(): Promise<boolean> {\n this.addressCoords = await this.geolocate()\n\n return !!this.addressCoords\n }\n\n /**\n * Submit the captured address evidence to the Client API.\n *\n * @returns\n */\n private async submitAddress(): Promise<void> {\n const data = this.addressData\n if (!data) return\n\n await this.client.submitAddress({\n line1: data.line1,\n line2: data.line2,\n city: data.city,\n region: data.region,\n postalCode: data.postalCode,\n country: data.country,\n poa: data.poa,\n latitude: this.addressCoords?.latitude ?? null,\n longitude: this.addressCoords?.longitude ?? null,\n signals: this.config.signals,\n })\n }\n\n /**\n * Resolve the device's coordinates via the Geolocation API. Resolves to `null`\n * on denial/unavailability/timeout — the `device_location` method then simply\n * has no coordinates to check (routing the session to review).\n *\n * @returns\n */\n private geolocate(): Promise<{ latitude: number; longitude: number } | null> {\n const geo = this.nav?.geolocation\n if (!geo) return Promise.resolve(null)\n\n return new Promise((resolve) => {\n geo.getCurrentPosition(\n (pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),\n () => resolve(null),\n { enableHighAccuracy: false, timeout: 10_000, maximumAge: 60_000 },\n )\n })\n }\n\n private next(): WidgetStep {\n return Flow.nextStep(this.step, {\n documentType: this.documentType,\n livenessMode: this.livenessMode,\n workflow: this.workflow,\n })\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => this.scheduler(resolve, ms))\n }\n\n /** Run an async step, routing any failure to the error screen. */\n private async run(fn: () => Promise<void> | void): Promise<void> {\n try {\n await fn()\n } catch (error) {\n this.fail(error)\n }\n }\n\n private fail(error: unknown): void {\n // Notify once, on the first failure. `pendingError` is the guard so a repeat\n // (or a later close) doesn't re-fire.\n if (this.settled || this.pendingError) return\n const err = error instanceof Error ? error : new Error(String(error))\n this.pendingError = err\n this.step = 'result'\n\n this.view.render({\n step: 'result',\n documentType: this.documentType,\n errorMessage: err.message,\n })\n\n // Surface the failure to the integrator immediately, not when the user\n // eventually dismisses the screen (they may never), so an expired session or\n // token is knowable programmatically. `dispatch` also forwards a non-closing\n // `error` event to an embedding parent; the terminal `arkyc:error` (which\n // tears the overlay down) is posted from `finishResult` on dismissal.\n this.dispatch('error', this.errorData(err))\n this.config.onError?.(err)\n }\n\n /** Event payload for a failure: the message plus the stable key/status when it's a typed API error. */\n private errorData(err: Error): { message: string; status?: number; error?: string } {\n if (err instanceof WidgetApiError) return { message: err.message, status: err.status, error: err.error }\n\n return { message: err.message }\n }\n\n private finishResult(): void {\n if (this.settled) return\n this.settled = true\n this.teardownRealtime()\n\n if (this.pendingError) {\n // `onError` + the `error` event already fired at failure time; only relay\n // the terminal error to an embedding parent (closing its overlay) here.\n this.post('arkyc:error', { error: serializeError(this.pendingError) })\n } else if (this.result) {\n this.post('arkyc:complete', { payload: this.result })\n this.dispatch('complete', this.result)\n this.config.onComplete?.(this.result)\n }\n\n this.destroy()\n this.config.onSettle?.()\n }\n\n private finishClose(): void {\n if (this.settled) return\n\n this.settled = true\n this.teardownRealtime()\n this.post('arkyc:close', {})\n this.dispatch('close')\n this.config.onClose?.()\n this.destroy()\n this.config.onSettle?.()\n }\n\n /** Stop any active session watch + disconnect the push transport. */\n private teardownRealtime(): void {\n this.stopWatch?.()\n this.rtClient?.disconnect()\n this.rtClient = null\n }\n\n private post(type: string, extra: Record<string, unknown>): void {\n if (!this.postToParent) return\n try {\n this.win.parent?.postMessage({ type, ...extra }, '*')\n } catch {\n // Cross-origin parent without access — nothing more we can do.\n }\n }\n}\n\nfunction serializeError(error: Error): { message: string; name: string } {\n return { message: error.message, name: error.name }\n}\n\n/** The origin of an absolute URL, or '' when it can't be parsed. */\nfunction originOf(url: string): string {\n try {\n return new URL(url).origin\n } catch {\n return ''\n }\n}\n\n/**\n * Build the hosted-page URL the handoff QR encodes: always the `target` page\n * plus the session `token`, and the API base **only when the phone can actually\n * reach it** — i.e. a genuine cross-origin base. `apiBase` is the widget's\n * resolved Client API base (always origin-qualified), so a relative proxy\n * resolves to the desktop's own `pageOrigin` and is skipped (the hosted page\n * then falls back to its own default base instead of a URL the phone can't use).\n */\nexport const composeHandoffUrl = (target: string, token: string, apiBase: string, pageOrigin: string): string => {\n const sep = target.includes('?') ? '&' : '?'\n let url = `${target}${sep}token=${encodeURIComponent(token)}`\n const baseOrigin = originOf(apiBase)\n if (baseOrigin && baseOrigin !== pageOrigin) url += `&baseUrl=${encodeURIComponent(apiBase)}`\n return url\n}\n\nexport const resolveContainer = (container: string | HTMLElement, doc: Document): HTMLElement => {\n const el = typeof container === 'string' ? doc.querySelector<HTMLElement>(container) : container\n if (!el) throw new Error(`ArkycWidget.mount: container \"${String(container)}\" not found.`)\n return el\n}\n\n/**\n * Make an inline mount point a reliable host for the widget, so `mount()` renders\n * predictably regardless of the page layout — a static or zero-height container,\n * or one sharing the viewport with an app root that would otherwise paint over it.\n *\n * The widget gives the host its own stacking context and lifts it above sibling\n * app content, the way `open()` owns its overlay. Each property is only set when\n * the host hasn't already committed to one (checked against computed style where\n * available, else inline style), so an embed that wants normal flow or a specific\n * layer can still override it with its own CSS.\n */\nexport const adoptContainer = (container: HTMLElement, doc: Document): void => {\n const computed = doc.defaultView?.getComputedStyle?.(container)\n const position = computed?.position ?? container.style.position\n if (!position || position === 'static') container.style.position = 'relative'\n\n const zIndex = computed?.zIndex ?? container.style.zIndex\n if (!zIndex || zIndex === 'auto') container.style.zIndex = '1'\n\n // A self-contained stacking context keeps the widget's layers from bleeding\n // into (or being clipped by) the surrounding page.\n if (!container.style.isolation) container.style.isolation = 'isolate'\n}\n\nexport const buildController = (options: BaseWidgetOptions, onSettle: () => void): WidgetController => {\n if (!options.token) throw new Error('ArkycWidget requires a client `token`.')\n return new WidgetController({\n token: options.token,\n baseUrl: options.baseUrl,\n handoff: options.handoff,\n handoffUrl: options.handoffUrl,\n branding: options.branding,\n signals: options.signals,\n onComplete: options.onComplete,\n onError: options.onError,\n onClose: options.onClose,\n onEvent: options.onEvent,\n onSettle,\n fetch: options.fetch,\n doc: options.doc,\n win: options.win,\n nav: options.nav,\n faceAnalyzer: options.faceAnalyzer,\n faceTuning: options.faceTuning,\n documentAnalyzer: options.documentAnalyzer,\n documentTuning: options.documentTuning,\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"controller.mjs","names":[],"sources":["../src/controller.ts"],"sourcesContent":["import type {\n DocumentType,\n LivenessChallenge,\n LivenessMode,\n SessionTransitionEvent,\n VerificationStatus,\n WidgetResult,\n WidgetStep,\n WorkflowConfig,\n} from '@arkyc/types'\nimport { REALTIME_EVENT, workflowAddressConfig } from '@arkyc/types'\nimport { ArkycClient, type ClientHandoff, type ClientRealtime, type ClientSession, WidgetApiError } from './client'\nimport { Flow } from './flow'\nimport { Theme } from './theme'\nimport { WidgetView, type AddressFormData } from './ui'\nimport { isDesktopDevice } from './device'\nimport { renderQrSvg } from './qr'\nimport { createWidgetRealtimeClient, type WidgetRealtimeClient } from './realtime'\nimport type {\n BaseWidgetOptions,\n ViewHandlers,\n WidgetControllerConfig,\n WidgetEvent,\n WidgetEventListener,\n WidgetEventMap,\n WidgetEventName,\n WidgetSessionTransitionData,\n} from './types'\n\n/** Per-step lead-in copy shown before each capture/liveness step. */\nconst STEP_INSTRUCTIONS: Partial<Record<WidgetStep, { title: string; body: string; cta?: string }>> = {\n front_capture: {\n title: 'Front of your document',\n body: 'Place the front of your ID flat and fully inside the frame. We’ll capture it automatically.',\n },\n back_capture: {\n title: 'Back of your document',\n body: 'Now turn your ID over and show the back, fully inside the frame.',\n },\n selfie_capture: {\n title: 'Take a selfie',\n body: 'Look straight at the camera in good light and hold still — we’ll capture it automatically.',\n },\n active_liveness: {\n title: 'Quick liveness check',\n body: 'Follow the on-screen prompts (such as turn your head, blink or smile). This confirms you’re really here.',\n },\n}\n\n/**\n * Drives the full verification flow: renders each screen via {@link WidgetView},\n * advances through {@link flow}, and calls the Client API at each step. Cosmetic\n * processing screens auto-advance; capture screens wait for an image. On a\n * terminal result it surfaces the outcome and (in hosted mode) posts\n * `arkyc:complete` / `arkyc:error` / `arkyc:close` to the parent window.\n */\n/**\n * Re-base the server-resolved project logo onto the API origin the widget is\n * actually talking to. The stored `logo_url` carries the API's own host, which a\n * handed-off phone cannot reach — it reaches the API through the absolute\n * `baseUrl` passed in the handoff link. Rewriting the origin (keeping the path)\n * makes the logo load on whatever device runs the widget. Only applied when\n * `baseUrl` is absolute; the integrator's own branding is never re-based, and\n * neither is a presigned object-storage URL (S3/R2/GCS), whose signature is only\n * valid on its own host.\n */\nfunction rebaseBrandingLogo<T extends { logo_url?: string | null }>(branding: T, baseUrl: string | undefined): T {\n const logo = branding.logo_url\n if (!logo || !baseUrl || !/^https?:\\/\\//i.test(baseUrl)) return branding\n try {\n const base = new URL(baseUrl)\n const resolved = new URL(logo, base)\n if (resolved.origin === base.origin) return branding\n if (/[?&]x-(amz|goog)-signature=/i.test(resolved.search)) return branding\n return { ...branding, logo_url: `${base.origin}${resolved.pathname}${resolved.search}${resolved.hash}` }\n } catch {\n return branding\n }\n}\n\nexport class WidgetController {\n private readonly client: ArkycClient\n private readonly view: WidgetView\n private readonly win: Window\n private readonly nav: Navigator\n private readonly postToParent: boolean\n private readonly scheduler: (fn: () => void, ms: number) => void\n private readonly transientMs: number\n private readonly pollMs: number\n private readonly maxPolls: number\n private readonly maxHandoffPolls: number\n\n /** Cross-device handoff: project config + whether the offer/QR is showing. */\n private handoffConfig: ClientHandoff = { enabled: false, allow_desktop: true, url: '' }\n private handoffReady = false\n private handoffActive = false\n\n /** This session's id + how to watch it live (push transport vs. polling). */\n private sessionId = ''\n private realtimeConfig: ClientRealtime | null = null\n private rtClient: WidgetRealtimeClient | null = null\n private rtResolved = false\n /** The last status observed, so a transition only emits once per change. */\n private lastStatus: VerificationStatus | null = null\n /** Per-name event listeners registered via {@link on} (payload-erased for storage). */\n private listeners = new Map<WidgetEventName, Set<(data: never) => void>>()\n /** Cancels the active session watch (e.g. continue-on-this-device). */\n private stopWatch: (() => void) | undefined\n\n private step: WidgetStep = 'welcome'\n private documentType: DocumentType | null = null\n private country: string | null = null\n private selfie: Blob | null = null\n private livenessMode: LivenessMode = 'passive'\n /** The session's capture model; `active` mandates a live camera (no fallback). */\n private captureModel: 'passive' | 'active' | 'both' = 'passive'\n private livenessChallenges: LivenessChallenge[] = []\n /** The address-entry form data, captured before the address submission. */\n private addressData: AddressFormData | null = null\n /** Device coordinates resolved on the address step (when the user opts in). */\n private addressCoords: { latitude: number; longitude: number } | null = null\n /** The applied workflow (orders/toggles stages, skip_ocr), or null for the default flow. */\n private workflow: WorkflowConfig | null = null\n private result: WidgetResult | null = null\n /** The session was already terminal when the widget loaded (show a close-only notice). */\n private terminalOnLoad = false\n /** Whether the current step's instruction interstitial has been acknowledged. */\n private instructionAcked = false\n private pendingError: Error | null = null\n private settled = false\n\n constructor(private readonly config: WidgetControllerConfig) {\n const doc = config.doc ?? globalThis.document\n const win = config.win ?? globalThis.window\n if (!doc || !win) throw new Error('The Arkyc widget must run in a browser environment.')\n this.win = win\n this.nav = config.nav ?? globalThis.navigator\n\n this.client = new ArkycClient({\n token: config.token,\n baseUrl: config.baseUrl,\n fetch: config.fetch,\n })\n const theme = new Theme(config.branding)\n this.view = new WidgetView(\n doc,\n theme,\n this.handlers(),\n config.nav ?? globalThis.navigator,\n config.faceAnalyzer,\n config.faceTuning,\n config.documentAnalyzer,\n config.documentTuning,\n )\n\n this.postToParent = config.postToParent ?? (!!win.parent && win.parent !== win)\n this.scheduler = config.scheduler ?? ((fn, ms) => setTimeout(fn, ms))\n this.transientMs = config.transientMs ?? 700\n this.pollMs = config.pollMs ?? 1500\n this.maxPolls = config.maxPolls ?? 40\n // A handed-off session is bounded by its TTL (~15 min); poll generously.\n this.maxHandoffPolls = config.maxHandoffPolls ?? 600\n }\n\n /** The widget's root element — append it to an overlay or inline container. */\n get element(): HTMLElement {\n return this.view.element\n }\n\n /** Connect to the session, then route to the result, the QR, or the welcome flow. */\n start(): void {\n this.view.renderLoading()\n void this.run(() => this.bootstrap())\n }\n\n /** Tear down the view and release the camera (does not fire callbacks). */\n destroy(): void {\n this.view.destroy()\n }\n\n /** Close the widget as if the user dismissed it (fires `onClose`/`onSettle`). */\n close(): void {\n this.finishClose()\n }\n\n /**\n * Subscribe to a named widget event; returns an unsubscribe function. Having a\n * listener (here or via `onEvent`) is what activates the event stream.\n */\n on<K extends WidgetEventName>(event: K, listener: WidgetEventListener<K>): () => void {\n const set = this.listeners.get(event) ?? new Set<(data: never) => void>()\n set.add(listener)\n this.listeners.set(event, set)\n\n return () => set.delete(listener)\n }\n\n /** Whether anyone is listening for `name` (the firehose or a named listener). */\n private hasListener(name: WidgetEventName): boolean {\n if (this.config.onEvent) return true\n const set = this.listeners.get(name)\n\n return !!set && set.size > 0\n }\n\n /**\n * Surface an event: to local listeners (firehose + `on`), and — in hosted\n * iframe mode — to the embedding parent as `arkyc:event` so the SDK's\n * `handle.on(...)` works across the frame.\n */\n private dispatch<K extends WidgetEventName>(name: K, data: WidgetEventMap[K]): void {\n this.emit(name, data)\n // The parent window is the consumer in iframe mode, so forwarding there isn't\n // gated on a local listener (post() already no-ops when not embedding).\n this.post('arkyc:event', { name, data })\n }\n\n /** Emit an event to the firehose + named listeners — but only if one is active. */\n private emit<K extends WidgetEventName>(name: K, data: WidgetEventMap[K]): void {\n if (!this.hasListener(name)) return\n try {\n this.config.onEvent?.({ name, data } as WidgetEvent)\n } catch {\n // A consumer callback threw — never let it break the flow.\n }\n const set = this.listeners.get(name)\n if (set) {\n for (const listener of [...set]) {\n try {\n listener(data as never)\n } catch {\n // ditto\n }\n }\n }\n }\n\n /** Record an observed status, emitting `session.transition` on a real change. */\n private observe(status: VerificationStatus): void {\n if (status === this.lastStatus) return\n const previous = this.lastStatus\n this.lastStatus = status\n this.dispatch(REALTIME_EVENT.sessionTransition, {\n session_id: this.sessionId,\n status,\n previous_status: previous,\n } satisfies WidgetSessionTransitionData)\n }\n\n /**\n * Lazily connect the push transport for this session (once). Returns null for\n * transports the widget does not stream over or when the transport SDK can't\n * load, and the caller then polls instead.\n */\n private async resolveRealtime(): Promise<WidgetRealtimeClient | null> {\n if (this.rtResolved) return this.rtClient\n this.rtResolved = true\n const cfg = this.realtimeConfig\n if (!cfg || cfg.transport === 'polling' || cfg.transport === 'memory') return null\n\n const factory = this.config.realtimeFactory ?? createWidgetRealtimeClient\n // Build from the client's *resolved* base (absolute/proxy value, or the\n // hosted default when `baseUrl` was omitted) — never the raw option, which\n // is empty in the zero-config case and would resolve against the page origin.\n const apiBase = this.client.base\n try {\n this.rtClient = await factory(cfg, {\n authEndpoint: `${apiBase}/realtime/auth`,\n token: await this.client.authToken(),\n })\n } catch {\n this.rtClient = null\n }\n\n return this.rtClient\n }\n\n private handlers(): ViewHandlers {\n return {\n onClose: () => this.finishClose(),\n // The session was already fetched + resolved during bootstrap; just advance.\n onStart: () => void this.run(() => this.enter(this.next())),\n onDocumentSelected: (type, country) => {\n this.documentType = type\n this.country = country || null\n void this.run(() => this.enter('front_capture'))\n },\n onImage: (blob) => void this.run(() => this.onImage(blob)),\n onActiveLiveness: (video, performed, selfie) =>\n void this.run(async () => {\n await this.client.submitLiveness({\n selfie,\n video,\n mode: 'active',\n challenges: performed,\n signals: this.config.signals,\n })\n await this.enter(this.next())\n }),\n onAddress: (data) => void this.run(() => this.onAddress(data)),\n onShareLocation: () => this.shareLocation(),\n onAcknowledge: () => this.finishResult(),\n onUsePhone: () => void this.run(() => this.startHandoff()),\n onContinueHere: () => {\n this.handoffActive = false\n this.stopWatch?.()\n this.step = 'welcome'\n this.render()\n },\n }\n }\n\n /**\n * Fetch the session and route: an already-terminal session (e.g. a stale handoff\n * link, or one finished on another device) shows a close-only notice; on a\n * desktop with handoff enabled we lead with the QR; otherwise the welcome flow.\n */\n private async bootstrap(): Promise<void> {\n if (this.settled) return\n const session = await this.client.getSession()\n if (this.settled) return\n this.sessionId = session.id\n this.realtimeConfig = session.realtime ?? null\n this.workflow = session.workflow ?? null\n // Theme from the project's branding (server-resolved) unless the integrator\n // passed branding explicitly — their value wins.\n if (this.config.branding == null && session.branding)\n this.view.applyBranding(rebaseBrandingLogo(session.branding, this.config.baseUrl))\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) return this.showTerminal(session.status)\n this.resolveLiveness(session)\n if (session.handoff) this.handoffConfig = session.handoff\n const canHandoff =\n this.config.handoff !== false && isDesktopDevice(this.nav) && this.handoffConfig.enabled && !!this.handoffTarget()\n if (canHandoff) {\n this.handoffReady = true\n await this.startHandoff()\n } else {\n this.step = 'welcome'\n this.render()\n }\n }\n\n /** The hosted handoff page URL: a consumer override, else the server-provided one. */\n private handoffTarget(): string {\n return (this.config.handoffUrl ?? this.handoffConfig.url ?? '').trim()\n }\n\n /** Render the QR for this session and wait for the other device to finish. */\n private async startHandoff(): Promise<void> {\n const target = this.handoffTarget()\n if (!target) return\n this.handoffActive = true\n const url = this.buildHandoffUrl(target, await this.client.createHandoffToken())\n // Offer \"continue on this device\" only when the project permits it.\n this.view.renderHandoff(renderQrSvg(url), this.handoffConfig.allow_desktop)\n await this.pollHandoff()\n }\n\n /**\n * Build the hosted-page URL the QR encodes. It carries a single-use handoff\n * token, never this browser's own token.\n *\n * @param target\n * @param handoffToken\n * @returns\n */\n private buildHandoffUrl(target: string, handoffToken: string): string {\n const pageOrigin = this.win.location?.origin ?? globalThis.location?.origin ?? ''\n return composeHandoffUrl(target, handoffToken, this.client.base, pageOrigin)\n }\n\n /**\n * Watch the session while the user verifies on the other\n * device; mirror the result.\n *\n * @returns\n */\n private async pollHandoff(): Promise<void> {\n const rt = await this.resolveRealtime()\n if (rt) {\n const status = await this.pushWatch(rt, this.maxHandoffPolls, () => this.handoffActive && !this.settled)\n if (status) {\n this.handoffActive = false\n return this.showResult(status)\n }\n\n return // cancelled (continue here) or budget elapsed → the QR persists\n }\n\n let errors = 0\n for (let i = 0; i < this.maxHandoffPolls && this.handoffActive && !this.settled; i++) {\n await this.delay(this.pollMs)\n if (!this.handoffActive || this.settled) return\n try {\n const session = await this.client.getSession()\n errors = 0\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) {\n this.handoffActive = false\n return this.showResult(session.status)\n }\n } catch (error) {\n // The session/token can expire mid-wait (401) — stop and reflect that.\n if (error instanceof WidgetApiError && error.status === 401) {\n this.handoffActive = false\n return this.showResult('expired')\n }\n if (++errors >= 5) return\n }\n }\n }\n\n /**\n * Await a terminal status over the push transport. Resolves to the terminal\n * status, or null if `active()` went false or the tick budget elapsed (push can\n * miss events / disconnect; the budget bounds the wait). An initial fetch\n * catches a session that finished before we subscribed.\n *\n * @param rt\n * @param budget\n * @param active\n * @returns\n */\n private pushWatch(\n rt: WidgetRealtimeClient,\n budget: number,\n active: () => boolean,\n ): Promise<VerificationStatus | null> {\n return new Promise((resolve) => {\n let done = false\n const finish = (status: VerificationStatus | null) => {\n if (done) return\n done = true\n this.stopWatch = undefined\n unsubscribe()\n resolve(status)\n }\n const channel = this.realtimeConfig!.channel\n const unsubscribe = rt.subscribe(channel, (event, data) => {\n if (event !== REALTIME_EVENT.sessionTransition) return\n const status = (data as SessionTransitionEvent).status\n this.observe(status)\n if (Flow.isTerminal(status)) finish(status)\n })\n this.stopWatch = () => finish(null)\n // Catch a race where the session already finished before we subscribed.\n void this.client\n .getSession()\n .then((session) => {\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) finish(session.status)\n })\n .catch((error) => {\n if (error instanceof WidgetApiError && error.status === 401) finish('expired')\n })\n // Bound the wait by the same tick budget the poller would use.\n const countdown = async (n: number): Promise<void> => {\n if (done) return\n if (!active() || n >= budget) return finish(null)\n await this.delay(this.pollMs)\n void countdown(n + 1)\n }\n void countdown(0)\n })\n }\n\n /**\n * Resolve which liveness flow to run from the session's capture model.\n *\n * @param session\n */\n private resolveLiveness(session: ClientSession): void {\n this.livenessChallenges = session.liveness_challenges ?? []\n const model = session.capture_model ?? 'passive'\n this.captureModel = model\n const wantsActive = model === 'active' || model === 'both'\n // `active` is honoured even without a live camera — the active-liveness\n // screen then shows the device as unsupported (no fallback). `both` falls\n // back to passive when the camera/recorder isn't available.\n this.livenessMode = wantsActive && (model === 'active' || this.view.cameraSupported) ? 'active' : 'passive'\n }\n\n private async onImage(blob: Blob | null): Promise<void> {\n const signals = this.config.signals\n switch (this.step) {\n case 'front_capture':\n await this.client.submitDocumentFront({\n image: blob,\n country: this.country,\n documentType: this.documentType,\n signals,\n })\n return this.enter(this.next())\n case 'back_capture':\n await this.client.submitDocumentBack({\n image: blob,\n country: this.country,\n documentType: this.documentType,\n })\n return this.enter('ocr_processing')\n case 'selfie_capture':\n this.selfie = blob\n return this.enter('passive_liveness')\n }\n }\n\n /**\n * Enter a step: render it, and drive any automatic (processing) work.\n *\n * @param step\n * @returns\n */\n private async enter(step: WidgetStep): Promise<void> {\n if (this.settled) return\n this.step = step\n\n // Lead each capture/liveness step with an instruction screen so the user\n // knows what's next and starts it deliberately. The Continue button re-enters\n // the same step with the instruction acknowledged.\n const instruction = STEP_INSTRUCTIONS[step]\n if (instruction && !this.instructionAcked) {\n this.view.renderInstruction(step, instruction.title, instruction.body, instruction.cta ?? 'Continue', () => {\n this.instructionAcked = true\n void this.run(() => this.enter(step))\n })\n return\n }\n this.instructionAcked = false\n this.render()\n\n switch (step) {\n case 'ocr_processing':\n await this.delay(this.transientMs)\n return this.enter(this.next())\n case 'address_processing':\n await this.submitAddress()\n return this.enter(this.next())\n case 'passive_liveness':\n await this.client.submitLiveness({ selfie: this.selfie, signals: this.config.signals })\n return this.enter(this.next())\n case 'face_match':\n await this.client.complete({ signals: this.config.signals })\n return this.enter(this.next())\n case 'processing':\n return this.poll()\n }\n }\n\n /** Watch the session until it reaches a terminal status (then show the result).\n *\n * @returns\n */\n private async poll(): Promise<void> {\n const rt = await this.resolveRealtime()\n if (rt) {\n const status = await this.pushWatch(rt, this.maxPolls, () => !this.settled)\n\n return this.showResult(status ?? 'requires_review')\n }\n\n for (let i = 0; i < this.maxPolls && !this.settled; i++) {\n const session = await this.client.getSession()\n this.observe(session.status)\n if (Flow.isTerminal(session.status)) return this.showResult(session.status)\n await this.delay(this.pollMs)\n }\n // Still processing after the poll budget — surface as pending review.\n this.showResult('requires_review')\n }\n\n private showResult(status: VerificationStatus): void {\n this.result = { status, decision: Flow.statusToDecision(status) }\n this.step = 'result'\n this.render()\n }\n\n /**\n * A session that was already terminal on load: a notice with only a Close button.\n *\n * @param status\n */\n private showTerminal(status: VerificationStatus): void {\n this.result = { status, decision: Flow.statusToDecision(status) }\n this.step = 'result'\n this.terminalOnLoad = true\n this.render()\n\n // A session already *decided* when this device opened it (approved / rejected /\n // requires_review) still owes the integrator its result. The notice only offers\n // Close, so the flow never reaches `finishResult` — surface the outcome now,\n // mirroring `fail`, so `onComplete` and the `complete` event/listeners fire\n // immediately rather than waiting on a dismissal that may never come. Non-decision\n // terminals (expired / cancelled) aren't completions and stay close-only. The\n // terminal `arkyc:complete` (parent teardown) still waits for Close, which posts\n // `arkyc:close`; `dispatch` forwards the non-closing `complete` event to a parent.\n if (this.result.decision != null) {\n this.dispatch('complete', this.result)\n this.config.onComplete?.(this.result)\n }\n }\n\n private render(): void {\n this.view.render({\n step: this.step,\n documentType: this.documentType,\n decision: this.result?.decision,\n status: this.result?.status,\n terminalNotice: this.terminalOnLoad,\n allowSkip: !!this.config.signals,\n livenessChallenges: this.livenessChallenges,\n requireLiveCamera: this.captureModel === 'active',\n // The active flow is strict end-to-end: documents must be detected, not\n // manually waved through.\n strictCapture: this.livenessMode === 'active',\n handoffAvailable: this.handoffReady,\n addressMethods: workflowAddressConfig(this.workflow)?.methods ?? [],\n })\n }\n\n /**\n * Store the entered address, resolve device location if opted in, then verify.\n *\n * @param data\n * @returns\n */\n private async onAddress(data: AddressFormData): Promise<void> {\n this.addressData = data\n // Coordinates are normally resolved up front by `shareLocation` when the user\n // ticks \"use my location\" (so the prompt is immediate); geolocate here only as\n // a fallback, and clear them if they opted out.\n if (!data.useLocation) this.addressCoords = null\n else if (!this.addressCoords) this.addressCoords = await this.geolocate()\n\n return this.enter('address_processing')\n }\n\n /** Request the device's location (from the address-entry \"use my location\" opt-in). */\n private async shareLocation(): Promise<boolean> {\n this.addressCoords = await this.geolocate()\n\n return !!this.addressCoords\n }\n\n /**\n * Submit the captured address evidence to the Client API.\n *\n * @returns\n */\n private async submitAddress(): Promise<void> {\n const data = this.addressData\n if (!data) return\n\n await this.client.submitAddress({\n line1: data.line1,\n line2: data.line2,\n city: data.city,\n region: data.region,\n postalCode: data.postalCode,\n country: data.country,\n poa: data.poa,\n latitude: this.addressCoords?.latitude ?? null,\n longitude: this.addressCoords?.longitude ?? null,\n signals: this.config.signals,\n })\n }\n\n /**\n * Resolve the device's coordinates via the Geolocation API. Resolves to `null`\n * on denial/unavailability/timeout — the `device_location` method then simply\n * has no coordinates to check (routing the session to review).\n *\n * @returns\n */\n private geolocate(): Promise<{ latitude: number; longitude: number } | null> {\n const geo = this.nav?.geolocation\n if (!geo) return Promise.resolve(null)\n\n return new Promise((resolve) => {\n geo.getCurrentPosition(\n (pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),\n () => resolve(null),\n { enableHighAccuracy: false, timeout: 10_000, maximumAge: 60_000 },\n )\n })\n }\n\n private next(): WidgetStep {\n return Flow.nextStep(this.step, {\n documentType: this.documentType,\n livenessMode: this.livenessMode,\n workflow: this.workflow,\n })\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => this.scheduler(resolve, ms))\n }\n\n /** Run an async step, routing any failure to the error screen. */\n private async run(fn: () => Promise<void> | void): Promise<void> {\n try {\n await fn()\n } catch (error) {\n this.fail(error)\n }\n }\n\n private fail(error: unknown): void {\n // Notify once, on the first failure. `pendingError` is the guard so a repeat\n // (or a later close) doesn't re-fire.\n if (this.settled || this.pendingError) return\n const err = error instanceof Error ? error : new Error(String(error))\n this.pendingError = err\n this.step = 'result'\n\n this.view.render({\n step: 'result',\n documentType: this.documentType,\n errorMessage: err.message,\n })\n\n // Surface the failure to the integrator immediately, not when the user\n // eventually dismisses the screen (they may never), so an expired session or\n // token is knowable programmatically. `dispatch` also forwards a non-closing\n // `error` event to an embedding parent; the terminal `arkyc:error` (which\n // tears the overlay down) is posted from `finishResult` on dismissal.\n this.dispatch('error', this.errorData(err))\n this.config.onError?.(err)\n }\n\n /** Event payload for a failure: the message plus the stable key/status when it's a typed API error. */\n private errorData(err: Error): { message: string; status?: number; error?: string } {\n if (err instanceof WidgetApiError) return { message: err.message, status: err.status, error: err.error }\n\n return { message: err.message }\n }\n\n private finishResult(): void {\n if (this.settled) return\n this.settled = true\n this.teardownRealtime()\n\n if (this.pendingError) {\n // `onError` + the `error` event already fired at failure time; only relay\n // the terminal error to an embedding parent (closing its overlay) here.\n this.post('arkyc:error', { error: serializeError(this.pendingError) })\n } else if (this.result) {\n this.post('arkyc:complete', { payload: this.result })\n this.dispatch('complete', this.result)\n this.config.onComplete?.(this.result)\n }\n\n this.destroy()\n this.config.onSettle?.()\n }\n\n private finishClose(): void {\n if (this.settled) return\n\n this.settled = true\n this.teardownRealtime()\n this.post('arkyc:close', {})\n this.dispatch('close', undefined)\n this.config.onClose?.()\n this.destroy()\n this.config.onSettle?.()\n }\n\n /** Stop any active session watch + disconnect the push transport. */\n private teardownRealtime(): void {\n this.stopWatch?.()\n this.rtClient?.disconnect()\n this.rtClient = null\n }\n\n private post(type: string, extra: Record<string, unknown>): void {\n if (!this.postToParent) return\n try {\n this.win.parent?.postMessage({ type, ...extra }, '*')\n } catch {\n // Cross-origin parent without access — nothing more we can do.\n }\n }\n}\n\nfunction serializeError(error: Error): { message: string; name: string } {\n return { message: error.message, name: error.name }\n}\n\n/** The origin of an absolute URL, or '' when it can't be parsed. */\nfunction originOf(url: string): string {\n try {\n return new URL(url).origin\n } catch {\n return ''\n }\n}\n\n/**\n * Build the hosted-page URL the handoff QR encodes: always the `target` page\n * plus the session `token`, and the API base **only when the phone can actually\n * reach it** — i.e. a genuine cross-origin base. `apiBase` is the widget's\n * resolved Client API base (always origin-qualified), so a relative proxy\n * resolves to the desktop's own `pageOrigin` and is skipped (the hosted page\n * then falls back to its own default base instead of a URL the phone can't use).\n */\nexport const composeHandoffUrl = (target: string, token: string, apiBase: string, pageOrigin: string): string => {\n const sep = target.includes('?') ? '&' : '?'\n let url = `${target}${sep}token=${encodeURIComponent(token)}`\n const baseOrigin = originOf(apiBase)\n if (baseOrigin && baseOrigin !== pageOrigin) url += `&baseUrl=${encodeURIComponent(apiBase)}`\n return url\n}\n\nexport const resolveContainer = (container: string | HTMLElement, doc: Document): HTMLElement => {\n const el = typeof container === 'string' ? doc.querySelector<HTMLElement>(container) : container\n if (!el) throw new Error(`ArkycWidget.mount: container \"${String(container)}\" not found.`)\n return el\n}\n\n/**\n * Make an inline mount point a reliable host for the widget, so `mount()` renders\n * predictably regardless of the page layout — a static or zero-height container,\n * or one sharing the viewport with an app root that would otherwise paint over it.\n *\n * The widget gives the host its own stacking context and lifts it above sibling\n * app content, the way `open()` owns its overlay. Each property is only set when\n * the host hasn't already committed to one (checked against computed style where\n * available, else inline style), so an embed that wants normal flow or a specific\n * layer can still override it with its own CSS.\n */\nexport const adoptContainer = (container: HTMLElement, doc: Document): void => {\n const computed = doc.defaultView?.getComputedStyle?.(container)\n const position = computed?.position ?? container.style.position\n if (!position || position === 'static') container.style.position = 'relative'\n\n const zIndex = computed?.zIndex ?? container.style.zIndex\n if (!zIndex || zIndex === 'auto') container.style.zIndex = '1'\n\n // A self-contained stacking context keeps the widget's layers from bleeding\n // into (or being clipped by) the surrounding page.\n if (!container.style.isolation) container.style.isolation = 'isolate'\n}\n\nexport const buildController = (options: BaseWidgetOptions, onSettle: () => void): WidgetController => {\n if (!options.token) throw new Error('ArkycWidget requires a client `token`.')\n return new WidgetController({\n token: options.token,\n baseUrl: options.baseUrl,\n handoff: options.handoff,\n handoffUrl: options.handoffUrl,\n branding: options.branding,\n signals: options.signals,\n onComplete: options.onComplete,\n onError: options.onError,\n onClose: options.onClose,\n onEvent: options.onEvent,\n onSettle,\n fetch: options.fetch,\n doc: options.doc,\n win: options.win,\n nav: options.nav,\n faceAnalyzer: options.faceAnalyzer,\n faceTuning: options.faceTuning,\n documentAnalyzer: options.documentAnalyzer,\n documentTuning: options.documentTuning,\n })\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"flow.d.mts","names":[],"sources":["../src/flow.ts"],"mappings":";;;;UAaiB,WAAA;EACf,YAAA,GAAe,YAAA;EADW;EAG1B,YAAA,GAAe,YAAA;EAFA;EAIf,QAAA,GAAW,cAAA;AAAA;;;;;cAeA,IAAA;EAjBI;;;;EAAA,gBAsBC,UAAA,EAAY,UAAA;EALjB;;;EAAA,gBAsBK,iBAAA,EAAmB,kBAAA;EAAA;;;;;;EAAA,OAc5B,eAAA,CAAgB,IAAA,EAAM,YAAA;EA2Ca;;;;;;EAAA,OAjCnC,SAAA,CAAU,GAAA,GAAK,WAAA,GAAmB,UAAA;EAzCzB;;;;;;;;EAAA,OAwDT,aAAA,CAAc,IAAA,EAAM,UAAA,EAAY,GAAA,GAAK,WAAA;EAf3B;;;;;;;;EAAA,OAiCV,QAAA,CAAS,OAAA,EAAS,UAAA,EAAY,GAAA,GAAK,WAAA,GAAmB,UAAA;EAA7C;;;;;;EAAA,OAiBT,UAAA,CAAW,MAAA,EAAQ,kBAAA;EASnB;;;;;EAAA,OAAA,gBAAA,CAAiB,MAAA,EAAQ,kBAAA,GAAqB,oBAAA;AAAA"}
1
+ {"version":3,"file":"flow.d.mts","names":[],"sources":["../src/flow.ts"],"mappings":";;;;UAaiB,WAAA;EACf,YAAA,GAAe,YAAA;EADW;EAG1B,YAAA,GAAe,YAAA;EAFA;EAIf,QAAA,GAAW,cAAA;AAAA;;;;;cAgBA,IAAA;EAlBI;;;;EAAA,gBAuBC,UAAA,EAAY,UAAA;EALjB;;;EAAA,gBAsBK,iBAAA,EAAmB,kBAAA;EAAA;;;;;;EAAA,OAc5B,eAAA,CAAgB,IAAA,EAAM,YAAA;EA2Ca;;;;;;EAAA,OAjCnC,SAAA,CAAU,GAAA,GAAK,WAAA,GAAmB,UAAA;EAzCzB;;;;;;;;EAAA,OAwDT,aAAA,CAAc,IAAA,EAAM,UAAA,EAAY,GAAA,GAAK,WAAA;EAf3B;;;;;;;;EAAA,OAiCV,QAAA,CAAS,OAAA,EAAS,UAAA,EAAY,GAAA,GAAK,WAAA,GAAmB,UAAA;EAA7C;;;;;;EAAA,OAiBT,UAAA,CAAW,MAAA,EAAQ,kBAAA;EASnB;;;;;EAAA,OAAA,gBAAA,CAAiB,MAAA,EAAQ,kBAAA,GAAqB,oBAAA;AAAA"}
package/dist/flow.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { workflowEnabledSteps, workflowRunsOcr } from "@arkyc/types";
2
2
  //#region src/flow.ts
3
- /** The widget screens that make up each coarse verification stage, in stage-local order. */
3
+ /** The widget screens that make up each coarse verification stage, in stage-local order. Screening runs server-side only. */
4
4
  const STAGE_STEPS = {
5
5
  document: [
6
6
  "document_selection",
@@ -8,6 +8,7 @@ const STAGE_STEPS = {
8
8
  "back_capture",
9
9
  "ocr_processing"
10
10
  ],
11
+ screening: [],
11
12
  address: ["address_entry", "address_processing"],
12
13
  liveness: [
13
14
  "active_liveness",
package/dist/flow.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"flow.mjs","names":[],"sources":["../src/flow.ts"],"sourcesContent":["import {\n type DocumentType,\n type LivenessMode,\n type VerificationDecision,\n type VerificationStatus,\n type WidgetStep,\n type WorkflowConfig,\n type WorkflowStepKey,\n workflowEnabledSteps,\n workflowRunsOcr,\n} from '@arkyc/types'\n\n/** Context that influences flow branching. */\nexport interface FlowContext {\n documentType?: DocumentType | null\n /** Which liveness flow this session runs (resolved from the capture model). */\n livenessMode?: LivenessMode\n /** The applied workflow (orders/toggles the coarse stages), or null for the default flow. */\n workflow?: WorkflowConfig | null\n}\n\n/** The widget screens that make up each coarse verification stage, in stage-local order. */\nconst STAGE_STEPS: Record<WorkflowStepKey, WidgetStep[]> = {\n document: ['document_selection', 'front_capture', 'back_capture', 'ocr_processing'],\n address: ['address_entry', 'address_processing'],\n liveness: ['active_liveness', 'selfie_capture', 'passive_liveness'],\n face_match: ['face_match'],\n}\n\n/**\n * The verification flow's step machine. Stateless — exposed as static members so\n * the single \"flow\" concern lives in one class rather than as floating helpers.\n */\nexport class Flow {\n /**\n * The flow screens, in the order the widget walks them. The `back_capture`\n * screen is skipped for single-sided documents (passports).\n */\n static readonly STEP_ORDER: WidgetStep[] = [\n 'welcome',\n 'document_selection',\n 'front_capture',\n 'back_capture',\n 'ocr_processing',\n 'active_liveness',\n 'selfie_capture',\n 'passive_liveness',\n 'face_match',\n 'processing',\n 'result',\n ]\n\n /**\n * Statuses from which a session can no longer progress.\n */\n static readonly TERMINAL_STATUSES: VerificationStatus[] = [\n 'approved',\n 'rejected',\n 'requires_review',\n 'expired',\n 'cancelled',\n ]\n\n /**\n * Whether a document type has a second (back) side to capture.\n *\n * @param type\n * @returns\n */\n static documentHasBack(type: DocumentType | null | undefined): boolean {\n return type != null && type !== 'passport'\n }\n\n /**\n * The screens to walk for this session, in order. With no workflow this is the\n * default {@link STEP_ORDER}; a workflow reorders the coarse stages and drops\n * disabled ones (their screens are excluded entirely), always bracketed by\n * `welcome` and `processing`/`result`.\n */\n static stepOrder(ctx: FlowContext = {}): WidgetStep[] {\n const stages = workflowEnabledSteps(ctx.workflow) as WorkflowStepKey[]\n const middle = stages.flatMap((stage) => STAGE_STEPS[stage])\n\n return ['welcome', ...middle, 'processing', 'result']\n }\n\n /**\n * Whether a step runs for the given context. `back_capture` is skipped for\n * single-sided documents; `ocr_processing` is skipped when the workflow skips\n * OCR; the liveness steps branch on the resolved mode — `active_liveness` only\n * in active mode, `selfie_capture`/`passive_liveness` only in passive mode.\n * Stage-level disabling is handled by {@link stepOrder} (disabled stages are\n * absent from the order), so this only covers within-stage branching.\n */\n static isStepEnabled(step: WidgetStep, ctx: FlowContext = {}): boolean {\n if (step === 'back_capture') return Flow.documentHasBack(ctx.documentType)\n if (step === 'ocr_processing') return workflowRunsOcr(ctx.workflow)\n if (step === 'active_liveness') return ctx.livenessMode === 'active'\n if (step === 'selfie_capture' || step === 'passive_liveness') {\n return ctx.livenessMode !== 'active'\n }\n return true\n }\n\n /**\n * The next enabled screen after `current`, honouring the workflow order and the\n * branch rules. Returns `current` when already at the end.\n *\n * @param current\n * @param ctx\n * @returns\n */\n static nextStep(current: WidgetStep, ctx: FlowContext = {}): WidgetStep {\n const order = Flow.stepOrder(ctx)\n const idx = order.indexOf(current)\n if (idx < 0) return current\n for (let i = idx + 1; i < order.length; i += 1) {\n const step = order[i]!\n if (Flow.isStepEnabled(step, ctx)) return step\n }\n return current\n }\n\n /**\n * Whether a session status is terminal (the flow is done).\n *\n * @param status\n * @returns\n */\n static isTerminal(status: VerificationStatus): boolean {\n return Flow.TERMINAL_STATUSES.includes(status)\n }\n\n /**\n * Map a terminal session status to the decision the integrator cares about.\n * Non-decision terminals (`expired`/`cancelled`) and in-flight statuses map to\n * `null`.\n */\n static statusToDecision(status: VerificationStatus): VerificationDecision | null {\n switch (status) {\n case 'approved':\n return 'approved'\n case 'rejected':\n return 'rejected'\n case 'requires_review':\n return 'requires_review'\n default:\n return null\n }\n }\n}\n"],"mappings":";;;AAsBA,MAAM,cAAqD;CACzD,UAAU;EAAC;EAAsB;EAAiB;EAAgB;CAAgB;CAClF,SAAS,CAAC,iBAAiB,oBAAoB;CAC/C,UAAU;EAAC;EAAmB;EAAkB;CAAkB;CAClE,YAAY,CAAC,YAAY;AAC3B;;;;;AAMA,IAAa,OAAb,MAAa,KAAK;;;;;CAKhB,OAAgB,aAA2B;EACzC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;;;;CAKA,OAAgB,oBAA0C;EACxD;EACA;EACA;EACA;EACA;CACF;;;;;;;CAQA,OAAO,gBAAgB,MAAgD;EACrE,OAAO,QAAQ,QAAQ,SAAS;CAClC;;;;;;;CAQA,OAAO,UAAU,MAAmB,CAAC,GAAiB;EAIpD,OAAO;GAAC;GAAW,GAHJ,qBAAqB,IAAI,QACpB,CAAC,CAAC,SAAS,UAAU,YAAY,MAE1B;GAAG;GAAc;EAAQ;CACtD;;;;;;;;;CAUA,OAAO,cAAc,MAAkB,MAAmB,CAAC,GAAY;EACrE,IAAI,SAAS,gBAAgB,OAAO,KAAK,gBAAgB,IAAI,YAAY;EACzE,IAAI,SAAS,kBAAkB,OAAO,gBAAgB,IAAI,QAAQ;EAClE,IAAI,SAAS,mBAAmB,OAAO,IAAI,iBAAiB;EAC5D,IAAI,SAAS,oBAAoB,SAAS,oBACxC,OAAO,IAAI,iBAAiB;EAE9B,OAAO;CACT;;;;;;;;;CAUA,OAAO,SAAS,SAAqB,MAAmB,CAAC,GAAe;EACtE,MAAM,QAAQ,KAAK,UAAU,GAAG;EAChC,MAAM,MAAM,MAAM,QAAQ,OAAO;EACjC,IAAI,MAAM,GAAG,OAAO;EACpB,KAAK,IAAI,IAAI,MAAM,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;GAC9C,MAAM,OAAO,MAAM;GACnB,IAAI,KAAK,cAAc,MAAM,GAAG,GAAG,OAAO;EAC5C;EACA,OAAO;CACT;;;;;;;CAQA,OAAO,WAAW,QAAqC;EACrD,OAAO,KAAK,kBAAkB,SAAS,MAAM;CAC/C;;;;;;CAOA,OAAO,iBAAiB,QAAyD;EAC/E,QAAQ,QAAR;GACE,KAAK,YACH,OAAO;GACT,KAAK,YACH,OAAO;GACT,KAAK,mBACH,OAAO;GACT,SACE,OAAO;EACX;CACF;AACF"}
1
+ {"version":3,"file":"flow.mjs","names":[],"sources":["../src/flow.ts"],"sourcesContent":["import {\n type DocumentType,\n type LivenessMode,\n type VerificationDecision,\n type VerificationStatus,\n type WidgetStep,\n type WorkflowConfig,\n type WorkflowStepKey,\n workflowEnabledSteps,\n workflowRunsOcr,\n} from '@arkyc/types'\n\n/** Context that influences flow branching. */\nexport interface FlowContext {\n documentType?: DocumentType | null\n /** Which liveness flow this session runs (resolved from the capture model). */\n livenessMode?: LivenessMode\n /** The applied workflow (orders/toggles the coarse stages), or null for the default flow. */\n workflow?: WorkflowConfig | null\n}\n\n/** The widget screens that make up each coarse verification stage, in stage-local order. Screening runs server-side only. */\nconst STAGE_STEPS: Record<WorkflowStepKey, WidgetStep[]> = {\n document: ['document_selection', 'front_capture', 'back_capture', 'ocr_processing'],\n screening: [],\n address: ['address_entry', 'address_processing'],\n liveness: ['active_liveness', 'selfie_capture', 'passive_liveness'],\n face_match: ['face_match'],\n}\n\n/**\n * The verification flow's step machine. Stateless — exposed as static members so\n * the single \"flow\" concern lives in one class rather than as floating helpers.\n */\nexport class Flow {\n /**\n * The flow screens, in the order the widget walks them. The `back_capture`\n * screen is skipped for single-sided documents (passports).\n */\n static readonly STEP_ORDER: WidgetStep[] = [\n 'welcome',\n 'document_selection',\n 'front_capture',\n 'back_capture',\n 'ocr_processing',\n 'active_liveness',\n 'selfie_capture',\n 'passive_liveness',\n 'face_match',\n 'processing',\n 'result',\n ]\n\n /**\n * Statuses from which a session can no longer progress.\n */\n static readonly TERMINAL_STATUSES: VerificationStatus[] = [\n 'approved',\n 'rejected',\n 'requires_review',\n 'expired',\n 'cancelled',\n ]\n\n /**\n * Whether a document type has a second (back) side to capture.\n *\n * @param type\n * @returns\n */\n static documentHasBack(type: DocumentType | null | undefined): boolean {\n return type != null && type !== 'passport'\n }\n\n /**\n * The screens to walk for this session, in order. With no workflow this is the\n * default {@link STEP_ORDER}; a workflow reorders the coarse stages and drops\n * disabled ones (their screens are excluded entirely), always bracketed by\n * `welcome` and `processing`/`result`.\n */\n static stepOrder(ctx: FlowContext = {}): WidgetStep[] {\n const stages = workflowEnabledSteps(ctx.workflow) as WorkflowStepKey[]\n const middle = stages.flatMap((stage) => STAGE_STEPS[stage])\n\n return ['welcome', ...middle, 'processing', 'result']\n }\n\n /**\n * Whether a step runs for the given context. `back_capture` is skipped for\n * single-sided documents; `ocr_processing` is skipped when the workflow skips\n * OCR; the liveness steps branch on the resolved mode — `active_liveness` only\n * in active mode, `selfie_capture`/`passive_liveness` only in passive mode.\n * Stage-level disabling is handled by {@link stepOrder} (disabled stages are\n * absent from the order), so this only covers within-stage branching.\n */\n static isStepEnabled(step: WidgetStep, ctx: FlowContext = {}): boolean {\n if (step === 'back_capture') return Flow.documentHasBack(ctx.documentType)\n if (step === 'ocr_processing') return workflowRunsOcr(ctx.workflow)\n if (step === 'active_liveness') return ctx.livenessMode === 'active'\n if (step === 'selfie_capture' || step === 'passive_liveness') {\n return ctx.livenessMode !== 'active'\n }\n return true\n }\n\n /**\n * The next enabled screen after `current`, honouring the workflow order and the\n * branch rules. Returns `current` when already at the end.\n *\n * @param current\n * @param ctx\n * @returns\n */\n static nextStep(current: WidgetStep, ctx: FlowContext = {}): WidgetStep {\n const order = Flow.stepOrder(ctx)\n const idx = order.indexOf(current)\n if (idx < 0) return current\n for (let i = idx + 1; i < order.length; i += 1) {\n const step = order[i]!\n if (Flow.isStepEnabled(step, ctx)) return step\n }\n return current\n }\n\n /**\n * Whether a session status is terminal (the flow is done).\n *\n * @param status\n * @returns\n */\n static isTerminal(status: VerificationStatus): boolean {\n return Flow.TERMINAL_STATUSES.includes(status)\n }\n\n /**\n * Map a terminal session status to the decision the integrator cares about.\n * Non-decision terminals (`expired`/`cancelled`) and in-flight statuses map to\n * `null`.\n */\n static statusToDecision(status: VerificationStatus): VerificationDecision | null {\n switch (status) {\n case 'approved':\n return 'approved'\n case 'rejected':\n return 'rejected'\n case 'requires_review':\n return 'requires_review'\n default:\n return null\n }\n }\n}\n"],"mappings":";;;AAsBA,MAAM,cAAqD;CACzD,UAAU;EAAC;EAAsB;EAAiB;EAAgB;CAAgB;CAClF,WAAW,CAAC;CACZ,SAAS,CAAC,iBAAiB,oBAAoB;CAC/C,UAAU;EAAC;EAAmB;EAAkB;CAAkB;CAClE,YAAY,CAAC,YAAY;AAC3B;;;;;AAMA,IAAa,OAAb,MAAa,KAAK;;;;;CAKhB,OAAgB,aAA2B;EACzC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;;;;CAKA,OAAgB,oBAA0C;EACxD;EACA;EACA;EACA;EACA;CACF;;;;;;;CAQA,OAAO,gBAAgB,MAAgD;EACrE,OAAO,QAAQ,QAAQ,SAAS;CAClC;;;;;;;CAQA,OAAO,UAAU,MAAmB,CAAC,GAAiB;EAIpD,OAAO;GAAC;GAAW,GAHJ,qBAAqB,IAAI,QACpB,CAAC,CAAC,SAAS,UAAU,YAAY,MAE1B;GAAG;GAAc;EAAQ;CACtD;;;;;;;;;CAUA,OAAO,cAAc,MAAkB,MAAmB,CAAC,GAAY;EACrE,IAAI,SAAS,gBAAgB,OAAO,KAAK,gBAAgB,IAAI,YAAY;EACzE,IAAI,SAAS,kBAAkB,OAAO,gBAAgB,IAAI,QAAQ;EAClE,IAAI,SAAS,mBAAmB,OAAO,IAAI,iBAAiB;EAC5D,IAAI,SAAS,oBAAoB,SAAS,oBACxC,OAAO,IAAI,iBAAiB;EAE9B,OAAO;CACT;;;;;;;;;CAUA,OAAO,SAAS,SAAqB,MAAmB,CAAC,GAAe;EACtE,MAAM,QAAQ,KAAK,UAAU,GAAG;EAChC,MAAM,MAAM,MAAM,QAAQ,OAAO;EACjC,IAAI,MAAM,GAAG,OAAO;EACpB,KAAK,IAAI,IAAI,MAAM,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;GAC9C,MAAM,OAAO,MAAM;GACnB,IAAI,KAAK,cAAc,MAAM,GAAG,GAAG,OAAO;EAC5C;EACA,OAAO;CACT;;;;;;;CAQA,OAAO,WAAW,QAAqC;EACrD,OAAO,KAAK,kBAAkB,SAAS,MAAM;CAC/C;;;;;;CAOA,OAAO,iBAAiB,QAAyD;EAC/E,QAAQ,QAAR;GACE,KAAK,YACH,OAAO;GACT,KAAK,YACH,OAAO;GACT,KAAK,mBACH,OAAO;GACT,SACE,OAAO;EACX;CACF;AACF"}
package/dist/index.d.mts CHANGED
@@ -3,7 +3,7 @@ import { ChallengeDetector, DEFAULT_TUNING, FaceAnalyzer, FaceSample, FaceTuning
3
3
  import { Theme } from "./theme.mjs";
4
4
  import { ArkycClient, ArkycClientOptions, ClientRealtime, ClientSession, ProviderSignalHints, WidgetApiError } from "./client.mjs";
5
5
  import { WidgetRealtimeClient, WidgetRealtimeFactory, WidgetRealtimeHandler, createWidgetRealtimeClient } from "./realtime.mjs";
6
- import { BaseWidgetOptions, MountWidgetOptions, RgbData, ViewHandlers, WidgetControllerConfig, WidgetEvent, WidgetEventListener, WidgetHandle } from "./types.mjs";
6
+ import { BaseWidgetOptions, MountWidgetOptions, RgbData, ViewHandlers, WidgetControllerConfig, WidgetErrorData, WidgetEvent, WidgetEventListener, WidgetEventMap, WidgetEventName, WidgetHandle, WidgetSessionTransitionData } from "./types.mjs";
7
7
  import { WidgetController } from "./controller.mjs";
8
8
  import { Camera, Facing } from "./capture.mjs";
9
9
  import { Flow, FlowContext } from "./flow.mjs";
@@ -15,5 +15,5 @@ import { ApiErrorKey, WidgetResult } from "@arkyc/types";
15
15
  declare const PACKAGE_NAME = "@arkyc/widget";
16
16
  declare const VERSION = "0.1.0";
17
17
  //#endregion
18
- export { type ApiErrorKey, ArkycClient, type ArkycClientOptions, ArkycWidget, BaseWidgetOptions, Camera, type ChallengeDetector, type ClientRealtime, type ClientSession, DEFAULT_DOCUMENT_TUNING, DEFAULT_TUNING, type DocRect, type DocumentAnalyzer, type DocumentSample, type DocumentTuning, type FaceAnalyzer, type FaceSample, type FaceTuning, type Facing, Flow, type FlowContext, MountWidgetOptions, PACKAGE_NAME, type ProviderSignalHints, RgbData, Theme, VERSION, ViewHandlers, WidgetApiError, type WidgetController, WidgetControllerConfig, WidgetEvent, WidgetEventListener, WidgetHandle, WidgetHandler, type WidgetRealtimeClient, type WidgetRealtimeFactory, type WidgetRealtimeHandler, type WidgetResult, analyzeDocumentGray, createDefaultDocumentAnalyzer, createDefaultFaceAnalyzer, createWidgetRealtimeClient, documentGuidance, isDocumentReady, isSelfieReady, makeChallengeDetector };
18
+ export { type ApiErrorKey, ArkycClient, type ArkycClientOptions, ArkycWidget, BaseWidgetOptions, Camera, type ChallengeDetector, type ClientRealtime, type ClientSession, DEFAULT_DOCUMENT_TUNING, DEFAULT_TUNING, type DocRect, type DocumentAnalyzer, type DocumentSample, type DocumentTuning, type FaceAnalyzer, type FaceSample, type FaceTuning, type Facing, Flow, type FlowContext, MountWidgetOptions, PACKAGE_NAME, type ProviderSignalHints, RgbData, Theme, VERSION, ViewHandlers, WidgetApiError, type WidgetController, WidgetControllerConfig, WidgetErrorData, WidgetEvent, WidgetEventListener, WidgetEventMap, WidgetEventName, WidgetHandle, WidgetHandler, type WidgetRealtimeClient, type WidgetRealtimeFactory, type WidgetRealtimeHandler, type WidgetResult, WidgetSessionTransitionData, analyzeDocumentGray, createDefaultDocumentAnalyzer, createDefaultFaceAnalyzer, createWidgetRealtimeClient, documentGuidance, isDocumentReady, isSelfieReady, makeChallengeDetector };
19
19
  //# sourceMappingURL=index.d.mts.map
@@ -1,5 +1,4 @@
1
1
  import { ClientRealtime } from "./client.mjs";
2
-
3
2
  //#region src/realtime.d.ts
4
3
  /** Fires for every event received on a subscribed channel. */
5
4
  type WidgetRealtimeHandler = (event: string, data: unknown) => void;
@@ -11,17 +10,20 @@ interface WidgetRealtimeClient {
11
10
  /** A factory that builds a push client from the session's realtime config. */
12
11
  type WidgetRealtimeFactory = (config: ClientRealtime, options: WidgetRealtimeOptions) => Promise<WidgetRealtimeClient | null>;
13
12
  interface WidgetRealtimeOptions {
14
- /** The client-token channel-auth endpoint (pusher). */
13
+ /** The client-token channel-auth endpoint. */
15
14
  authEndpoint: string;
16
15
  /** The session's client token (sent as `X-Client-Token` to the authorizer). */
17
16
  token: string;
18
17
  }
19
18
  /**
20
- * Connect to whichever push transport the API reported for this session. Returns
21
- * null for `polling`/`off`/`memory` (no push — the widget polls instead) and also
22
- * when the transport SDK can't be loaded (e.g. a plain `<script>` standalone embed
23
- * with no bundler), so realtime degrades gracefully to polling. The SDK is
24
- * dynamically imported so only the active transport is ever pulled in.
19
+ * Connect to the session's push transport. Returns null for transports the
20
+ * widget does not stream over (polling, and Firebase Cloud Messaging, which
21
+ * would prompt the end user for notification permission), and rejects when the
22
+ * transport SDK cannot load, so the caller polls instead.
23
+ *
24
+ * @param config
25
+ * @param options
26
+ * @returns
25
27
  */
26
28
  declare function createWidgetRealtimeClient(config: ClientRealtime, options: WidgetRealtimeOptions): Promise<WidgetRealtimeClient | null>;
27
29
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"realtime.d.mts","names":[],"sources":["../src/realtime.ts"],"mappings":";;;;KAGY,qBAAA,IAAyB,KAAA,UAAe,IAAa;AAAjE;AAAA,UAGiB,oBAAA;EACf,SAAA,CAAU,OAAA,UAAiB,OAAA,EAAS,qBAAqB;EACzD,UAAA;AAAA;AAFF;AAAA,KAMY,qBAAA,IACV,MAAA,EAAQ,cAAA,EACR,OAAA,EAAS,qBAAA,KACN,OAAA,CAAQ,oBAAA;AAAA,UAEI,qBAAA;EAV0C;EAYzD,YAAA;EAZU;EAcV,KAAK;AAAA;;;AAbK;AAIZ;;;;iBAuCsB,0BAAA,CACpB,MAAA,EAAQ,cAAA,EACR,OAAA,EAAS,qBAAA,GACR,OAAA,CAAQ,oBAAA"}
1
+ {"version":3,"file":"realtime.d.mts","names":[],"sources":["../src/realtime.ts"],"mappings":";;;KAKY,qBAAA,IAAyB,KAAA,UAAe,IAAa;AAAjE;AAAA,UAGiB,oBAAA;EACf,SAAA,CAAU,OAAA,UAAiB,OAAA,EAAS,qBAAqB;EACzD,UAAA;AAAA;AAFF;AAAA,KAMY,qBAAA,IACV,MAAA,EAAQ,cAAA,EACR,OAAA,EAAS,qBAAA,KACN,OAAA,CAAQ,oBAAA;AAAA,UAEI,qBAAA;EAV0C;EAYzD,YAAA;EAZU;EAcV,KAAK;AAAA;;;;;;;;;;;iBA8Ce,0BAAA,CACpB,MAAA,EAAQ,cAAA,EACR,OAAA,EAAS,qBAAA,GACR,OAAA,CAAQ,oBAAA"}
package/dist/realtime.mjs CHANGED
@@ -1,106 +1,88 @@
1
+ import { createRealtime } from "@arkstack/realtime";
2
+ import { REALTIME_EVENT } from "@arkyc/types";
1
3
  //#region src/realtime.ts
2
4
  /** CDN that serves any npm package as ESM (`…/pkg@ver/+esm`). */
3
5
  const CDN = "https://cdn.jsdelivr.net/npm/";
6
+ /** CDN paths for the SDK modules the realtime transports load. */
7
+ const CDN_PATHS = {
8
+ "pusher-js": "pusher-js@8/+esm",
9
+ "firebase/app": "firebase@11/app/+esm",
10
+ "firebase/auth": "firebase@11/auth/+esm",
11
+ "firebase/database": "firebase@11/database/+esm",
12
+ "firebase/messaging": "firebase@11/messaging/+esm"
13
+ };
14
+ /** The domain events the widget watches its session for. */
15
+ const SESSION_EVENTS = [REALTIME_EVENT.sessionTransition];
4
16
  /**
5
- * Import an optional transport SDK. Tries the bare package specifier first (a
6
- * bundler, import map, or Node resolves it), then falls back to the jsdelivr CDN
7
- * so a plain browser embed still loads it — a bare specifier like `pusher-js`
8
- * isn't resolvable at runtime in the browser, which would otherwise leave the
9
- * widget silently polling. Non-literal specifiers also keep the bundler from
10
- * trying to resolve these (they aren't hard dependencies) and avoid needing their
11
- * type declarations.
17
+ * Load a transport SDK by its package name (resolved by a bundler, import map or
18
+ * Node), falling back to jsDelivr so a plain `<script>` embed still gets it.
19
+ *
20
+ * @param specifier
21
+ * @returns
12
22
  */
13
- async function loadOptional(pkg, cdnPath) {
23
+ const importWithCdnFallback = async (specifier) => {
14
24
  try {
15
25
  return await import(
16
26
  /* @vite-ignore */
17
27
  /* webpackIgnore: true */
18
- pkg
28
+ specifier
19
29
  );
20
- } catch {
30
+ } catch (error) {
31
+ const path = CDN_PATHS[specifier];
32
+ if (!path) throw error;
21
33
  return await import(
22
34
  /* @vite-ignore */
23
35
  /* webpackIgnore: true */
24
- `${CDN}${cdnPath}`
36
+ `${CDN}${path}`
25
37
  );
26
38
  }
27
- }
39
+ };
28
40
  /**
29
- * Connect to whichever push transport the API reported for this session. Returns
30
- * null for `polling`/`off`/`memory` (no push — the widget polls instead) and also
31
- * when the transport SDK can't be loaded (e.g. a plain `<script>` standalone embed
32
- * with no bundler), so realtime degrades gracefully to polling. The SDK is
33
- * dynamically imported so only the active transport is ever pulled in.
41
+ * Connect to the session's push transport. Returns null for transports the
42
+ * widget does not stream over (polling, and Firebase Cloud Messaging, which
43
+ * would prompt the end user for notification permission), and rejects when the
44
+ * transport SDK cannot load, so the caller polls instead.
45
+ *
46
+ * @param config
47
+ * @param options
48
+ * @returns
34
49
  */
35
50
  async function createWidgetRealtimeClient(config, options) {
36
- try {
37
- if (config.transport === "pusher") return await createPusherClient(config, options);
38
- if (config.transport === "firebase") return await createFirebaseClient(config);
39
- } catch {
40
- return null;
41
- }
42
- return null;
43
- }
44
- async function createPusherClient(config, options) {
45
- const Pusher = (await loadOptional("pusher-js", "pusher-js@8/+esm")).default;
46
- const pusher = new Pusher(String(config.key ?? ""), {
47
- cluster: String(config.cluster ?? "mt1"),
48
- forceTLS: Boolean(config.forceTLS ?? false),
49
- ...config.wsHost ? {
50
- wsHost: String(config.wsHost),
51
- wsPort: Number(config.wsPort),
52
- wssPort: Number(config.wssPort ?? config.wsPort),
53
- enabledTransports: config.enabledTransports ?? ["ws", "wss"]
54
- } : {},
55
- channelAuthorization: {
56
- transport: "ajax",
57
- endpoint: options.authEndpoint,
58
- headers: { "X-Client-Token": options.token }
59
- }
51
+ if (config.transport !== "pusher" && config.transport !== "firebase-database") return null;
52
+ const authEndpoint = options.authEndpoint;
53
+ const auth = { headers: { "X-Client-Token": options.token } };
54
+ const pusher = config.pusher;
55
+ const firebaseDatabase = config["firebase-database"];
56
+ const client = createRealtime({
57
+ transport: config.transport,
58
+ pusher: pusher && {
59
+ ...pusher,
60
+ authEndpoint,
61
+ auth
62
+ },
63
+ "firebase-database": firebaseDatabase && {
64
+ ...firebaseDatabase,
65
+ authEndpoint,
66
+ auth
67
+ },
68
+ importModule: importWithCdnFallback
60
69
  });
70
+ await client.onPoll(() => {});
61
71
  return {
62
72
  subscribe(channel, handler) {
63
- const ch = pusher.subscribe(channel);
64
- const cb = (event, data) => handler(event, data);
65
- ch.bind_global(cb);
73
+ let active = true;
74
+ const stops = [];
75
+ for (const event of SESSION_EVENTS) client.listen(channel, event, (data) => handler(event, data)).then((stop) => active ? stops.push(stop) : stop()).catch(() => {});
66
76
  return () => {
67
- ch.unbind_global(cb);
68
- pusher.unsubscribe(channel);
77
+ active = false;
78
+ stops.forEach((stop) => stop());
69
79
  };
70
80
  },
71
81
  disconnect() {
72
- pusher.disconnect();
82
+ client.disconnect();
73
83
  }
74
84
  };
75
85
  }
76
- async function createFirebaseClient(config) {
77
- const [app, auth, database] = await Promise.all([
78
- loadOptional("firebase/app", "firebase@11/app/+esm"),
79
- loadOptional("firebase/auth", "firebase@11/auth/+esm"),
80
- loadOptional("firebase/database", "firebase@11/database/+esm")
81
- ]);
82
- const name = "arkyc-widget-realtime";
83
- const fbApp = app.getApps().some((a) => a.name === name) ? app.getApp(name) : app.initializeApp({
84
- apiKey: String(config.apiKey ?? ""),
85
- authDomain: String(config.authDomain ?? ""),
86
- projectId: String(config.projectId ?? ""),
87
- databaseURL: String(config.databaseURL ?? "")
88
- }, name);
89
- if (config.token) await auth.signInWithCustomToken(auth.getAuth(fbApp), config.token);
90
- const db = database.getDatabase(fbApp);
91
- const since = Date.now();
92
- return {
93
- subscribe(channel, handler) {
94
- const q = database.query(database.ref(db, `realtime/${channel}`), database.orderByChild("at"), database.startAt(since));
95
- const unsubscribe = database.onChildAdded(q, (snap) => {
96
- const value = snap.val();
97
- if (value) handler(value.event, value.payload);
98
- });
99
- return () => unsubscribe();
100
- },
101
- disconnect() {}
102
- };
103
- }
104
86
  //#endregion
105
87
  export { createWidgetRealtimeClient };
106
88
 
@@ -1 +1 @@
1
- {"version":3,"file":"realtime.mjs","names":[],"sources":["../src/realtime.ts"],"sourcesContent":["import type { ClientRealtime } from './client'\n\n/** Fires for every event received on a subscribed channel. */\nexport type WidgetRealtimeHandler = (event: string, data: unknown) => void\n\n/** A connected push transport. `subscribe` returns an unsubscribe function. */\nexport interface WidgetRealtimeClient {\n subscribe(channel: string, handler: WidgetRealtimeHandler): () => void\n disconnect(): void\n}\n\n/** A factory that builds a push client from the session's realtime config. */\nexport type WidgetRealtimeFactory = (\n config: ClientRealtime,\n options: WidgetRealtimeOptions,\n) => Promise<WidgetRealtimeClient | null>\n\nexport interface WidgetRealtimeOptions {\n /** The client-token channel-auth endpoint (pusher). */\n authEndpoint: string\n /** The session's client token (sent as `X-Client-Token` to the authorizer). */\n token: string\n}\n\n/** CDN that serves any npm package as ESM (`…/pkg@ver/+esm`). */\nconst CDN = 'https://cdn.jsdelivr.net/npm/'\n\n/**\n * Import an optional transport SDK. Tries the bare package specifier first (a\n * bundler, import map, or Node resolves it), then falls back to the jsdelivr CDN\n * so a plain browser embed still loads it — a bare specifier like `pusher-js`\n * isn't resolvable at runtime in the browser, which would otherwise leave the\n * widget silently polling. Non-literal specifiers also keep the bundler from\n * trying to resolve these (they aren't hard dependencies) and avoid needing their\n * type declarations.\n */\nasync function loadOptional(pkg: string, cdnPath: string): Promise<unknown> {\n try {\n return await import(/* @vite-ignore */ /* webpackIgnore: true */ pkg)\n } catch {\n return await import(/* @vite-ignore */ /* webpackIgnore: true */ `${CDN}${cdnPath}`)\n }\n}\n\n/**\n * Connect to whichever push transport the API reported for this session. Returns\n * null for `polling`/`off`/`memory` (no push — the widget polls instead) and also\n * when the transport SDK can't be loaded (e.g. a plain `<script>` standalone embed\n * with no bundler), so realtime degrades gracefully to polling. The SDK is\n * dynamically imported so only the active transport is ever pulled in.\n */\nexport async function createWidgetRealtimeClient(\n config: ClientRealtime,\n options: WidgetRealtimeOptions,\n): Promise<WidgetRealtimeClient | null> {\n try {\n if (config.transport === 'pusher') return await createPusherClient(config, options)\n if (config.transport === 'firebase') return await createFirebaseClient(config)\n } catch {\n // Transport SDK unavailable / failed to connect — fall back to polling.\n return null\n }\n\n return null\n}\n\n// --- pusher-js (minimal surface we depend on) ---\n\ninterface PusherChannel {\n bind_global(cb: (event: string, data: unknown) => void): void\n unbind_global(cb: (event: string, data: unknown) => void): void\n}\ninterface PusherInstance {\n subscribe(channel: string): PusherChannel\n unsubscribe(channel: string): void\n disconnect(): void\n}\ntype PusherCtor = new (key: string, options: Record<string, unknown>) => PusherInstance\n\nasync function createPusherClient(\n config: ClientRealtime,\n options: WidgetRealtimeOptions,\n): Promise<WidgetRealtimeClient> {\n const mod = (await loadOptional('pusher-js', 'pusher-js@8/+esm')) as { default: PusherCtor }\n const Pusher = mod.default\n const pusher = new Pusher(String(config.key ?? ''), {\n cluster: String(config.cluster ?? 'mt1'),\n forceTLS: Boolean(config.forceTLS ?? false),\n ...(config.wsHost\n ? {\n wsHost: String(config.wsHost),\n wsPort: Number(config.wsPort),\n wssPort: Number(config.wssPort ?? config.wsPort),\n enabledTransports: (config.enabledTransports as string[]) ?? ['ws', 'wss'],\n }\n : {}),\n // The widget authorizes private channels with its short-lived client token,\n // which the API scopes to this session's own channel only.\n channelAuthorization: {\n transport: 'ajax',\n endpoint: options.authEndpoint,\n headers: { 'X-Client-Token': options.token },\n },\n })\n\n return {\n subscribe(channel, handler) {\n const ch = pusher.subscribe(channel)\n const cb = (event: string, data: unknown) => handler(event, data)\n ch.bind_global(cb)\n\n return () => {\n ch.unbind_global(cb)\n pusher.unsubscribe(channel)\n }\n },\n disconnect() {\n pusher.disconnect()\n },\n }\n}\n\n// --- firebase (minimal surface we depend on) ---\n\ninterface FirebaseApp {\n name: string\n}\ninterface FirebaseAppModule {\n initializeApp(options: Record<string, unknown>, name: string): FirebaseApp\n getApps(): FirebaseApp[]\n getApp(name: string): FirebaseApp\n}\ninterface FirebaseAuthModule {\n getAuth(app: FirebaseApp): unknown\n signInWithCustomToken(auth: unknown, token: string): Promise<unknown>\n}\ninterface FirebaseSnapshot {\n val(): { event: string; payload: unknown } | null\n}\ninterface FirebaseDatabaseModule {\n getDatabase(app: FirebaseApp): unknown\n ref(db: unknown, path: string): unknown\n query(ref: unknown, ...constraints: unknown[]): unknown\n orderByChild(path: string): unknown\n startAt(value: number): unknown\n onChildAdded(query: unknown, cb: (snap: FirebaseSnapshot) => void): () => void\n}\n\nasync function createFirebaseClient(config: ClientRealtime): Promise<WidgetRealtimeClient> {\n const [app, auth, database] = (await Promise.all([\n loadOptional('firebase/app', 'firebase@11/app/+esm'),\n loadOptional('firebase/auth', 'firebase@11/auth/+esm'),\n loadOptional('firebase/database', 'firebase@11/database/+esm'),\n ])) as [FirebaseAppModule, FirebaseAuthModule, FirebaseDatabaseModule]\n\n const name = 'arkyc-widget-realtime'\n const fbApp = app.getApps().some((a) => a.name === name)\n ? app.getApp(name)\n : app.initializeApp(\n {\n apiKey: String(config.apiKey ?? ''),\n authDomain: String(config.authDomain ?? ''),\n projectId: String(config.projectId ?? ''),\n databaseURL: String(config.databaseURL ?? ''),\n },\n name,\n )\n\n // The per-session custom token authorizes reads of this session's channel only.\n if (config.token) await auth.signInWithCustomToken(auth.getAuth(fbApp), config.token)\n\n const db = database.getDatabase(fbApp)\n const since = Date.now()\n\n return {\n subscribe(channel, handler) {\n const q = database.query(\n database.ref(db, `realtime/${channel}`),\n database.orderByChild('at'),\n database.startAt(since),\n )\n const unsubscribe = database.onChildAdded(q, (snap) => {\n const value = snap.val()\n if (value) handler(value.event, value.payload)\n })\n\n return () => unsubscribe()\n },\n disconnect() {\n // Firebase listeners are torn down per-subscription via the returned unsub.\n },\n }\n}\n"],"mappings":";;AAyBA,MAAM,MAAM;;;;;;;;;;AAWZ,eAAe,aAAa,KAAa,SAAmC;CAC1E,IAAI;EACF,OAAO,MAAM;;;GAAoD;;CACnE,QAAQ;EACN,OAAO,MAAM;;;GAAoD,GAAG,MAAM;;CAC5E;AACF;;;;;;;;AASA,eAAsB,2BACpB,QACA,SACsC;CACtC,IAAI;EACF,IAAI,OAAO,cAAc,UAAU,OAAO,MAAM,mBAAmB,QAAQ,OAAO;EAClF,IAAI,OAAO,cAAc,YAAY,OAAO,MAAM,qBAAqB,MAAM;CAC/E,QAAQ;EAEN,OAAO;CACT;CAEA,OAAO;AACT;AAeA,eAAe,mBACb,QACA,SAC+B;CAE/B,MAAM,UAAS,MADI,aAAa,aAAa,kBAAkB,EAAA,CAC5C;CACnB,MAAM,SAAS,IAAI,OAAO,OAAO,OAAO,OAAO,EAAE,GAAG;EAClD,SAAS,OAAO,OAAO,WAAW,KAAK;EACvC,UAAU,QAAQ,OAAO,YAAY,KAAK;EAC1C,GAAI,OAAO,SACP;GACE,QAAQ,OAAO,OAAO,MAAM;GAC5B,QAAQ,OAAO,OAAO,MAAM;GAC5B,SAAS,OAAO,OAAO,WAAW,OAAO,MAAM;GAC/C,mBAAoB,OAAO,qBAAkC,CAAC,MAAM,KAAK;EAC3E,IACA,CAAC;EAGL,sBAAsB;GACpB,WAAW;GACX,UAAU,QAAQ;GAClB,SAAS,EAAE,kBAAkB,QAAQ,MAAM;EAC7C;CACF,CAAC;CAED,OAAO;EACL,UAAU,SAAS,SAAS;GAC1B,MAAM,KAAK,OAAO,UAAU,OAAO;GACnC,MAAM,MAAM,OAAe,SAAkB,QAAQ,OAAO,IAAI;GAChE,GAAG,YAAY,EAAE;GAEjB,aAAa;IACX,GAAG,cAAc,EAAE;IACnB,OAAO,YAAY,OAAO;GAC5B;EACF;EACA,aAAa;GACX,OAAO,WAAW;EACpB;CACF;AACF;AA4BA,eAAe,qBAAqB,QAAuD;CACzF,MAAM,CAAC,KAAK,MAAM,YAAa,MAAM,QAAQ,IAAI;EAC/C,aAAa,gBAAgB,sBAAsB;EACnD,aAAa,iBAAiB,uBAAuB;EACrD,aAAa,qBAAqB,2BAA2B;CAC/D,CAAC;CAED,MAAM,OAAO;CACb,MAAM,QAAQ,IAAI,QAAQ,CAAC,CAAC,MAAM,MAAM,EAAE,SAAS,IAAI,IACnD,IAAI,OAAO,IAAI,IACf,IAAI,cACF;EACE,QAAQ,OAAO,OAAO,UAAU,EAAE;EAClC,YAAY,OAAO,OAAO,cAAc,EAAE;EAC1C,WAAW,OAAO,OAAO,aAAa,EAAE;EACxC,aAAa,OAAO,OAAO,eAAe,EAAE;CAC9C,GACA,IACF;CAGJ,IAAI,OAAO,OAAO,MAAM,KAAK,sBAAsB,KAAK,QAAQ,KAAK,GAAG,OAAO,KAAK;CAEpF,MAAM,KAAK,SAAS,YAAY,KAAK;CACrC,MAAM,QAAQ,KAAK,IAAI;CAEvB,OAAO;EACL,UAAU,SAAS,SAAS;GAC1B,MAAM,IAAI,SAAS,MACjB,SAAS,IAAI,IAAI,YAAY,SAAS,GACtC,SAAS,aAAa,IAAI,GAC1B,SAAS,QAAQ,KAAK,CACxB;GACA,MAAM,cAAc,SAAS,aAAa,IAAI,SAAS;IACrD,MAAM,QAAQ,KAAK,IAAI;IACvB,IAAI,OAAO,QAAQ,MAAM,OAAO,MAAM,OAAO;GAC/C,CAAC;GAED,aAAa,YAAY;EAC3B;EACA,aAAa,CAEb;CACF;AACF"}
1
+ {"version":3,"file":"realtime.mjs","names":[],"sources":["../src/realtime.ts"],"sourcesContent":["import { createRealtime, type ModuleImporter, type RealtimeConfig } from '@arkstack/realtime'\nimport { REALTIME_EVENT } from '@arkyc/types'\nimport type { ClientRealtime } from './client'\n\n/** Fires for every event received on a subscribed channel. */\nexport type WidgetRealtimeHandler = (event: string, data: unknown) => void\n\n/** A connected push transport. `subscribe` returns an unsubscribe function. */\nexport interface WidgetRealtimeClient {\n subscribe(channel: string, handler: WidgetRealtimeHandler): () => void\n disconnect(): void\n}\n\n/** A factory that builds a push client from the session's realtime config. */\nexport type WidgetRealtimeFactory = (\n config: ClientRealtime,\n options: WidgetRealtimeOptions,\n) => Promise<WidgetRealtimeClient | null>\n\nexport interface WidgetRealtimeOptions {\n /** The client-token channel-auth endpoint. */\n authEndpoint: string\n /** The session's client token (sent as `X-Client-Token` to the authorizer). */\n token: string\n}\n\n/** CDN that serves any npm package as ESM (`…/pkg@ver/+esm`). */\nconst CDN = 'https://cdn.jsdelivr.net/npm/'\n\n/** CDN paths for the SDK modules the realtime transports load. */\nconst CDN_PATHS: Record<string, string> = {\n 'pusher-js': 'pusher-js@8/+esm',\n 'firebase/app': 'firebase@11/app/+esm',\n 'firebase/auth': 'firebase@11/auth/+esm',\n 'firebase/database': 'firebase@11/database/+esm',\n 'firebase/messaging': 'firebase@11/messaging/+esm',\n}\n\n/** The domain events the widget watches its session for. */\nconst SESSION_EVENTS = [REALTIME_EVENT.sessionTransition]\n\n/**\n * Load a transport SDK by its package name (resolved by a bundler, import map or\n * Node), falling back to jsDelivr so a plain `<script>` embed still gets it.\n *\n * @param specifier\n * @returns\n */\nexport const importWithCdnFallback: ModuleImporter = async (specifier) => {\n try {\n return await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier)\n } catch (error) {\n const path = CDN_PATHS[specifier]\n if (!path) throw error\n\n return await import(/* @vite-ignore */ /* webpackIgnore: true */ `${CDN}${path}`)\n }\n}\n\n/**\n * Connect to the session's push transport. Returns null for transports the\n * widget does not stream over (polling, and Firebase Cloud Messaging, which\n * would prompt the end user for notification permission), and rejects when the\n * transport SDK cannot load, so the caller polls instead.\n *\n * @param config\n * @param options\n * @returns\n */\nexport async function createWidgetRealtimeClient(\n config: ClientRealtime,\n options: WidgetRealtimeOptions,\n): Promise<WidgetRealtimeClient | null> {\n if (config.transport !== 'pusher' && config.transport !== 'firebase-database') return null\n\n const authEndpoint = options.authEndpoint\n const auth = { headers: { 'X-Client-Token': options.token } }\n const pusher = config.pusher as RealtimeConfig['pusher']\n const firebaseDatabase = config['firebase-database'] as RealtimeConfig['firebase-database']\n\n const client = createRealtime({\n transport: config.transport,\n pusher: pusher && { ...pusher, authEndpoint, auth },\n 'firebase-database': firebaseDatabase && { ...firebaseDatabase, authEndpoint, auth },\n importModule: importWithCdnFallback,\n })\n\n // Connect now, so a transport that cannot load falls back to polling.\n await client.onPoll(() => {})\n\n return {\n subscribe(channel, handler) {\n let active = true\n const stops: Array<() => void> = []\n\n for (const event of SESSION_EVENTS) {\n client\n .listen(channel, event, (data) => handler(event, data))\n .then((stop) => (active ? stops.push(stop) : stop()))\n .catch(() => {\n // An unauthorized or unavailable channel leaves the session to the poll budget.\n })\n }\n\n return () => {\n active = false\n stops.forEach((stop) => stop())\n }\n },\n disconnect() {\n void client.disconnect()\n },\n }\n}\n"],"mappings":";;;;AA2BA,MAAM,MAAM;;AAGZ,MAAM,YAAoC;CACxC,aAAa;CACb,gBAAgB;CAChB,iBAAiB;CACjB,qBAAqB;CACrB,sBAAsB;AACxB;;AAGA,MAAM,iBAAiB,CAAC,eAAe,iBAAiB;;;;;;;;AASxD,MAAa,wBAAwC,OAAO,cAAc;CACxE,IAAI;EACF,OAAO,MAAM;;;GAAoD;;CACnE,SAAS,OAAO;EACd,MAAM,OAAO,UAAU;EACvB,IAAI,CAAC,MAAM,MAAM;EAEjB,OAAO,MAAM;;;GAAoD,GAAG,MAAM;;CAC5E;AACF;;;;;;;;;;;AAYA,eAAsB,2BACpB,QACA,SACsC;CACtC,IAAI,OAAO,cAAc,YAAY,OAAO,cAAc,qBAAqB,OAAO;CAEtF,MAAM,eAAe,QAAQ;CAC7B,MAAM,OAAO,EAAE,SAAS,EAAE,kBAAkB,QAAQ,MAAM,EAAE;CAC5D,MAAM,SAAS,OAAO;CACtB,MAAM,mBAAmB,OAAO;CAEhC,MAAM,SAAS,eAAe;EAC5B,WAAW,OAAO;EAClB,QAAQ,UAAU;GAAE,GAAG;GAAQ;GAAc;EAAK;EAClD,qBAAqB,oBAAoB;GAAE,GAAG;GAAkB;GAAc;EAAK;EACnF,cAAc;CAChB,CAAC;CAGD,MAAM,OAAO,aAAa,CAAC,CAAC;CAE5B,OAAO;EACL,UAAU,SAAS,SAAS;GAC1B,IAAI,SAAS;GACb,MAAM,QAA2B,CAAC;GAElC,KAAK,MAAM,SAAS,gBAClB,OACG,OAAO,SAAS,QAAQ,SAAS,QAAQ,OAAO,IAAI,CAAC,CAAC,CACtD,MAAM,SAAU,SAAS,MAAM,KAAK,IAAI,IAAI,KAAK,CAAE,CAAC,CACpD,YAAY,CAEb,CAAC;GAGL,aAAa;IACX,SAAS;IACT,MAAM,SAAS,SAAS,KAAK,CAAC;GAChC;EACF;EACA,aAAa;GACX,OAAY,WAAW;EACzB;CACF;AACF"}