MIRA FIVE

Node.js and edge

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, TanStack Start, Nuxt, Astro) build on this one and wire it for you. On Convex, use @mirafive/sdk-convex.

Install

npm install @mirafive/sdk-server

For Deno:

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 for where to find it. It stays on the server: new Mira() throws a TypeError when it runs in a browser.

.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:

Create one client per process and reuse it:

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).

Server events are sent in full mode by default. See Consent.

Verify

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

    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).

  2. Track a real event, then open the source's live view in MIRA FIVE and find it.

Nothing arriving? See Troubleshooting.

Track events

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

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:

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:

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.

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:

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

Call it after signup and login. It needs full mode. See 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:

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.

  • 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.

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:

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:

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:

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

What a unit takes

FieldDescription
userIdYour id for the signed-in person. The unit of flags assigned by person.
anonymousIdThe browser SDK's anonymous id, when the browser sent it to you. The unit of flags assigned by browser. Anything after a . is ignored.
propertiesFacts 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.
optedOutSet 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 and 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:

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 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.

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 }).

CodeStatusRetriedMeaning
invalid_json400noThe body is not JSON.
validation_failed400noAn event breaks a rule. errors names it. Buffered batches drop the named events and send the rest once more.
collection_mode_not_allowed400noA full batch to a consentless source. Also reported by flags when mira is a consentless client, which cannot send exposures.
unauthorized401noThe key is missing, wrong or revoked.
website_key_as_bearer403noYou passed a website key. Servers use the secret key.
secret_key_exposed403noFlag endpoints only: this secret key was sent from a browser. Rotate it.
not_found404noFlag endpoints only: flags are not available for this key.
payload_too_large413noThe batch is over 1 MiB. Buffered batches are split before sending.
rate_limited429yesAfter Retry-After.
sink_unavailable503yesMIRA FIVE could not store the batch.
timeout408 or noneyesThe request took longer than timeoutMs.
network_errornoneyesNo answer.
abortednonenosignal was aborted, or an event came after shutdown().
invalid_eventnonenoRefused before sending: the event breaks a limit or is not JSON.
unexpectedanyif 408, 429 or 5xxAn 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.

API reference

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)

NameTypeDefaultDescription
keystring | undefinedRequired. The source's secret key. Without one, deliveries fail with unauthorized.
hoststringhttps://events.mirafive.iohttps:// 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.
flushAtnumber100Events per buffered batch, 1–1000.
flushAfterMsnumber1000How long an event waits in the buffer.
timeoutMsnumber10000Per request.
maxRetriesnumber3On 408, 429, 5xx, network errors and timeouts.
fetchFetchglobal fetchA custom transport.
waitUntil(promise) => voidReceives every delivery in flight, e.g. ctx.waitUntil or waitUntil from @vercel/functions.
onError(error: MiraError) => voidconsole.warnFailures of buffered events.
MemberDescription
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)

NameTypeDefaultDescription
keystring | undefinedRequired. The source's secret key.
hoststringhttps://events.mirafive.ioAs for Mira.
refreshSecondsnumber30How old the document may get before a read refreshes it. At least 10.
timeoutMsnumber1500How long the first read waits for the document.
documentFlagDocumentA snapshot to start from, used while younger than 7 days.
miraMiraSends the exposures of experiments counted on the server. Without it, nobody is counted.
fetchFetchglobal fetchA custom transport.
waitUntil(promise) => voidReceives refreshes and exposure flushes.
onError(error: MiraError) => voidconsole.warnFailed fetches and lookups, unreadable flags.
MemberDescription
for(unit?, { waitUntil? }?)Promise<UserFlags>. The flags of one unit (see 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

MemberDescription
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

SymptomCause and fix
Nothing arrivesThe function ended before delivery: pass waitUntil, use after() in Next.js, or await mira.flush(). Check what onError logs and the host.
unauthorizedMIRAFIVE_SECRET_KEY is empty, wrong or revoked. It must be the secret key of a server source.
403 website_key_as_bearerYou passed the website key. Server code needs the secret key.
403 secret_key_exposed from flagsThe 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_allowedThe 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-onlyThe package was bundled into browser code. Use the browser SDK there, with the website key.
invalid_event in onErrorThe event breaks a server limit or its properties are not JSON. It was dropped; the others were sent.
ingestion_paused or allowance_exhausted in onErrorThe server accepted the batch but kept nothing: the organization is paused or out of allowance.
A flag always returns its fallbackuser.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 exposuresPass a full-mode mira to MiraFlags.

Set up with an AI agent

Paste this into your coding agent:

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.

On this page