@uengage.io/browser-sdk
v2.1.0
Published
Browser bundles for the uEngage platform APIs. One IIFE per service for pages with no build step (served from cdn.platform.uengage.io), plus ESM/CJS entry points for bundled apps. Token minting, refresh-before-expiry and transport degradation are internal
Maintainers
Readme
@uengage.io/browser-sdk
Browser bundles for the uEngage platform APIs, for pages that have no build
step — a PHP/CI4 template, a legacy admin screen, a white-label storefront.
One <script> tag, one createClient call, done.
Bundles are served from https://cdn.platform.uengage.io and also published to
npm for apps that do bundle.
| Bundle | Global | What it does |
| ---------- | ------------------ | ------------------------------------------- |
| realtime | Uengage.realtime | Live order updates from services/realtime |
Restricted realtime channels
Use tokenUrl to obtain a channel-restricted token from your backend, then call
client.subscribe('delivery-updates', ...). The backend must authorize the user
and mint the token with the platform SDK's auth.mintRealtimeToken helper.
An ordinary service token cannot substitute for the restricted grant on named channels.
Names are case-sensitive identifiers, not paths or wildcards. Tokens permit exactly one name, are valid for 60–900 seconds (300 by default), and cannot publish or call other platform APIs. Minting does not create a publisher. See the complete integration and authorization guide.
Quick start
<script
src="https://cdn.platform.uengage.io/browser-sdk/1.0.0/realtime.min.js"
integrity="sha384-…"
crossorigin="anonymous"
></script>
<script>
var rt = Uengage.realtime.createClient({
env: 'prod',
token: grant.token, // minted by your backend, already in the page
transport: 'auto',
});
var sub = rt.subscribe('/orders/' + orderId, {
onUpdate: function (event, meta) {
// meta.origin is 'live' or 'snapshot'; the data is the same either way.
render(event.data);
},
onError: function (err) {
console.warn(err.code, err.message);
},
});
// sub.unsubscribe(); rt.close();
</script>The integrity value above is a placeholder — the real hash is only known once
the release build runs, and the publish job prints it into its GitHub Actions
summary. Copy it from there; a stale hash blocks the script outright.
Pin the exact version and keep the integrity hash. The floating
/browser-sdk/v1/ alias exists for emergency rollout, but a floating URL cannot
be used with integrity, and SRI is what stops a compromised bucket from
injecting script into every embedding site. The release job prints the hash for
each version into its GitHub Actions summary.
There is no bundle.init() and no polling loop to write. Token handling,
refresh, reconnect, catch-up after a dropped connection and de-duplication all
happen inside subscribe.
Options
| Option | Default | Notes |
| --------------------- | -------- | ----------------------------------------------------------------------- |
| env | — | 'dev' \| 'uat' \| 'prod'. Resolves API and AppSync endpoints. |
| token | — | The token your page already holds. The usual choice. |
| getToken | — | Supplies the token, for a screen that outlives one. See below. |
| tokenUrl | — | Same-origin GET returning { token, expiresIn }; the SDK fetches it. |
| serviceId | — | OAuth2 client id. With serviceSecret. |
| serviceSecret | — | OAuth2 client secret. Present in the page — read the security note. |
| transport | 'auto' | 'auto' \| 'live' \| 'poll' \| 'proxy' |
| snapshotUrl | — | Required by 'proxy'. Same-origin route on your backend. |
| pollIntervalMs | 5000 | Poll cadence when polling. |
| degradeAfter | 3 | Consecutive dead sockets before 'auto' falls back to polling. |
| apiBase | — | Overrides env, for a preview stack. |
| endpoints | — | Explicit AppSync endpoints, for a stack not in the built-in table. |
| maxReconnectDelayMs | 30000 | Backoff ceiling for socket reconnects. |
Pass exactly one of token, getToken, tokenUrl, or
serviceId+serviceSecret — transport: 'proxy' takes none of them. Supplying
two is refused rather than resolved by a precedence rule.
token is the normal shape: your backend authorizes the user, mints a scoped
token, and the page receives it before it builds a client. Reach for getToken
only when a screen outlives one token — it is asked whenever the client needs a
fresh one, roughly once per token lifetime and always before the current one
expires, so returning a newer value is all a long-lived screen has to do. It is
not called on every subscribe; the refresh scheduler caches in between.
An expired token fails once, permanently, naming getToken as the fix — a
literal cannot change, so retrying it would only stall. A getToken that throws
is retried, like tokenUrl; an app that knows the session is gone opts into the
permanent latch by throwing a UengageBrowserError itself.
subscribe(channel, { onUpdate, onError }) returns { channel, unsubscribe() }.
onUpdate receives the platform event envelope and { channel, origin }.
Reading once, on demand
getLatest(channel) is the "refresh button": one read of the channel's latest
value, resolving to null when nothing is cached.
document.querySelector('#refresh').addEventListener('click', async function () {
try {
var event = await rt.getLatest('/orders/' + orderId);
if (event) render(event.data);
else showNoUpdatesYet();
} catch (err) {
showError(err.message); // err.permanent tells you whether to offer a retry
}
});It opens no socket and starts no polling, so a page that only ever calls
getLatest holds no connection at all. It also does not fire onUpdate on a
subscription to the same channel — you already have the value in hand, and
delivering it twice would be a surprise.
null is also the answer for a channel the caller cannot see. The service
returns the same 404 for "nothing cached" and "not yours", deliberately, so
sequential order ids cannot be walked to discover which ones exist.
In 'proxy' mode it reads your snapshotUrl and no platform credential is
involved.
Errors
onError receives a UengageBrowserError with a stable code and a
permanent flag. permanent means retrying cannot help — surface it rather
than hiding it behind a spinner.
| code | permanent | Meaning |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------- |
| invalid_options | yes | Thrown synchronously from createClient. |
| auth_failed | yes | The credential was rejected, or getToken returned a non-string. |
| session_expired | yes | The session is gone: a 401/403 from tokenUrl, an expired token, or a UengageBrowserError your getToken threw. |
| auth_unavailable | no | Network or 5xx while minting, including a getToken that threw. |
| snapshot_failed | no | A snapshot read failed. |
| forbidden | yes | The channel was rejected by the namespace policy. |
| transport_degraded | maybe | Live updates unavailable; the client fell back to polling. |
Transports
| Mode | Behaviour |
| --------- | ---------------------------------------------------------------------------------------------------------------- |
| 'auto' | WebSocket, falling back to polling after degradeAfter dead sockets and recovering automatically. Use this. |
| 'live' | WebSocket only. For debugging. |
| 'poll' | Poll the platform snapshot route only. |
| 'proxy' | Poll a route on your backend. No platform credential in the page at all. |
'auto' exists because corporate proxies and some mobile carriers block wss:
outright, and that is only discoverable by trying. The fallback reads the same
last-value cache the socket would have delivered from, so the data is identical
and only the latency changes.
How it stays authenticated
Platform tokens live about 15 minutes. AppSync validates the JWT when the connection opens and on every subscribe frame, and closes the socket once the token behind it lapses — AppSync Events has no way to re-authenticate a live connection, so a reconnect roughly every 15 minutes is unavoidable. The client makes it invisible:
- The token is cached in memory and never handed out with less than 60 seconds of life left.
- Concurrent callers share one in-flight mint, so eight subscriptions produce one token rather than eight.
- A background timer refreshes 120 seconds before expiry, so the forced reconnect finds a token already in hand instead of paying a round-trip inside its backoff window.
- On reconnect the SDK resubscribes every channel and re-reads each snapshot; ULID ordering drops anything already delivered, so nothing renders twice and nothing is missed.
- A hidden tab stops refreshing entirely, and catches up when it comes back.
Bad credentials are latched, not retried. A wrong serviceId fails identically
every time, and retrying from every page view would turn a copy-paste mistake
into sustained load on the auth service.
Security: the secret is public
Prefer token — your backend authorizes the user and mints a scoped token, and
nothing secret reaches the page at all. The rest of this section is about what
you avoid by doing so.
A serviceSecret passed to createClient is in the page and readable by
anyone who opens devtools or fetches the bundle. It also mints tokens whose
subject is service:*, and services/realtime skips its tenant-ownership check
for service principals — so an extracted credential can read orders across
tenants, and can mint fresh tokens for as long as the registry entry lives.
If that is not acceptable for a given surface, both alternatives keep the same page code:
tokenUrl— your backend mints against its own session and returns{ token, expiresIn }. A stolen response is worth only the minutes left on it.transport: 'proxy'— nothing platform-related reaches the page. Your backend authorizes from its own session and calls the platform server-side, which is also the only way to enforce per-customer ownership today.
See examples/ci4/ for both, wired up with packages/platform-sdk-php.
If you do embed a secret
- Register a dedicated client per embedding site, with the narrowest
allowedScopesthat works. Scopes do not gate the realtime routes, but the same JWT is accepted by wallet, business, zones and audit — narrow scopes are what stop a leaked storefront credential from reaching those. - Never reuse a credential across environments.
- Rate-limit and alarm on
/auth/business/oauth/token: every page view mints a token, and there is no cross-page cache by design.
Rotation runbook
Rotating is a multi-site deploy, not a single action. Know that before you need it:
- Register a replacement client and confirm it works against uat.
- Cut a new browser-sdk version if anything in the bundle changed.
- Update the credential in every embedding site and deploy each one.
- Only once every site is confirmed on the new credential, disable the old registry entry — deleting it first takes every un-migrated site down.
npm
pnpm add @uengage.io/browser-sdkimport { createClient } from '@uengage.io/browser-sdk/realtime';Apps that already bundle can equally use @uengage.io/platform-sdk/realtime
directly; this package adds the token lifecycle and transport fallback on top.
Publishing
| Target | Trigger | Goes to npm | Overwrite |
| ------------- | ------------------------------------------------------------- | ----------- | -------------------------------------------------------- |
| prod | tag browser-sdk-v<version> | yes | never — versions are immutable |
| uat / any env | Publish browser-sdk workflow, Run workflow → pick the env | no | allowed, so a branch can be re-published while iterating |
The dispatch path exists to put a build in front of someone on UAT before it is
tagged. It never publishes to npm — a tag is the only way onto npm. Both paths
assume the target environment's existing AWS_DEPLOY_ROLE_ARN, so publishing
needs no IAM of its own.
Overwriting on a non-prod CDN invalidates any SRI hash taken from an earlier publish of that same version, so re-copy the hash from the job summary after each dispatch.
Adding a bundle for another service
src/bundles/<service>/index.ts, exportingcreateClient.- An entry in
bundles.config.json. - Tests under
test/bundles/<service>/. - An
exportsentry inpackage.json, and tagbrowser-sdk-v<next>.
No new package, workflow, CI job, IAM role, CDN behaviour or DNS record — the
distribution serves /browser-sdk/** and the release job walks the manifest.
Everything in src/core/ (auth, refresh, HTTP retry, errors) is already shared.
All bundles ship under one version. Published paths are immutable and never deleted, so a version bump nobody needed costs nobody anything.
Local
pnpm --filter @uengage.io/browser-sdk test
pnpm --filter @uengage.io/browser-sdk build
pnpm --filter @uengage.io/browser-sdk check:browser-safe # asserts what ships
pnpm --filter @uengage.io/browser-sdk sri # sizes + <script> tagsrealtime-demo/ at the repo root is a working harness against uat: pnpm check
verifies the environment (OIDC discovery, the snapshot route, credentials) and
pnpm dev serves a page you can point at a locally built bundle.
