# @permutive-engineering/realtime-api-client

[![ES5](https://img.shields.io/badge/target-ES5-f7df1e.svg)](#how-to-use-the-client-without-asyncawait)
[![TypeScript](https://img.shields.io/badge/types-included-3178c6.svg)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE.md)

> Realtime cohort segmentation for JavaScript.

A small client for the Permutive Realtime API. You tell it what the user is
looking at and what they do; it keeps a live copy of the user's **cohort**
memberships in sync as the server recomputes them.

- ⚡ **Realtime segmentation:** cohort membership stays live, updating as the
  server recomputes it.
- 🎯 **Activation-ready:** the cohorts activated for a downstream platform
  (e.g. `'gam'`, `'xandr'`) are available to be placed into your ad request.
- 🧠 **Server-authoritative:** the server resolves identity, sessions, and
  segmentation.
- 📦 **You own persistence:** the client persists nothing; your app stores the
  resume token and User ID and hands them back when opening a session.
- 🕰️ **Runs everywhere:** zero runtime dependencies, ships ES5, and every call
  returns a thenable — no `Promise` global required.

Using React? The
[React bindings](../javascript-realtime-api-client-react/README.md) wrap this
client in components and hooks.

📚 **[Permutive docs](https://docs.permutive.com/)** | 🌐 **[Permutive.com](https://www.permutive.com)**

## Installation

```sh
npm install @permutive-engineering/realtime-api-client
```

No build step? Load it from a CDN as a `<script>` tag — see
[How to use the client from a script tag](#how-to-use-the-client-from-a-script-tag).

## Getting Started

Configure the client once, open a **session**, then run commands (track
events, identify the user) and read live cohorts from it:

```typescript
import {
  permutive,
  getCohorts,
} from '@permutive-engineering/realtime-api-client'

const client = permutive({ apiKey: 'your-api-key' })

// declare what's open now; views are keyed by a ViewKey you choose
const session = await client.openSession({
  context: {
    views: { '1': { title: document.title, url: window.location.href } },
  },
})

// react to cohort changes…
session.onStateChange(change => {
  console.log('cohorts:', getCohorts(change.current))
})

// …and drive them by tracking events against a view
await session.track({ view_key: '1', name: 'Pageview' })
```

## How-tos

### How to control session lifecycle

The client persists nothing; where you store the resume token decides how long
a session lives:

| You want                     | Store the token in          |
| ---------------------------- | --------------------------- |
| A fresh session every launch | nowhere; skip persistence   |
| Session ends with the tab    | `sessionStorage`            |
| Long-lived sessions          | `localStorage`/AsyncStorage |

```typescript
// persist on every change; identifying can change the user_id mid-session.
// split stores: the session ends with the tab, the user survives launches.
// the token is opaque — store it verbatim
session.onStateChange(({ current }) => {
  sessionStorage.setItem('permutive:resumeToken', current.session_resume_token)
  localStorage.setItem('permutive:userId', current.user_id)
})

// on the next load, hand both back; they are hints: the token resumes the
// session, and the user_id anchors a fresh one if the session is gone.
// declare the views open now — a resumed session doesn't restore them
const session = await client.openSession({
  session_resume_token:
    sessionStorage.getItem('permutive:resumeToken') ?? undefined,
  user_id: localStorage.getItem('permutive:userId') ?? undefined,
  context: {
    views: { '1': { title: document.title, url: window.location.href } },
  },
})
```

The state also carries a `session_id`, which is stable and safe to log or
display for debugging, but it is not what you resume with.

Resuming returns a new `Session` object, and view keys are unique per
`Session` object — so restart your view counter; there's nothing to carry
over. See [How views work](#how-views-work).

### How to sign in, switch profiles, and log out

Signing in is an `identify` on the live session — the session continues, and
the server may re-anchor the user. Logging out is closing the session and
opening a fresh anonymous one:

```typescript
// sign in / switch profile: same session, user_id may change;
// identities upsert by tag
const result = await session.identify({
  identities: [{ tag: 'email_sha256', id: hash }],
})
if (result.output.user_id) {
  console.log('user is now', result.output.user_id)
}

// log out: end the session, then start over as anonymous
session.close()
session = await client.openSession()
```

When several identities compete, `priority` decides which wins resolution —
the lower the number, the higher the precedence (`0` wins over `1`):

```typescript
await session.identify({
  identities: [
    { tag: 'email_sha256', id: hash, priority: 0 },
    { tag: 'device', id: deviceId, priority: 1 },
  ],
})
```

You can also seed identities or a known User ID when opening, instead of a
follow-up command:

```typescript
await client.openSession({ identities: [{ tag: 'email_sha256', id: hash }] })
await client.openSession({
  user_id: knownUserId,
  identities: [{ tag: 'email_sha256', id: hash }],
})
await client.openSession({ user_id: knownUserId })
```

### How views work

A **view** is a logical page or surface showing one piece of content. The
session's `context` holds the views that are currently open, keyed by a
`ViewKey` you choose (any string — a counter works well). Events carry the
`view_key` of the view they belong to. When a view's content changes — a new
article, the next video — mint a new key; in-place updates only refine the
same content:

```typescript
// a view starts when its key enters the context…
await session.setContext({
  views: { '1': { title: 'Article', url: articleUrl } },
})

// …events attach to it by key…
await session.track({ view_key: '1', name: 'Pageview' })

// …changing a view's title/url refines the current view (say, a title that
// resolves after data loads) — when the content itself changes, mint a new
// key instead; and dropping a key ends its view
await session.setContext({
  views: {
    '1': { title: 'Another Title', url: 'https://www.example.com/article1' },
  },
})
await session.setContext({ views: {} })
```

`setContext` sends only what changed since the last call, so it's cheap to
call often. Several views can be open at once — each event's `view_key` picks
the one it belongs to.

Keys only need to be unique within one `Session` object — each `openSession()`
resolves with a new one, so after a reload (even when resuming the same
session) your counter simply restarts; key `'1'` on the new `Session` names a
new view, as it should, since the content is being presented anew.

### How to start a new view without a page load

Anytime a view's content changes — a new article, the next video — mint a new
key; never reuse a key for different content. A counter makes every view
distinct:

```typescript
let viewCount = 0 // the counter lives and dies with the Session object
let viewKey = ''

async function openView(title: string, url: string) {
  viewKey = String(++viewCount) // new surface = new key = new view
  await session.setContext({ views: { [viewKey]: { title, url } } })
}

await openView('Catalog: sports', 'https://example.com/sports')
await session.track({ view_key: viewKey, name: 'Pageview' })

// the user switches category: a fresh view, not an update to the old one
await openView('Catalog: news', 'https://example.com/news')
```

### How to track video views

Send `Videoview` when the video opens, then `VideoEngagement` while it
plays, following the
[CTV video schema](https://docs.permutive.com/sdks/ctv/video-tracking).
Both engagement properties are running totals of the view:
`total_engaged_time` is the engaged seconds, and `total_completion` is the
viewed fraction, from 0 to 1. Do not send `VideoCompletion`. The Realtime
API generates that event from the two above.

```typescript
let startedAt = 0

player.on('play', () => {
  startedAt = Date.now()
  // video properties are snake_case, nested under `video`
  void session.track({
    view_key: viewKey,
    name: 'Videoview',
    properties: { video: { title: video.title, runtime: video.runtime } }, // seconds
  })
})

player.on('ended', ({ progress }) => {
  void session.track({
    view_key: viewKey,
    name: 'VideoEngagement',
    properties: {
      video: { title: video.title },
      total_completion: progress, // 0..1, from your player
      total_engaged_time: Math.round((Date.now() - startedAt) / 1000),
    },
  })
})
```

One event at the end of the video reports engagement only for a user who
watches it through. To report the totals while the video plays, contact
Permutive support for help.

Each video is its own view — new video, new key. For a playlist that plays
several videos in one surface, bump the `ViewKey` per video, as in
[How to start a new view without a page load](#how-to-start-a-new-view-without-a-page-load).

### How to track page views

The same pattern with `Pageview` and `PageviewEngagement`:

```typescript
const start = Date.now()
await session.track({
  view_key: viewKey,
  name: 'Pageview',
  properties: { article: { id: article.id } },
})

// when the page/screen ends (before you close the view):
await session.track({
  view_key: viewKey,
  name: 'PageviewEngagement',
  properties: {
    article: { id: article.id },
    engaged_time: Math.round((Date.now() - start) / 1000),
  },
})

// any custom event works the same way; these all reuse the key because they
// belong to the same article — the next article gets a new key
await session.track({
  view_key: viewKey,
  name: 'ArticleShared',
  properties: { method: 'twitter' },
})
```

### How to activate cohorts in Google Publisher Tag

`getActivation` reads the cohorts activated for one platform; set them as GPT
key-values before requesting ads, and keep them fresh from `onStateChange`:

```typescript
import { getActivation } from '@permutive-engineering/realtime-api-client'
import type { SessionState } from '@permutive-engineering/realtime-api-client'

function applyTargeting(state: SessionState) {
  const cohorts = getActivation(state, 'gam')
  googletag.cmd.push(() => {
    googletag.setConfig({ targeting: { permutive: cohorts } })
  })
}

applyTargeting(session.getState())
session.onStateChange(({ current }) => applyTargeting(current))
```

### How to handle errors

A command promise rejects when the command did not complete: the server
rejected it (the rejection value is the server's error), or the session ended
while it was in flight (the rejection value is the reason the session ended).

```typescript
try {
  await session.identify({ identities: [{ tag: 'email_sha256', id: hash }] })
} catch (error) {
  console.error('identify failed:', error)
}
```

`session.closed` separately resolves when the session itself ends — whether
from a connection failure or a deliberate `close` — with the reason (or
`undefined` for a plain close):

```typescript
session.closed.then(reason => {
  console.log('session ended:', reason ?? 'closed normally')
})
```

A lost connection does not end the session. The client reconnects and carries
on with the same session, user and views, and `closed` stays pending. Commands
that were in flight when the connection dropped reject, so retry them rather
than treating the rejection as the end of the session. `closed` resolves only
when the session really is over: you called `close`, or the connection failed
in a way the client cannot recover from (a bad API key, for example).

### How to use the client without async/await

Every imperative call returns a `PromiseLike`, so an older runtime with no
`Promise` global drives the same values with `.then(onFulfilled, onRejected)`:

```typescript
permutive({ apiKey: 'your-api-key' })
  .openSession()
  .then(
    session => {
      session
        .identify({ identities: [{ tag: 'email_sha256', id: hash }] })
        .then(result => console.log(result.output.user_id))

      session.onStateChange(change => {
        console.log(getCohorts(change.current))
      })
    },
    error => console.error(error),
  )
```

### How to use the client from a script tag

For a page with no build step, load the UMD bundle from a CDN. It attaches one
`PermutiveRealtime` global exposing `createClient`, `getCohorts`, and
`getActivation`.
A classic script is not a module, so drive the client with `.then(…)`, not
`await`:

```html
<script src="https://cdn.jsdelivr.net/npm/@permutive-engineering/realtime-api-client@0.2"></script>
<script>
  var client = PermutiveRealtime.createClient({ apiKey: 'your-api-key' })

  client
    .openSession({
      context: { views: { 1: { title: document.title, url: location.href } } },
    })
    .then(function (session) {
      session.onStateChange(function (change) {
        console.log('cohorts:', PermutiveRealtime.getCohorts(change.current))
      })
      return session.track({ view_key: '1', name: 'Pageview' })
    })
</script>
```

Pin to the latest minor (`@0.2`) rather than a full version: the CDN resolves it
to the newest matching release, so patch-level bug fixes reach your pages
automatically without a manual bump. [unpkg](https://unpkg.com/) serves the same
bundle at `https://unpkg.com/@permutive-engineering/realtime-api-client@0.2`.

> **Note:** The CDN bundle targets ES5, so it runs on older browsers without
> transpilation.

## API Reference

- <a id="identity"></a>**`Identity`** ![type](https://img.shields.io/badge/type-3178c6?style=flat-square)
  `{ tag: string; id: string; priority?: number }`

- **`permutive(config)`** ![function](https://img.shields.io/badge/function-8250df?style=flat-square)

  Captures `{ apiKey }` once and returns a `Permutive` you open sessions
  from.

- **`Permutive.openSession(options?)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Opens a session and resolves with a live `Session`. Every option is optional
  — a bare `openSession()` opens a fresh anonymous session with no views:
  - `session_resume_token` resumes that session
    ([lifecycle](#how-to-control-session-lifecycle)); the rest apply when a
    session is created rather than resumed.
  - `user_id` anchors the session to a known user.
  - `identities` seeds [`Identity[]`](#identity) at open.
  - `context` the views open now; keys are scoped to the `Session` this call
    creates.

- **`Session.track(input)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Sends an event — `{ view_key?, name, properties? }` — and resolves with its
  `Result`. Cohort changes fold into the state automatically.

- **`Session.identify(input)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Upserts `{ identities: Identity[] }` by tag; may re-anchor the user, in
  which case `result.output.user_id` carries the new User ID.

- **`Session.setContext(context)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Declares the open views (`{ views: Record<ViewKey, { title?, url? }> }`);
  only the difference from the previous context is sent. View keys are unique
  per `Session` object; each `openSession()` — including a resume — starts
  afresh. See [How views work](#how-views-work).

- **`Session.getState()`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  The current `SessionState`: `user_id`, `session_id`,
  `session_resume_token`, and the segmentation read by `getCohorts` /
  `getActivation`.

- **`Session.onStateChange(listener)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Calls `listener({ previous, current, delta })` on every applied state
  change; returns an unsubscribe function.

- **`Session.send(type, input?)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  The generic command form; `track` and `identify` are its dedicated
  equivalents.

- **`Session.closed`** ![property](https://img.shields.io/badge/property-8250df?style=flat-square)

  A promise that resolves when the session ends, with the reason (or
  `undefined` for a plain close). A lost connection does not end the session;
  see [How to handle errors](#how-to-handle-errors).

- **`Session.close(reason?)`** ![method](https://img.shields.io/badge/method-8250df?style=flat-square)

  Ends the session deliberately; any in-flight command rejects, and
  `Session.closed` resolves with `reason`.

- **`getCohorts(state)`** ![function](https://img.shields.io/badge/function-8250df?style=flat-square)

  The user's current cohort memberships, as an array of cohort IDs.

- **`getActivation(state, platform)`** ![function](https://img.shields.io/badge/function-8250df?style=flat-square)

  The cohorts activated for one downstream platform (e.g. `'gam'`).
