@waler/sdk
v0.5.0
Published
The surface a Waler app talks to the shell through.
Readme
@waler/sdk
The SDK a Waler app embeds. A Waler app is a web page that a Waler shell loads in an iframe on the web and in a native WebView on iOS and Android; this package is how that page talks to the shell — session, context, tokens, navigation, device capabilities and events, over one bridge that behaves the same wherever the shell runs.
TypeScript, ESM, no runtime dependencies. Works with React, Vue, Svelte or plain HTML — the SDK has no opinion about your framework.
Install
npm install @waler/sdkQuickstart
import { createSdk } from '@waler/sdk'
const Waler = createSdk({
// Your app's registered id.
appId: 'my-app',
// The shell origins allowed to host this surface. A security boundary, not
// configuration — see "Origins" below.
allowedOrigins: ['https://tenant-one.example.com'],
})
// Resolves once the shell has answered. From here on, `context` is filled in
// and `capabilities` is populated.
const { api, context, theme, locale } = await Waler.ready
console.log(context.tenantId, context.userId, context.activeAudience)ready rejects rather than resolving half-way. If it resolves, you have a live
bridge and a real context; you never have to null-check what it returned.
Calling the platform
The browser never calls the platform API. getToken() returns the session of
the person using your app, valid for a few minutes; send it to your own backend,
which calls the platform with your app token and that session.
const { token } = await Waler.getToken()
const response = await fetch('/api/some-resource', {
headers: { Authorization: `Bearer ${token}` },
})Your backend validates the session against the platform's JWKS before trusting
it. Keep the platform address and issuer in your backend's own configuration:
api in the handshake is informational, and a value taken from the browser
would let a forged one decide where your app token goes.
Sessions are held for you
Calling getToken() before every request is the right pattern: the result is
kept until close to its expiry, so asking twice does not issue two sessions.
Calls made at the same time share one issuance rather than one each.
It is memory only — nothing is written to localStorage or sessionStorage,
and nothing survives a reload.
// Skips what is held and asks for a newly issued one.
const fresh = await Waler.getToken({ forceRefresh: true })What is held is dropped whenever contextchange or audiencechange arrives — a
session issued under the previous context is still accepted, carrying the
previous answer, which is a failure with nothing to see. So a getToken() from
inside those handlers gets a new session, not the one you had.
Confirming a write
Some writes need the person's explicit approval. When the platform answers one with a confirmation id, hand it to the shell: it shows the person what will happen, lets them edit what may be edited, and resolves with a single-use proof and the arguments as approved. Repeat the call with both.
const { token, args } = await Waler.requestConfirmation(confirmationId)It rejects with CANCELLED if the person declines.
Feature detection, never version comparison
Your app is inside several shells at once, each on a different version, while the app itself is always on its latest deploy. So ask what the shell can do, never what version it is.
if (Waler.capabilities.has('scanBarcode')) {
const { value } = await Waler.scanBarcode()
fillField(value)
} else {
showManualEntryField()
}ready returns a shellVersion. It is for telemetry. Gating a feature on it is
a bug that only shows up in the tenants that have not updated.
Capability-gated methods — notify, download, share, scanBarcode,
getGeolocation — reject with UNSUPPORTED if you call them without checking.
Origins
allowedOrigins is the trust boundary, and the only thing standing between your
surface and a page that embedded it without permission.
- These are the only origins the SDK will send to, each addressed explicitly.
- Messages from any other origin are dropped silently, before anything reads them.
- There is no wildcard.
'*'and'https://*.example.com'are both refused — the first is not an origin, the second matches nothing and would leave you with an allowlist you believe covers every tenant and that in fact covers none. - An allowlist with nothing usable in it fails immediately with
INVALID_ARGUMENT, rather than hanging until the handshake times out.
List the shell origins of the tenants your app is installed in:
createSdk({
appId: 'my-app',
allowedOrigins: ['https://tenant-one.example.com', 'https://tenant-two.example.com'],
})On iOS and Android the shell opens your app in a native WebView of its own, and
allowedOrigins is not used there. The native shell checks your app's origin
against surfaces[].allowedOrigins in your manifest before it puts the bridge in
the page, and only the main frame gets it.
Events
const off = Waler.on('contextchange', (context) => {
// The shell resolved a different context, without reloading the surface.
render(context)
})
// Call `off()` when your view goes away.| Event | When |
| ------------------ | ---------------------------------------------------------------- |
| contextchange | the shell resolved a different context |
| audiencechange | the user switched to another population |
| commandupdate | a write this app started has a result |
| themechange | the tenant's theme changed, or the system toggled dark mode |
| localechange | language or timezone changed |
| visibilitychange | the surface left or came back — pause polling when invisible |
Two things to assume about commandupdate, because writes are asynchronous:
it may never arrive (rebuild state when your view opens), and it may arrive
twice (handle it idempotently, keyed on commandId). Without both, a
"processing…" spinner can run forever with no error anywhere.
Errors
Everything rejects with an SdkError carrying a code:
import type { SdkError } from '@waler/sdk'
try {
await Waler.scanBarcode()
} catch (error) {
const { code } = error as SdkError
if (code === 'UNSUPPORTED') showManualEntryField()
else if (code === 'CANCELLED') return
else throw error
}| Code | Meaning |
| ------------------ | -------------------------------------------------------------- |
| UNSUPPORTED | this shell does not offer the method or capability — fall back |
| FORBIDDEN | not granted in the manifest, or denied |
| CANCELLED | the user backed out |
| TIMEOUT | the shell did not answer in time |
| INVALID_ARGUMENT | bad input, including an unusable allowedOrigins |
| NOT_IN_SHELL | running outside the shell |
| INTERNAL | everything else |
Treat the list as open: a newer shell may send a code this version has never
heard of, and it reaches you unchanged rather than flattened. Always keep an
else.
The stack on these errors points at your await, not at the SDK's internals.
Running without a shell
Open your app directly in a browser tab and the SDK falls back to a null
transport: everything rejects with NOT_IN_SHELL. That is a designed path, not
a failure — it is how you develop and test without standing up a shell.
import type { SdkError } from '@waler/sdk'
try {
const { context } = await Waler.ready
render(context)
} catch (error) {
if ((error as SdkError).code === 'NOT_IN_SHELL') render(mockContext)
else throw error
}Surface
| Member | |
| -------------------------- | ----------------------------------------------------------- |
| ready | Promise<ReadyPayload> — resolves only over a live bridge |
| context | current ShellContext, or null before ready resolves |
| capabilities | .has(name) and .list() |
| getToken(request?) | the person's session, for your own backend |
| requestConfirmation(id) | asks the person to approve a write, resolves with the proof |
| openApp(key, params?) | opens another surface, resolves with what it returned |
| navigate(path) | tells the shell your current route, for deep links and back |
| close(result?) | closes this surface, resolving whoever opened it |
| setHeader(state) | title, badge, or hide the native header |
| on(event, handler) | returns an unsubscribe function |
| invoke(method, payload?) | escape hatch for methods newer than this SDK |
navigate() and setHeader() are explicit on purpose: the SDK does not patch
history.pushState or watch <title> to infer them, because it will not rewrite
globals it does not own inside your app.
Everything is typed. ReadyPayload, ShellContext, SdkError, Capability,
SdkEventMap and the rest are exported from the package root.
Versioning and compatibility
This package follows semver. A breaking change is a major, and what it replaces is deprecated first: the old member keeps working and the docs say what to use instead, until the major removes it. Pin what you install and upgrade when it suits you.
That covers the package. It does not cover the shell, and the difference is the one thing worth understanding here.
Your app does not choose which shell loads it. The shell is an installed application that may be older than your app, and several ages of it are in use at the same time. So a version comparison cannot help you — the shell is not a dependency you picked, it is the thing you are running inside of.
Ask what it can do, never how old it is:
if (Waler.capabilities.has('scanBarcode')) await Waler.scanBarcode()
else showManualEntryField()The bridge is built so that this always has an answer. A method the shell does
not know rejects with UNSUPPORTED instead of hanging, and an error code this
package does not know reaches your catch unchanged. Neither is a promise about
a period of time; both are how the bridge behaves, in every version.
Requirements
Any browser the shell supports. Node 20+ if you import it in a build step. Published as ESM only, with no runtime dependencies.
