# Node.js and edge

Source: https://docs.mirafive.io/sdks/node

> Send MIRA FIVE events and evaluate feature flags from Node.js, Bun, Deno, Cloudflare Workers and Vercel functions.

`@mirafive/sdk-server` sends events and evaluates feature flags on a server. Use it in Node.js, Bun, Deno, Cloudflare Workers, Vercel functions and other edge runtimes. Using Next.js, TanStack Start, Nuxt or Astro? Their packages ([Next.js](https://docs.mirafive.io/sdks/nextjs), [TanStack Start](https://docs.mirafive.io/sdks/tanstack-start), [Nuxt](https://docs.mirafive.io/sdks/nuxt), [Astro](https://docs.mirafive.io/sdks/astro)) build on this one and wire it for you. On Convex, use [`@mirafive/sdk-convex`](https://docs.mirafive.io/sdks/convex).

## Install

```bash
npm install @mirafive/sdk-server
```

For Deno:

```sh
deno add npm:@mirafive/sdk-server
```

Requires Node 20 or newer, Bun, Deno or workerd (Cloudflare Workers). The package is ESM only, has no runtime dependencies and uses only `fetch`, `crypto.subtle`, `crypto.randomUUID`, `AbortController`, timers and `TextEncoder`. Each of its two entries is under 4 kB (min + gzip).

## Set up

You need the **secret key** of a server source (`mf_…`). See [Keys](https://docs.mirafive.io/keys) for where to find it. It stays on the server: `new Mira()` throws a `TypeError` when it runs in a browser.

```sh title=".env"
MIRAFIVE_SECRET_KEY=mf_…
```

The package reads no environment variables itself. Pass the key as `key`, and your own host (optional) as `host`.

`track()` buffers. A batch leaves after 1 second or at 100 events. A process or function that ends before that loses the buffer, so each runtime needs one line that waits for delivery:

**Node, Bun, Deno**

Create one client per process and reuse it:

```ts title="src/mira.ts"
import { Mira } from '@mirafive/sdk-server'

export const mira = new Mira({
  key: process.env.MIRAFIVE_SECRET_KEY,
  host: process.env.MIRAFIVE_HOST,
})

process.on('SIGTERM', async () => {
  await mira.shutdown()
  process.exit(0)
})
```

The flush timer does not keep the process alive. In a script or CLI, `await mira.flush()` before it exits. In Deno, read the key with `Deno.env.get('MIRAFIVE_SECRET_KEY')` and run with `--allow-net` (and `--allow-env` for the key).

**Cloudflare Workers**

Keep one client per isolate and hand each request's delivery to that request's `ctx.waitUntil`:

```sh
npx wrangler secret put MIRAFIVE_SECRET_KEY
```

```ts title="src/index.ts"
import { Mira } from '@mirafive/sdk-server'

interface Env {
  MIRAFIVE_SECRET_KEY: string
}

let mira: Mira | undefined

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    mira ??= new Mira({ key: env.MIRAFIVE_SECRET_KEY })

    mira.track('api_call', { properties: { path: new URL(request.url).pathname } })
    ctx.waitUntil(mira.flush())

    return new Response('ok')
  },
}
```

A client that serves only one request can take the request's `waitUntil` in its constructor instead: `new Mira({ key, waitUntil: (promise) => ctx.waitUntil(promise) })`.

**Vercel functions**

Pass `waitUntil` from `@vercel/functions`. Every delivery, including the one the flush timer starts, is handed to it, so the function stays alive until the batch is sent:

```ts title="api/checkout.ts"
import { Mira } from '@mirafive/sdk-server'
import { waitUntil } from '@vercel/functions'

const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY, waitUntil })

export async function POST(request: Request): Promise<Response> {
  const { plan } = await request.json()

  mira.track('checkout_started', { properties: { plan } })

  return Response.json({ ok: true })
}
```

In Next.js route handlers and server actions, use `after(() => mira.flush())` from `next/server`, or the [Next.js package](https://docs.mirafive.io/sdks/nextjs), which does this for you.

Server events are sent in [full mode](https://docs.mirafive.io/guides/consent#full-mode) by default. See [Consent](#consent).

## Verify

1. Send an install check once. It proves the key and host work and is never stored or billed:

   ```ts title="scripts/mirafive-check.ts"
   import { Mira } from '@mirafive/sdk-server'

   const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })

   console.log(await mira.send([{ name: '$install_check' }]))
   ```

   It prints `{ batch: '…', accepted: 0, dropped: 1, reason: 'install_check' }`. A wrong key rejects with a `MiraError` instead (see [Errors](#errors)).
2. Track a real event, then open the source's live view in MIRA FIVE and find it.

Nothing arriving? See [Troubleshooting](#troubleshooting).

## Track events

Call `track(name, options)` where the thing happens:

```ts
mira.track('order completed', {
  userId: order.customerId,
  properties: { revenue: order.total, currency: 'EUR' },
})
```

`userId` is your own pseudonymous id, never an email address. `time` (a `Date`, epoch milliseconds or an ISO string) defaults to now. `anonymousId` and `sessionId` link the event to a browser visitor when the browser SDK sent them to you. Names starting with `$` are reserved.

Each event is checked when you call `track()`, against the limits the server applies: names up to 128 characters, ids up to 256, properties up to 64 values, 5 levels, keys of 128 characters and 32 KB. An event that breaks one, or whose properties are not JSON (a `BigInt`, a cycle), is dropped alone and reported to `onError` as `invalid_event`. The rest of the batch goes.

`with()` returns a view whose events carry the same ids and properties. It shares the client's buffer:

```ts
const requestMira = mira.with({ userId: session.userId, properties: { tenant: 'acme' } })

requestMira.track('report_exported')
```

To have TypeScript check event names and properties, pass an event map. It is types only:

```ts
type Events = { signup: { plan: 'free' | 'pro' }; logout: undefined }

const mira = new Mira<Events>({ key: process.env.MIRAFIVE_SECRET_KEY })

mira.track('signup', { properties: { plan: 'pro' } }) // checked by TypeScript
```

Event names, properties and revenue are covered in [Track events](https://docs.mirafive.io/guides/track-events).

## Identify users

`identify(userId, traits?, { anonymousId? })` records the person's traits. With the browser's anonymous id, it also links the visitor's earlier events to the user:

```ts
mira.identify(user.id, { plan: user.plan }, { anonymousId })
```

Call it after signup and login. It needs full mode. See [Identify users](https://docs.mirafive.io/guides/identify-users).

## Idempotent sends

Webhooks and jobs are sometimes delivered twice. `send()` delivers 1–1000 events as one batch now and resolves to the server's receipt. Give it an idempotency key and a repeat is stored and billed once:

```ts
await mira.send(
  [{ name: 'order completed', userId: order.customerId, properties: { revenue: 129, currency: 'EUR' } }],
  { idempotencyKey: `order-${order.id}` },
)
```

The batch id is derived from the key, so every MIRA FIVE SDK maps the same key to the same batch, and the server keeps a batch id for a day. Retries resend the byte-identical body. To resend the same bytes from another process (a durable outbox), also fix each event's `time` and pass `sentAt`: `send(events, { idempotencyKey, sentAt })`.

`send()` rejects with a `MiraError`. It honours `Retry-After` up to 120 seconds; pass `signal` to cancel it.

## Consent

- **Full mode** (default) sends the identifiers you pass: `userId`, `anonymousId`, `sessionId`. You hold the consent or other lawful basis for them.
- **Consentless mode** (`mode: 'consentless'`) counts without identifiers, for revenue, invoices and anonymous totals. Passing `userId`, `anonymousId` or `sessionId` then throws a `TypeError`, because the server would refuse the whole batch.
- A consentless source refuses full batches with `400 collection_mode_not_allowed`.
- Do Not Track and Global Privacy Control reach a server as `DNT: 1` and `Sec-GPC: 1` headers. For such a request, leave the ids out of `track()` and pass `optedOut: true` to flags.

See [Consent](https://docs.mirafive.io/guides/consent).

## Feature flags

`@mirafive/sdk-server/flags` evaluates this source's flags in your process. Create one `MiraFlags` next to the client and pass the client as `mira`, so experiments counted on the server send their exposures:

```ts title="src/mira.ts"
import { Mira } from '@mirafive/sdk-server'
import { MiraFlags } from '@mirafive/sdk-server/flags'

export const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })
export const flags = new MiraFlags({ key: process.env.MIRAFIVE_SECRET_KEY, mira })
```

Get the flags of one person or visitor, then read them:

```ts
const user = await flags.for({
  userId: session.userId,
  properties: { plan: 'pro' },
  optedOut: request.headers.get('Sec-GPC') === '1' || request.headers.get('DNT') === '1',
})

if (user.enabled('new-checkout')) {
  // the new checkout
}

const headline = user.variant('pricing-test', 'control')
const limits = user.config('checkout-limits', { maxItems: 10 })
```

The first `for()` waits up to 1.5 seconds for the flag document. After that, reads are synchronous. A `for()` more than 30 seconds after the last fetch starts a refresh in the background (with `If-None-Match`) and answers from the document it has. Reads never throw: a flag that is missing, unreadable or not loaded yet answers your fallback. Failed fetches, and each unreadable flag once, go to `onError`.

On Cloudflare Workers, keep one `MiraFlags` per isolate and pass each request's `waitUntil`, so refreshes and exposures leave with that request:

```ts
const user = await flags.for({ userId }, { waitUntil: (promise) => ctx.waitUntil(promise) })
```

### What a unit takes

| Field | Description |
| --- | --- |
| `userId` | Your id for the signed-in person. The unit of flags assigned by person. |
| `anonymousId` | The browser SDK's anonymous id, when the browser sent it to you. The unit of flags assigned by browser. Anything after a `.` is ignored. |
| `properties` | Facts your targeting rules test. Held in memory, never sent. |
| `consent` | `{ experiments?, targeting? }`, the visitor's answer. An omitted scope counts as granted: your own lawful basis applies. |
| `optedOut` | Set it from `Sec-GPC: 1` or `DNT: 1`. No ids, no segment lookup, no exposure. Fixed values and property rules still apply. |

Without `experiments` consent, the anonymous id is not used and every experiment answers its default. Without `targeting` consent, no segment is looked up and segment conditions are false. See [Feature flags](https://docs.mirafive.io/guides/feature-flags) and [Experiments](https://docs.mirafive.io/guides/experiments).

### Segment lookups

When a flag tests a segment, `for()` asks MIRA FIVE which segments the unit is in (`POST /v1/flags/segments`). The lookups of one tick share requests of up to 100 units, wait at most 300 ms and are cached for a minute. A failed lookup makes segment conditions false, and `evaluate()` reports `MEMBERSHIP_UNAVAILABLE`.

### Experiments

`enabled`, `variant` and `config` count the unit in an experiment counted on the server: one `$exposure` per unit, variant and hour, sent through `mira`. `evaluate(key)` explains an answer and never counts. An experiment counted in the browser answers its default on the server (`NOT_ALLOWED`); a bootstrap hands it to the browser instead.

### Server rendering

Render the server's answers into the page, so the browser SDK starts from them and the first render matches:

```ts
import { bootstrapHeaders } from '@mirafive/sdk-server/flags'

const html = `<!doctype html><html><head>${user.bootstrap()}</head><body></body></html>`

return new Response(html, { headers: { 'Content-Type': 'text/html', ...bootstrapHeaders } })
```

`bootstrap()` returns `<script type="application/json" id="mirafive-flags">…</script>` with `<`, `>`, `&`, U+2028 and U+2029 escaped. It carries only flags the website reads too, never server-only values. `bootstrapHeaders` is `Cache-Control: private, no-store`: the block belongs to one visitor, so send it with every page that carries one. [React](https://docs.mirafive.io/sdks/react#server-rendering) and the browser SDK read the same block.

### Snapshots

`flags.snapshot()` returns the document in use. Store it at build time and pass it back as `document`: it answers at once, before the first fetch, while it is younger than 7 days.

## Errors

`track()`, `identify()` and `flush()` never throw for transport reasons. Their failures go to `onError` (default: `console.warn`), including batches the server accepted but dropped for `ingestion_paused` or `allowance_exhausted`. `send()` rejects with a `MiraError`. Flag reads never throw; their failures go to the `onError` of `MiraFlags`.

```ts
import { Mira, MiraError } from '@mirafive/sdk-server'

const mira = new Mira({
  key: process.env.MIRAFIVE_SECRET_KEY,
  onError: (error: MiraError) => console.error('[mirafive]', error.code, error.status, error.message),
})
```

`MiraError` extends `Error` with `code`, `status?`, `retryable`, `retryAfterMs?` and `errors?` (for `validation_failed`, up to 10 `{ path, message }`).

| Code | Status | Retried | Meaning |
| --- | --- | --- | --- |
| `invalid_json` | 400 | no | The body is not JSON. |
| `validation_failed` | 400 | no | An event breaks a rule. `errors` names it. Buffered batches drop the named events and send the rest once more. |
| `collection_mode_not_allowed` | 400 | no | A full batch to a consentless source. Also reported by flags when `mira` is a consentless client, which cannot send exposures. |
| `unauthorized` | 401 | no | The key is missing, wrong or revoked. |
| `website_key_as_bearer` | 403 | no | You passed a website key. Servers use the secret key. |
| `secret_key_exposed` | 403 | no | Flag endpoints only: this secret key was sent from a browser. Rotate it. |
| `not_found` | 404 | no | Flag endpoints only: flags are not available for this key. |
| `payload_too_large` | 413 | no | The batch is over 1 MiB. Buffered batches are split before sending. |
| `rate_limited` | 429 | yes | After `Retry-After`. |
| `sink_unavailable` | 503 | yes | MIRA FIVE could not store the batch. |
| `timeout` | 408 or none | yes | The request took longer than `timeoutMs`. |
| `network_error` | none | yes | No answer. |
| `aborted` | none | no | `signal` was aborted, or an event came after `shutdown()`. |
| `invalid_event` | none | no | Refused before sending: the event breaks a limit or is not JSON. |
| `unexpected` | any | if 408, 429 or 5xx | An answer without a code, such as a bare `502` from a proxy, or an unreadable flag document. |

Retries use full-jitter backoff (250 ms doubling, at most 4 s) and resend the byte-identical body. Buffered deliveries honour `Retry-After` up to 30 seconds. The server's error format is described in [Ingest API errors](https://docs.mirafive.io/ingest-api/errors).

## API reference

```ts
import { Mira, MiraError } from '@mirafive/sdk-server'
import type {
  EventOptions,
  Events,
  Fetch,
  Json,
  MiraErrorCode,
  MiraErrorInit,
  MiraOptions,
  Mode,
  Page,
  Receipt,
  Scope,
  SendEvent,
  SendOptions,
} from '@mirafive/sdk-server'

import { bootstrapHeaders, MiraFlags } from '@mirafive/sdk-server/flags'
import type {
  Bootstrap,
  Flag,
  FlagDocument,
  FlagErrorCode,
  FlagEvaluation,
  FlagUnit,
  ForOptions,
  MiraFlagsOptions,
  MiraFlagsStatus,
  UserFlags,
} from '@mirafive/sdk-server/flags'
```

### `new Mira<Events>(options)`

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `key` | `string \| undefined` | | **Required.** The source's secret key. Without one, deliveries fail with `unauthorized`. |
| `host` | `string` | `https://events.mirafive.io` | `https://` only; `http://` only for `localhost`, `127.0.0.1` and `[::1]`. An empty string means the default. |
| `mode` | `'full' \| 'consentless'` | `'full'` | Collection mode of every batch. |
| `flushAt` | `number` | `100` | Events per buffered batch, 1–1000. |
| `flushAfterMs` | `number` | `1000` | How long an event waits in the buffer. |
| `timeoutMs` | `number` | `10000` | Per request. |
| `maxRetries` | `number` | `3` | On 408, 429, 5xx, network errors and timeouts. |
| `fetch` | `Fetch` | global `fetch` | A custom transport. |
| `waitUntil` | `(promise) => void` | | Receives every delivery in flight, e.g. `ctx.waitUntil` or `waitUntil` from `@vercel/functions`. |
| `onError` | `(error: MiraError) => void` | `console.warn` | Failures of buffered events. |

| Member | Description |
| --- | --- |
| `track(name, options?)` | Buffers one event. `options`: `userId`, `anonymousId`, `sessionId`, `properties`, `time`, `page` (`{ url?, title?, referrer? }`), `id` (a UUID of your own). |
| `identify(userId, traits?, { anonymousId? })` | Buffers `$identify`. |
| `send(events, options?)` | `Promise<Receipt>`. Delivers 1–1000 events (`{ name, …track options }`) as one batch now. `options`: `idempotencyKey`, `signal`, `sentAt` (epoch ms). Rejects with `MiraError`; an empty or oversized list or an empty `idempotencyKey` throws a `TypeError`. |
| `flush()` | `Promise<void>`. Sends the buffer and waits for every delivery in flight. Never rejects. |
| `shutdown()` | `Promise<void>`. Flushes and stops. Later events go to `onError` as `aborted`. |
| `with(scope)` | A view whose events carry `scope.userId`, `scope.anonymousId` and `scope.properties`. Shares the buffer. |

`Receipt` is `{ batch, accepted, dropped, reason? }`. `dropped > 0` with a `reason` (`bot`, `install_check`, `ingestion_paused`, `allowance_exhausted`) means nothing was kept. A receipt is final: it is never retried.

### `new MiraFlags(options)`

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `key` | `string \| undefined` | | **Required.** The source's secret key. |
| `host` | `string` | `https://events.mirafive.io` | As for `Mira`. |
| `refreshSeconds` | `number` | `30` | How old the document may get before a read refreshes it. At least 10. |
| `timeoutMs` | `number` | `1500` | How long the first read waits for the document. |
| `document` | `FlagDocument` | | A snapshot to start from, used while younger than 7 days. |
| `mira` | `Mira` | | Sends the exposures of experiments counted on the server. Without it, nobody is counted. |
| `fetch` | `Fetch` | global `fetch` | A custom transport. |
| `waitUntil` | `(promise) => void` | | Receives refreshes and exposure flushes. |
| `onError` | `(error: MiraError) => void` | `console.warn` | Failed fetches and lookups, unreadable flags. |

| Member | Description |
| --- | --- |
| `for(unit?, { waitUntil? }?)` | `Promise<UserFlags>`. The flags of one unit (see [What a unit takes](#what-a-unit-takes)). `waitUntil` overrides the constructor's for this call. |
| `ready()` | `Promise<boolean>`. Whether a document is usable, after waiting up to `timeoutMs`. |
| `snapshot()` | `FlagDocument \| undefined`. The document in use. |
| `status()` | `{ ready, stale, stopped, fetchedAt? }`. `stale` after 5 minutes without a confirmed document; `stopped` after a 401 or 403, until restart. |

A failed refresh keeps the last document and backs off up to 5 minutes.

### `UserFlags`

| Member | Description |
| --- | --- |
| `enabled(key, fallback = false)` | `boolean`. `true` for the variant `on`, `false` for `off`, the fallback otherwise. |
| `variant(key, fallback?)` | `string`, or `undefined` without a fallback. |
| `config<T>(key, fallback)` | `T`. The remote-config value of the variant. |
| `evaluate(key)` | `FlagEvaluation`. Explains the answer; never counts an exposure. |
| `bootstrap()` | `string`. The `<script id="mirafive-flags">` block for the browser SDK. |

`evaluate()` returns `{ variant, reason, rule?, errorCode? }` or `{ reason: 'ERROR', errorCode }`. Reasons: `STATIC`, `TARGETING_MATCH`, `SPLIT`, `DEFAULT`, `DISABLED`. Error codes: `NOT_READY` (no document yet), `FLAG_NOT_FOUND` (not a flag of this source), `UNSUPPORTED` (a newer or broken flag format). Beside a variant: `MEMBERSHIP_UNAVAILABLE` (segments could not be looked up, so their conditions were false) and `NOT_ALLOWED` (no consent, or an experiment counted in the browser; the default is served).

`bootstrapHeaders` is `{ 'Cache-Control': 'private, no-store' }`.

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Nothing arrives | The function ended before delivery: pass `waitUntil`, use `after()` in Next.js, or `await mira.flush()`. Check what `onError` logs and the host. |
| `unauthorized` | `MIRAFIVE_SECRET_KEY` is empty, wrong or revoked. It must be the secret key of a server source. |
| `403 website_key_as_bearer` | You passed the website key. Server code needs the secret key. |
| `403 secret_key_exposed` from flags | The key was sent with an `Origin` or `Sec-Fetch-Site` header, i.e. from a browser. Rotate it and keep it server-side. |
| `400 collection_mode_not_allowed` | The source is consentless and the client sends full batches. Pass `mode: 'consentless'` and no ids. |
| `TypeError: consentless mode …` | Identifiers in consentless mode. Remove them or switch the mode. |
| `TypeError: @mirafive/sdk-server is server-only` | The package was bundled into browser code. Use the [browser SDK](https://docs.mirafive.io/sdks/browser) there, with the website key. |
| `invalid_event` in `onError` | The event breaks a server limit or its properties are not JSON. It was dropped; the others were sent. |
| `ingestion_paused` or `allowance_exhausted` in `onError` | The server accepted the batch but kept nothing: the organization is paused or out of allowance. |
| A flag always returns its fallback | `user.evaluate(key)` says why: `NOT_READY` (no document within 1.5 s; see `onError`), `FLAG_NOT_FOUND` (not a flag of this source), `NOT_ALLOWED` (consent, or an experiment counted in the browser). |
| Experiments counted on the server get no exposures | Pass a full-mode `mira` to `MiraFlags`. |

## Set up with an AI agent

Paste this into your coding agent:

```text
Add MIRA FIVE server-side analytics (and feature flags, if the project uses them) with @mirafive/sdk-server.
Docs: https://docs.mirafive.io/sdks/node.md

1. Install @mirafive/sdk-server with the project's package manager (Deno: deno add npm:@mirafive/sdk-server).
   If this is a Next.js, TanStack Start, Nuxt, Astro or Convex project, use that framework's MIRA FIVE package instead.
2. Read the secret key from MIRAFIVE_SECRET_KEY and add MIRAFIVE_SECRET_KEY= to .env.example.
   It is server-only: never import this package or the key into browser code.
3. Create one client in a server-only module:
     export const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })
   from '@mirafive/sdk-server'. On Cloudflare Workers create it once per isolate from env.MIRAFIVE_SECRET_KEY.
4. Make sure events leave before the process or function ends:
   - long-running Node, Bun or Deno server: await mira.shutdown() on SIGTERM;
   - Cloudflare Workers: ctx.waitUntil(mira.flush()) in every request;
   - Vercel functions: pass waitUntil from '@vercel/functions' to new Mira();
   - scripts: await mira.flush() before exit.
5. Track the few business events that matter where they happen, e.g.
     mira.track('signup', { userId: user.id, properties: { plan } })
   and call mira.identify(user.id, { plan }) after signup and login. Use the internal user id, never an email.
   For webhooks, use await mira.send([...], { idempotencyKey: event.id }).
6. Only if the project uses feature flags: export const flags = new MiraFlags({ key: process.env.MIRAFIVE_SECRET_KEY, mira })
   from '@mirafive/sdk-server/flags', then (await flags.for({ userId: user.id })).enabled('key').
   Pass optedOut: true when the request has Sec-GPC: 1 or DNT: 1.
7. Keep the default full mode (the app holds consent). For anonymous counts only, use
   mode: 'consentless' and pass no userId, anonymousId or sessionId.
8. Verify: run await mira.send([{ name: '$install_check' }]) once and check that the receipt's
   reason is 'install_check'. Report what you changed.
Do not add other analytics libraries, cookies or consent banners.
```
