@ianmenethil/zp-observer
v0.1.5
Published
Telemetry runtime for Zenith hosted checkout — session lifecycle, fingerprint, HPP, heartbeat, and outcome events.
Maintainers
Readme
zp-observer v2
ZenPay/HCP lifecycle helper — iframe detection, modal open/close signals, heartbeat dead detection, and safe onPluginClose wiring. Browser-focused, HCP-aware, zero hardcoded endpoint names or payload schemas.
Install
npm install @ianmenethil/zp-observerQuick start
import {
createLifecycleController,
watchIframe,
onPageHide,
openHostedCheckout,
} from "@ianmenethil/zp-observer";
import { zpPayment } from "@ianmenethil/zp-hcp";
// 1. Create lifecycle controller with your endpoints and payloads
const lifecycle = createLifecycleController({
identifier: mupid,
config: {
openAction: {
endpoint: () => "/api/events/ping",
body: (ctx) => ({ sessionId: ctx.identifier, signal: ctx.signal, device: ctx.device }),
},
closeAction: {
endpoint: () => "/api/events/pong",
body: (ctx) => ({ sessionId: ctx.identifier, signal: ctx.signal, reason: ctx.reason, device: ctx.device }),
},
updateAction: {
endpoint: () => "/api/events/ping",
body: (ctx) => ({ sessionId: ctx.identifier, signal: ctx.signal, device: ctx.device, ...(ctx.meta ?? {}) }),
},
},
});
// 2. Auto-detect ZenPay iframe (uses built-in selectors for all brands)
const iframeWatcher = watchIframe(
(iframeSrc) => lifecycle.handleOpened(iframeSrc),
{ onLoad: (iframeSrc) => lifecycle.handleReady(iframeSrc) },
);
// 3. Pagehide → ambiguous unload (not a close)
const pageHideHandle = onPageHide((_persisted) => {
lifecycle.handleUpdate("page_unloading");
});
// 4. Safe close wiring
const payment = openHostedCheckout(
zpPayment,
{
url: "https://pay.travelpay.com.au/online/v5",
merchantUniquePaymentId: mupid,
// ... other plugin options ...
},
() => lifecycle.handleClosed("plugin_closed"),
);API
Transport
post(options) — send a JSON payload via fetch or sendBeacon.
type DeliveryMode = "fetch" | "beacon" | "auto";
post({
url: string;
body: Record<string, unknown>;
headers?: Record<string, string>;
timeoutMs?: number;
delivery?: DeliveryMode;
credentials?: RequestCredentials;
}): Promise<{ ok: boolean; status?: number; error?: string }>Beacon is never used when custom headers are present (beacon API cannot attach headers).
Lifecycle controller
createLifecycleController(options) — orchestrates open/close/update actions with single-fire guards.
createLifecycleController({
identifier: string;
config: {
openAction: LifecycleAction;
closeAction: LifecycleAction;
updateAction: LifecycleAction;
};
transport?: typeof post; // defaults to post
signals?: LifecycleSignals; // custom signal label overrides
now?: () => number; // clock override
deviceFp?: string; // optional device fingerprint (from zp-devicefp etc.)
}): LifecycleController
// LifecycleController
{
handleOpened(iframeSrc: string | null): Promise<PostResult>;
handleReady(iframeSrc: string | null): Promise<PostResult>;
handleClosed(reason?: string, meta?: Record<string, unknown>): Promise<PostResult>;
handleUpdate(signal: string, meta?: Record<string, unknown>): Promise<PostResult>;
wasOpened(): boolean;
wasClosed(): boolean;
}
// LifecycleAction
{
endpoint: (ctx: LifecycleActionContext) => string;
body: (ctx: LifecycleActionContext) => Record<string, unknown>;
headers?: (ctx: LifecycleActionContext) => Record<string, string>;
delivery?: (ctx: LifecycleActionContext) => DeliveryMode;
}
// LifecycleActionContext
{
identifier: string;
signal: string;
reason?: string;
iframeSrc?: string | null;
meta?: Record<string, unknown>;
now: () => number;
device: DeviceContext;
}DeviceContext
Auto-captured on every lifecycle action dispatch:
| Field | Source | Notes |
|---|---|---|
| capturedAt | new Date().toISOString() | Always present |
| userAgent | navigator.userAgent | |
| pageUrl | location.href | |
| referrer | document.referrer | |
| visibilityState | document.visibilityState | |
| online | navigator.onLine | null when unavailable |
| screenWidth | screen.width | |
| screenHeight | screen.height | |
| timezone | Intl.DateTimeFormat().resolvedOptions().timeZone | |
| language | navigator.language | |
| deviceFp | Consumer-supplied | Only present when deviceFp was passed to createLifecycleController |
Adapters
watchIframe(onDetected, options?) — single-fire iframe detection. Defaults to ZP_IFRAME_SELECTORS (all known ZenPay brand domains). Override with options.selectors.
watchIframe(
(iframeSrc: string | null) => void,
options?: {
selectors?: string[];
intervalMs?: number;
timeoutMs?: number;
onTimeout?: () => void;
onLoad?: (iframeSrc: string | null) => void;
}
): { stop: () => void }onPageHide(callback) — listens for pagehide and passes the persisted flag.
onPageHide((persisted: boolean) => void): { uninstall: () => void }openHostedCheckout(factory, options, onClose) — safe onPluginClose wiring. Passes a string callback id to the plugin (WAF-safe), then swaps in a real function after open().
openHostedCheckout(
factory: (options: Record<string, unknown>) => HostedCheckoutInstance,
options: { merchantUniquePaymentId: string } & Record<string, unknown>,
onClose: () => void
): HostedCheckoutInstancebuildCloseCallbackId(identifier) — creates a sanitised callback id from a MUPID.
Heartbeat
startHeartbeat(options) — dead-detection via interval probes. Fires onDead once at threshold; does not emit healthy heartbeats.
startHeartbeat({
intervalMs: number;
missThreshold: number;
probe: () => Promise<{ ok: boolean }>;
onDead: () => void;
}): { stop: () => void; missedBeats: () => number }Constants
ZP_IFRAME_SELECTORS — default iframe selectors covering all ZenPay brands (b2bpay, zenithpayments, travelpay, schooleasypay, rentalrewards, thoroughbredpayments, childcareeasypay).
CDN
<script src="https://cdn.jsdelivr.net/npm/@ianmenethil/zp-observer/dist/cdn/zp.obs.min.js"></script>
<script>
const { createLifecycleController, watchIframe } = window.ZPObserver;
</script>Signal boundaries
The observer runs on the merchant parent page and can only observe: plugin launch, iframe DOM insertion, iframe load / v6 onLoad, onPluginClose, and parent pagehide. It cannot observe card form progress, 3DS progress, payment submission, or any hosted-page internal state inside the cross-origin iframe.
Build
bun run build # check → test → dist/npm + dist/cdn
bun test # 58 testsLicense
MIT
