SDK for building the web front end of a sift — a Red Sift app that runs inside the Red Sift Cloud shell.
A sift's front end is two halves that never talk to each other directly:
┌─────────────────────────────────┐
│ client shell (Red Sift Cloud) │
│ relays every message │
└───────┬──────────────────┬──────┘
window.postMessage │ │ Worker.postMessage
(cross-origin) │ │ (client's own origin)
┌───────▼───────┐ ┌──────▼──────────┐
│ <iframe> │ │ Web Worker │
│ the view │ │ the controller │
│ SiftView │ │ SiftController │
│ useSiftView │ │ │
└───────────────┘ └─────────────────┘
- The view is a page served from the sift's own origin and embedded in an iframe. It renders the UI.
- The controller is a Web Worker created by the client from the sift's
controller bundle. It owns data access (
SiftStorage, backed by IndexedDB) and prepares what the view renders. - The client is the shell that hosts both. It brokers every message between the two halves, and owns everything the sift itself cannot do: OAuth popups, billing, top-level navigation, browser history.
This package is the library both halves are built on: it owns the wire protocol, the message security, and the plugin system.
- Install
- Entry points
- Quick start
- Lifecycle
- Protocol reference
- API reference
- Origin and message security
- Plugins
- TypeScript
- Upgrading to 3.0
- Upgrading from 2.0.x
- Development
npm install @redsift/sift-sdk-webReact is an optional peer dependency (>=16.13.1) — install it only if you
use the useSiftView hook. It is never bundled into the
SDK, so your app's copy of React is the one the hook uses.
The package declares engines of Node ≥ 18.18 and npm ≥ 9. The published
bundles are ES modules with no CommonJS or UMD build — use a bundler, or
native ESM.
| Import | Contents |
|---|---|
@redsift/sift-sdk-web |
SiftView, SiftController, SiftStorage, EmailClientController, and the create*/register* factories |
@redsift/sift-sdk-web/react |
useSiftView |
Type declarations ship with the package and are wired up through the exports
map, so import type works with moduleResolution of bundler, node16 or
nodenext without a @types package.
import { createSiftController } from '@redsift/sift-sdk-web';
createSiftController({
// Required. Return the view's HTML entry point and its data. `data` may be
// a promise: the SDK sends the HTML immediately and the data when it
// resolves, so the view can render its shell while the query runs.
loadView: function ({ type, sizeClass, params }) {
// One-time setup, guarded: `loadView` can be called more than once in a
// worker's lifetime (a host re-sends it when the view's size class or
// type changes), and each call would otherwise add another subscriber.
//
// It is still the earliest place this can go: `this.storage` is created
// when the client's `init` message is handled, whereas a method named
// `init` on this object runs at *construction*, before that.
if (!this._subscribed) {
this._subscribed = true;
this.storage.subscribe('*', (buckets) => {
this.publish('data-changed', { buckets });
});
// Messages the view published with `publish`.
this.view.subscribe('refresh', () => this.publish('data-changed', {}));
}
return {
html: 'index.html',
data: this.storage.getAll({ bucket: 'summary' }),
};
},
});If loadView throws, returns something that is not an object, or returns a
data promise that rejects, the SDK reports loadViewFailedCallback to the
client rather than leaving the view waiting for data that never arrives.
import { createSiftView } from '@redsift/sift-sdk-web';
const view = createSiftView(
{
init: function () {
// Messages the controller published with `publish`.
this.controller.subscribe('data-changed', () => this.render());
},
// Called by the client with whatever the controller's `loadView` resolved.
presentView: function (params) {
this.data = params.data;
this.render();
},
render: function () {
// ...
},
},
// Pin the protocol to the client's origin. Optional where the host lets the
// origin be discovered, required otherwise — construction throws rather
// than running unpinned. See "Origin and message security".
{ clientOrigin: 'https://app.redsift.io' }
);
// Ask the client to do something only it can do:
document.querySelector('#login').onclick = () =>
view.login({ redirectUri: '/' });import { useEffect, useState } from 'react';
import { useSiftView } from '@redsift/sift-sdk-web/react';
export default function App() {
const [params, siftView] = useSiftView({
clientOrigin: 'https://app.redsift.io',
});
const [changed, setChanged] = useState(null);
useEffect(() => {
// Handle the controller's message locally. Answering it with a `publish`
// back to the controller would loop forever: the controller's `refresh`
// handler publishes `data-changed`, which arrives here again.
const onChange = (message) => setChanged(message);
siftView.controller.subscribe('data-changed', onChange);
return () => siftView.controller.unsubscribe('data-changed', onChange);
}, [siftView]);
// `params` is null until the client sends `presentView`.
if (!params) return <Spinner />;
return (
<Dashboard
data={params.data}
changed={changed}
// A user action is a fresh cause, so this one is safe to publish
onRefresh={() => siftView.publish('refresh')}
onLogin={() => siftView.login({ redirectUri: '/' })}
/>
);
}siftView is referentially stable across renders, so it is safe in dependency
arrays. The hook registers the window message listener for the life of the
component and, on unmount, removes it and stops any plugins it started.
The client drives the whole sequence; both halves only respond.
| # | Direction | Message | What happens |
|---|---|---|---|
| 1 | client → controller | init |
The SDK opens the sift's storage, then replies initCallback. |
| 2 | client → controller | initPlugins, loadView, startPlugins |
Sent on initCallback. Your loadView({ type, sizeClass, params }) runs. |
| 3 | controller → client | loadViewCallback |
Carries { html, data }. Sent twice when html accompanies a data promise: once with the HTML, again with the data. |
| 4 | client → view | (iframe src set to the sift's web root + html) |
The view page loads and constructs SiftView / mounts the hook. |
| 5 | client → view | _initPlugins, presentView, _startPlugins |
Sent on iframe load. Your presentView(params) runs, params.data being what step 3 delivered. |
After that the channel is bidirectional and event-driven: the view publishes to the controller and notifies the client, the controller publishes to the view, and the client pushes storage updates and plugin messages in.
If loadView fails at step 2 the client receives loadViewFailedCallback,
but whether a view was already loaded depends on how it failed. A throw, a
return that is not an object, or a rejecting data promise with no html
means no loadViewCallback was ever sent, so no view is loaded. When html
accompanies a data promise, though, the HTML goes out as soon as loadView
returns — so the client may already have loaded the view by the time the
rejection arrives, leaving it showing its shell with no data. Do not assume
failure precedes view creation.
Every message is a structured-clonable object with a method naming the
operation. Most also carry a params, but that is a convention rather than a
guarantee — initCallback puts its payload in result — so the tables below
name each message's payload field rather than promising one shape. They list
the complete set the SDK handles or sends; a sift never needs to construct one
by hand.
Dispatch rule: an inbound method x is handled by the controller's _x.
Unknown methods are warned about once each (another controller may share the
worker scope).
method |
params |
Effect |
|---|---|---|
init |
{ accountGuid, siftGuid, dbSchema } |
Creates this.storage; replies initCallback. |
initPlugins |
{ pluginConfigs } |
Initialises plugins whose contexts include controller. |
startPlugins |
{ pluginConfigs } |
Starts them. |
stopPlugins |
{ pluginConfigs } |
Stops them and clears the active set. |
loadView |
{ client, type, sizeClass, data } |
Calls your loadView({ type, sizeClass, params: data }). May arrive more than once — see below. |
storageUpdated |
string[] of bucket names |
Publishes '*' with the array on this.storage, then each bucket name. |
notifyController |
{ topic, value } |
Publishes topic on this.view. |
emailComposer |
{ topic, value } |
Publishes topic on this.emailclient. |
terminate |
— | self.close(). |
loadView is not once-per-worker: the legacy iframe-controller host sends it
again whenever the size class or view type changes, and the SDK relays each one
straight to your loadView. Guard anything in there that must happen only once
— subscriptions especially, since nothing de-duplicates a fresh closure.
EmailClientController handles two more: emailStats ({ name, value } →
your onstats) and getThreadRowDisplayInfo ({ tris, supportedTemplates } →
your loadThreadListView, answered with getThreadRowDisplayInfoCallback).
method |
Payload | Sent by |
|---|---|---|
initCallback |
result: the init params |
the SDK, after storage is open |
loadViewCallback |
params: { user, sift, type, sizeClass, result: { html, data } } |
the SDK, when loadView succeeds |
loadViewFailedCallback |
params: { user, sift, type, sizeClass, error: { message } } |
the SDK, when loadView throws, returns a non-object, or rejects |
notifyView |
params: { topic, value } |
controller.publish(topic, value) — the client relays it to the view |
notifyClient |
params: { topic, value } |
controller.emailclient.goto(...) / .close() |
Dispatch rule: the method name is looked up directly on your view instance, so
presentView calls presentView. Internal lifecycle and outbound-only methods
are never dispatchable — see the dispatch
denylist.
method |
params |
Effect |
|---|---|---|
presentView |
{ client, type, sizeClass, data } |
Your presentView. With the hook, becomes the returned params. |
willPresentView |
{ client, type, sizeClass } |
Your optional willPresentView, or the hook's willPresentView prop. |
notifyView |
{ topic, value } |
Publishes topic on view.controller. Handled by the SDK, not dispatched. |
_initPlugins |
{ pluginConfigs } |
Initialises plugins whose contexts include view. |
_startPlugins |
{ pluginConfigs } |
Starts them. |
_stopPlugins |
{ pluginConfigs } |
Stops them and clears the active set. |
_receivePluginMessages |
{ messages: [{ id, data }] } |
Routes each message to the active plugin with that id. |
| anything else | any | Called on your view instance if it is a dispatchable method; otherwise warned about. |
willPresentView is a round trip rather than part of the boot sequence: both
known clients send it only in response to a raw { method: 'willPresentView' }
message from the frame, and the SDK exposes no helper for that outbound half.
Treat it as optional — do not depend on it firing.
method |
params |
Sent by |
|---|---|---|
notifyController |
{ topic, value } |
view.publish(topic, value) — the client relays it to the controller |
notifyClient |
{ topic, value } |
view.notifyClient(topic, value) and every helper below |
Topics the SDK sends through notifyClient:
| Topic | value |
Sent by |
|---|---|---|
showOAuthPopup |
{ provider, options } |
showOAuthPopup |
showOAuthRemovePopup |
{ provider, options } |
removeOAuthIdentity |
signup |
{} |
signup |
login |
{ redirectUri } |
login |
logout |
{} |
logout |
navigate |
{ href, openInNewTab } |
navigate |
sync-history |
the router location's own fields (pathname, search, …) plus action |
the sync-history plugin |
track-ui-activity |
{} |
the track-ui-activity plugin |
Anything else the client understands — product-specific topics such as billing
or chat panels — goes through notifyClient(topic, value) directly. Keep
value a plain object or omit it: the Red Sift client rejects arrays and
primitives.
Each half publishes on its own object and subscribes to the other half's:
| to reach the other half | to hear from the other half | |
|---|---|---|
| view | view.publish(topic, value) |
view.controller.subscribe(topic, fn) |
| controller | controller.publish(topic, value) |
controller.view.subscribe(topic, fn) |
Nothing de-duplicates this channel, so do not answer a message from the other half with a message back to it — the two handlers will publish at each other indefinitely. Handle what arrives locally, and publish only on a fresh cause: a user action, a storage change, a request from the client.
Builds a SiftView subclass with instanceMethods on its prototype, and
returns an instance. If instanceMethods.init is a function it is called at
construction. options is passed to the SiftView constructor.
createSiftController(instanceMethods, options) and
createEmailClientController(instanceMethods, options) work the same way.
registerSiftView, registerSiftController and
registerEmailClientController are deprecated no-ops kept for compatibility;
they only log.
new SiftView({ clientOrigin }) — see Origin and message
security for clientOrigin.
| Member | Description |
|---|---|
controller |
Observable of messages the controller published. |
publish(topic, value) |
Relays a message to the controller, via the client. |
notifyClient(topic, value) |
Sends a topic the client itself acts on. This is what applies the origin pinning — prefer it over reaching for parent.postMessage. |
destroy() |
Removes the window message listener and stops active plugins. Call it if the view is torn down without a page unload. |
showOAuthPopup({ provider, options }) |
Opens the client's OAuth flow. An options.email is replaced by options.subject, a truncated SHA-256 of it, so the raw address does not travel through the redirect chain. |
removeOAuthIdentity({ provider, options }) |
Opens the client's identity-removal flow. |
signup() / login({ redirectUri }) / logout() |
Account actions, performed by the client. |
navigate({ href, openInNewTab }) |
Top-level navigation, performed by the client. |
setupSyncHistory({ history, initialPath }) |
Connects a history object to the sync-history plugin. Requires that plugin to be enabled. |
getPlugin({ id }) |
The active plugin instance with that id, or undefined. |
Implement presentView(params), and optionally willPresentView(params).
| Member | Description |
|---|---|
view |
Observable of messages the view published. |
storage |
SiftStorage. Created when the client's init message is handled, so loadView is the earliest place to use it — a method named init on the object passed to createSiftController runs at construction, before it exists. |
emailclient |
EmailClient: goto(params), close(), plus Observable. |
publish(topic, value) |
Relays a message to the view, via the client. |
Implement loadView({ type, sizeClass, params }), returning
{ html, data }. data may be a promise.
An Observable wrapper over @redsift/rs-storage:
get, getIndexKeys, getIndex, getWithIndex, getAllKeys, getAll,
getUser, putUser, delUser — each taking a query object and returning a
promise. Subscribe to '*' for every change (the message is the array of
changed buckets) or to a bucket name for that bucket.
For email-client integrations. Implement onstats(name, value) and
loadThreadListView(list, supportedTemplates); the SDK answers the client's
getThreadRowDisplayInfo with whatever loadThreadListView returns, keyed by
thread.
const [params, siftView] = useSiftView({ willPresentView, clientOrigin });Returns the client's presentView params (null until they arrive) and the
same API as SiftView apart from destroy(), which the hook's own cleanup
covers. willPresentView is a prop rather than a method, and the latest one
passed is always the one called.
The view is cross-origin to the client, so both directions of the window channel are checked.
Outbound messages are posted to a single resolved target origin rather than
'*', so the client is the only window that can read them.
Inbound messages must clear three checks before anything is dispatched:
event.originis one of the trusted origins;event.sourceis the embedding window — a trusted origin is not enough on its own, because sibling frames and popups on the client's origin can hold a reference to this frame and post to it. Strict equality, with no exception for an absentsourceand none for a self-post: cross-documentpostMessagesetssourceto the sending window, and it is null only when that window has since been discarded, so those exceptions covered a closed sender and this window posting to itself — neither of which a client does. A view that is not embedded still works, sinceparent === windowthere;- the payload is an object with a string
method.
clientOrigin accepts a string or an array of strings.
| Situation | Trusted origins | Outbound target |
|---|---|---|
clientOrigin given |
those origins | the embedding origin when it is one of them, else the first entry |
omitted, location.ancestorOrigins available |
the embedding origin | the embedding origin |
omitted, no ancestorOrigins, usable document.referrer |
the referrer's origin | the referrer's origin |
| omitted, not embedded | this page's own origin | this page's own origin |
clientOrigin: '*' |
any | '*' |
| nothing resolvable | throws | throws |
The trusted set is exactly the client — this page's own origin is not added to it. With the source check below requiring the embedding window, a message from anywhere else on this origin is rejected regardless, so listing it would widen the set for nothing.
An unresolvable origin fails closed. Earlier versions fell back to
accepting and posting to any origin, which kept a broken deployment working at
the cost of running unpinned — and nothing looked wrong while every message was
readable by any embedder. It now throws, and the escape hatch is explicit:
clientOrigin: '*' restores the unpinned behaviour, so an operator can unbreak
production without a code change.
Two cases make an origin unresolvable, and both are deliberate:
- Opaque origins —
file:,data:, and sandboxed documents withoutallow-same-origin— all serialize to the literal string"null"while not being same-origin with one another. Treating that as an origin would trust every opaque context alike, so it is discarded. - A stale referrer.
document.referreridentifies the embedding page only for the frame's initial navigation. Once the view navigates itself the referrer becomes the previous document in the same frame, which says nothing about the client. A referrer on the view's own origin is therefore discarded as stale rather than pinned to, which would make the browser drop every message in both directions.
So a host keeps discovery working by granting the frame allow-same-origin
(its origin is then not opaque) and by not stripping the referrer — a
Referrer-Policy of no-referrer on the embedding page leaves Firefox, which
has no ancestorOrigins, with nothing to go on. If your view navigates
in-frame, pass clientOrigin explicitly, for the same reason.
Passing a clientOrigin that yields no valid origin throws as well. A sift
that asks for a restriction must not silently get origin discovery instead.
Individual unparseable entries in an array are logged and skipped; only an
entirely empty result throws.
Message dispatch resolves a method name on your view instance, so it is constrained on purpose:
constructor, non-functions, and anything inherited fromObject.prototype(hasOwnProperty,__defineGetter__, …) never resolve;destroy,_onWindowMessage,_messageHandlerand_registerMessageListenersare blocked, so a message cannot silence, duplicate or re-enter the channel;publishandnotifyClientare blocked, so a message cannot make the view emit on its behalf. The user-facing helpers (login,navigate, …) stay dispatchable: they only ever notify the client that sent the message.
The controller side is equivalent. Its _-prefix rule means a method name of
_defineGetter__ would otherwise resolve to Object.prototype.__defineGetter__;
_registerMessageListeners, _triggerSiftViewInit and
_triggerSiftViewFailed are blocked outright.
Plugins are enabled by the client, not by the sift, through the
pluginConfigs on the initPlugins / _initPlugins messages:
// the client's side, for reference — a sift does not send this itself
const params = {
pluginConfigs: [{ id: 'sync-history' }, { id: 'track-ui-activity' }],
};A plugin only initialises in a context it declares, is never initialised twice for the same id, and is dropped from the active set when stopped — a later init/start cycle recreates it.
A plugin's context is { notifyClient } and nothing more, so it can send
topics to the client — pinned, like the view's own — without reaching the rest
of the view's surface.
| Plugin id | Contexts | What it does |
|---|---|---|
sync-history |
view |
Two-way sync between the view's router and the browser URL. |
track-ui-activity |
view |
Reports that the user is active in the view. |
Call setupSyncHistory({ history, initialPath }) once the plugin is running,
passing your router's history object:
view.setupSyncHistory({ history: myHistory, initialPath: '/summary' });Navigations inside the view are forwarded to the client as the sync-history
topic, and the client's own navigations arrive as plugin messages and are
applied to your history. Compatible with react-router v3 (action inside the
navigation op), react-router v4/v5 (action as a second argument), and the
history v5 { location, action } shape.
A POP from the client — the user pressed back or forward, so the client has
already moved — is applied with replace, not push, so it does not add a
duplicate entry inside the view. A missing action defaults to push; an
unrecognised one is rejected with a warning. Navigations echoed back from the
client are suppressed, so the two histories do not drive each other in a loop.
Reports mousemove, mousedown, touchstart, click, keydown and
capture-phase scroll as a single track-ui-activity topic, throttled to one
message every 5 seconds — activity is an on/off signal, so there is no point
crossing the frame boundary per event. Listeners are registered passively and
all of them, including the capture-phase scroll, are removed on stop.
The declarations are hand-written and cover the whole public surface. The factories are generic, so methods you pass in are visible on the result:
import { createSiftView, type PresentViewParams } from '@redsift/sift-sdk-web';
const view = createSiftView({
presentView(params: PresentViewParams) {
/* ... */
},
render() {
/* ... */
},
});
view.render(); // known to existBecause the declarations are hand-written they can drift from the JavaScript,
so test/types/api.test-d.ts exercises the public surface and npm run typecheck runs in CI — the drift is a build failure, not a surprise at
install time.
The origin model changed in ways that can turn a previously "working" view into a loud failure. That is the point — the failure was already there, just silent.
- An unresolvable client origin now throws instead of falling back to
accepting and posting to any origin. If a view constructs and immediately
throws
Could not determine the client origin, the host is not giving it anything to discover: grant the frameallow-same-origin, stop stripping the referrer, or passclientOriginexplicitly.clientOrigin: '*'restores the old unpinned behaviour if you need production working first. event.sourceis now checked strictly. A message whosesourceis not the embedding window is dropped, including one with nosourceat all. If anything in your view was driving the protocol by posting to its own window, or by synthesising amessageevent, it will stop being delivered.- The trusted set no longer includes your own origin. It is exactly the client. Nothing legitimate relied on this once the source check is strict.
- Plugins receive
{ notifyClient }, not the whole view. Only relevant if you wrote a plugin against the internal context object. It is still the view's ownnotifyClient, so a sift that overrides that method still sees what its plugins send.
Nothing else in the API changed, and SiftView and useSiftView are now the
same implementation underneath, so they cannot disagree about any of it.
- ESM only. The package publishes
.mjsbundles and anexportsmap. The 2.0.3 UMD build was not usable as a UMD build in the first place: loaded via a script tag it threwReferenceError: require is not defined. Import the package from a bundler or native ESM instead. - React is no longer bundled. In 2.0.3 the React entry point bundled its
own copy of React and threw
TypeError: Cannot read properties of null (reading 'useState')when used. React is now an optional peer dependency and your app's copy is used. - Origins are pinned by default. Outbound messages went to
'*'and inbound messages were accepted from any origin. They are now resolved as described above. PassclientOrigin: '*'to restore the old behaviour if a deployment needs it, and prefer passing the real origin. - New:
destroy()onSiftView, plugin cleanup on hook unmount, and bundled type declarations.
Developing this package needs a newer Node than using it does: eslint 10
requires ^20.19.0 || ^22.13.0 || >=24. That is declared in
devEngines.runtime, so npm ≥ 10.9 warns on a version that will not work —
earlier npm ignores the field, which is why devEngines.packageManager asks
for npm ≥ 10.9 as well.
The engines floor stays at Node 18.18 and npm 9, because that is the
consumer contract: nothing in the published bundles needs more, and a
consumer never installs eslint. Raising it would warn people whose setup is
fine.
npm ci
npm run lint # eslint (flat config)
npm run typecheck # tsc --noEmit over the declarations and their tests
npm test # builds, then runs the three test suites below
npm run build # rollup, ESM bundles into dist/
npm run format # prettierAll three suites run against the built bundles, not the sources, so they cover the packaging as well as the behaviour:
test/smoke.mjs— origin and source filtering, malformed payloads, the dispatch denylists, plugin lifecycle and cleanup, the controller's load-view paths and callback ordering, and the assertion that React is not bundled.test/export-parity.mjs— every value the declarations export exists at runtime, and every runtime export is declared. Compiling the type test cannot catch that direction, since it only contains code that should compile.test/hook-parity.mjs— rendersuseSiftViewfor real (server-side, so the render phase runs) and checks that it exposes the same members asSiftViewand puts identical messages on the wire. Both are wiring oversrc/lib/view-core.js; this is what keeps them from drifting apart again.
Releases are tag-driven. CI publishes on a vX.Y.Z tag and fails if the tag
does not match the version in package.json; pushes to branches only build.
MIT