npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fraios/inbox-client

v1.1.6

Published

First-party JavaScript / TypeScript client for the fraios ingestion service. Sends analytics envelopes to the canonical /inbox/{source} path for server-side Semantic Event normalization. Preserves the core @jitsu/js call surface while using fraios routing

Downloads

380

Readme

@fraios/inbox-client

Analytics client for the fraios ingestion service. Sends events from browsers, Node and edge runtimes to /inbox/{source}, where the service normalizes them into the canonical Semantic Event structure.

import { FraiosAnalytics } from "@fraios/inbox-client";

const analytics = new FraiosAnalytics({
  token: process.env.INBOX_TOKEN,
});

await analytics.track("Order Completed", {
  order_id: "ORD-1183",
  revenue: 149.5,
  currency: "EUR",
});

That is the whole integration. In a browser the client also fills page, campaign, screen, user-agent and campaign context on every event — see What gets captured.

  • No runtime dependencies. zod is optional, and only for validation.
  • Six runtimes — browser, Node, Bun, Deno, Cloudflare Workers, Vercel Edge.
  • Credential caching, typed errors and offline queueing handled for you.
  • Preserves the @jitsu/js call surface, so migration is a constructor change.

Install

npm install @fraios/inbox-client
# or: pnpm add @fraios/inbox-client / yarn add @fraios/inbox-client

Or load it in the browser with no build step:

<script type="module">
  import { FraiosAnalytics } from "https://inbox.fraios.dev/p.module.js";
</script>
<script src="https://inbox.fraios.dev/p.js"></script>
<script>
  const analytics = new window.FraiosAnalytics({
    token: "eyJhbGciOiJIUzI1NiJ9...", // browser-class Ingress Token, origin-pinned
  });
</script>

/p.module.js (ESM) and /p.js (classic, global FraiosAnalytics) serve the currently deployed client and are the supported browser URLs; /s/lib.js is a legacy alias of the classic build. To pin a version, install from npm and bundle it — the /clients/js/vX.Y.Z/ paths are aliases, not per-release snapshots.

Content blockers may classify analytics bundles or collector hostnames as tracking and stop the request inside the browser. Test the hosted URLs with the privacy extensions your audience uses; if delivery is business-critical, serve through a first-party path on your own origin.

Public, deploy-matched guide: inbox.fraios.dev/docs/client-js.


Sending events

Every send method takes the same three arguments: a subject, a payload, and optional envelope overrides.

analytics.track(event, properties, options);
//              ↑      ↑           ↑
//              1      2           3

| # | Argument | What it is | | --- | ------------ | ------------------------------------------------------------------------- | | 1 | event | The business fact — "Order Completed". | | 2 | properties | Your data about this event. | | 3 | options | Not more data — overrides for fields the client would otherwise fill. |

options has exactly five fields and no others: userId, anonymousId, timestamp, messageId, context.

userId belongs in options, not properties. Passed in properties it is stored as ordinary event data and never fills the identity column.

identify(userId,  traits?,     options?)
track   (event,   properties?, options?)
page    (name?,   properties?, options?)
screen  (name,    properties?, options?)
group   (groupId, traits?,     options?)

identify and group take traits — attributes of the person or company — where track, page and screen take properties, facts about the event.

await analytics.identify("user-42", { plan: "pro" });
await analytics.track(
  "Order Completed",
  { order_id: "ORD-1183" },
  { userId: "user-42" },
);
await analytics.page("Checkout");
await analytics.group("company-7", { name: "Acme" });
await analytics.reset(); // on logout

Call reset() on logout. identify() makes the id sticky, so without it the next person on a shared device inherits the previous user's identity.

Event names

Title Case, past tense, naming a business fact: Order Completed, Invoice Settled, Subscription Cancelled. The naming policy rejects anything else, including interaction noise like Button Clicked — name the fact, not the click.


Use the typed groups

Typed fields are queryable, joinable and stable across producers; properties is the catch-all for everything else. Group them inside the payload argument and the client lifts them to the event root for you:

await analytics.track("ETL Job Completed", {
  dimensions: {
    job_name: "daily_user_aggregation",
    source_system: "production_postgres_db",
    destination_system: "analytics_data_warehouse",
    status: "success",
  },
  metrics: {
    duration_seconds: 1245.5,
    rows_processed: 15782390,
    cpu_utilization_percent: 78.5,
  },
  flags: {
    is_full_refresh: true,
    triggered_downstream: true,
  },
  pipeline_version: "4.2.1", // not a typed group — stays in properties
});

| Group | Holds | | ------------ | ---------------------------------------------------------------- | | dimensions | Low-cardinality strings you group by — status, region, job name. | | metrics | Numbers you aggregate — durations, counts, percentages. | | flags | Booleans you filter on. |

Sixteen groups are lifted: involves, sentiment, classification, location, entity_linking, contextual_awareness, analysis, commerce, products, base_events, access, metrics, dimensions, content, flags, integrations. Anything else stays in properties, which is fine — reach for a typed group when you intend to query on the value.

Commerce carries its line items nested inside it, which is where the schema keeps them (commerce.products[] — there is no root products column; a top-level one is folded in for you):

await analytics.track("Order Completed", {
  commerce: {
    order_id: "BC-8891",
    revenue: 48900,
    currency: "ISK",
    products: [
      { entry_type: "Purchased Item", product_id: "CAR-ECON", product: "Economy Car",
        units: 1, unit_price: 39900,
        starts: "2026-09-20T10:00:00Z", ends: "2026-09-25T10:00:00Z" },
    ],
  },
  involves: [
    { role: "RENTED_TO", entity_type: "Person", id: "a3f9c2e8b1d40571", id_type: "BlueCar", label: "Name Redacted" },
    { role: "PICKED_UP_AT", entity_type: "Location", id: "KEF", id_type: "IATA", label: "Keflavik Airport" },
  ],
});

involves is the entity graph, and its fields follow conventions the validator cannot check for you:

| Field | What it must be | | --- | --- | | role | a graph edge type, UPPER_SNAKE — RENTED_TO, SUPPLIED_BY, BOUGHT_BY, VISITED. Not a noun like Customer. | | entity_type | a Capitalized noun — Person, Organization, Vehicle, Booking. | | id | a strong issued id: a standard code (IATA, GS1, ISO-4217), a source-system primary key, or a hash of a stable PII attribute. Never a name or anything re-issuable — a licence plate or username belongs in label. Never empty: drop the entry instead of sending a blank id. | | id_type | who issued the id — IATA, GS1, Stripe, your system's name. Not what the thing is. | | label | the display name. For a person, the literal "Name Redacted" — the real name belongs in context.traits, which is GDPR-isolated. |

ProductLine carries the booking-shaped fields too — starts, ends, duration, seats, destination, lead_time — so a rental or a flight is a product line, not a pile of properties.

Full field reference: Semantic Event schema.

Envelopes handed to send() / sendBatch() are transmitted verbatim — no lifting, no stamping. Shape those yourself.


What gets captured

In a browser the client fills these on every event. You do not pass them, and there is nothing to switch on — the pipeline models them as typed columns, and an event without them arrives with flags.page_missing set.

| Captured | Becomes | | ------------------------------------- | ----------------------------------------------------------------------------- | | context.page | url, path, host, title, referrer, referring_domain, search | | context.campaign | the utm_* set, read from the query string | | context.screen | width, height, pixel density | | context.user_agent_raw | one string, which the service expands into the device, os and browser columns | | context.locale / context.timezone | from the browser |

Page and campaign are re-read per event, so SPA navigation is tracked correctly. Anything you pass in the call's context option overrides what was captured. Outside a browser — Node, Workers, Deno, edge — nothing is captured rather than invented. context.ip and location are resolved by the service from the request, never by the client.

session_id is not one of these. The column exists and the schema assigns it to the client, but "a session" is a definition, not a fact — a 30-minute window invented here would disagree with however your warehouse sessionizes, and a column that quietly disagrees is worse than a null one. Set it yourself via send() if you have a definition you trust.


Validating payloads

Wrong value types in the typed groups are not rejected — they are coerced, and the coercion loses data:

dimensions: { rows: 15782390 }      → stored as "15782390"
metrics:    { status: "success" }   → stored as 0
flags:      { on: "yes" }           → stored as true

The schema is generated from the same Avro definitions the pipeline stores, so it cannot drift. zod is an optional peer dependency, pulled in only by importing this path:

import { PayloadSchema } from "@fraios/inbox-client/schema";

PayloadSchema.parse({
  dimensions: { job_name: "daily_user_aggregation", status: "success" },
  metrics: { duration_seconds: 1245.5 },
  flags: { is_full_refresh: true },
});

It describes what you author. Fields the client fills — messageId, timestamp, sentAt, anonymousId and context — and fields the service owns — partition, event_gid, received_at — are absent by design, and setting one is an error naming where it belongs.

TrackPayloadSchema, PagePayloadSchema and TraitsPayloadSchema validate a whole call, including the event name and options. Named types (Involved, Commerce, ProductLine, Traits and more) are exported alongside, carrying the field documentation into your editor.

Warnings while you build

With debug on, the client reports the mistakes the service accepts and then discards — identity fields in the payload, a typed group that is not an object, an event name the naming policy rejects. Warnings only: it never throws and never blocks a send.

new FraiosAnalytics({ token, debug: true });

// [fraios/inbox-client] "Order Complete" — event: not Title Case past tense…
// [fraios/inbox-client] "Order Complete" — properties.userId: belongs in the options argument…

Credentials

Pass the platform-issued Ingress Token you were given. That is the normal case:

new FraiosAnalytics({ token: process.env.INBOX_TOKEN });

Exactly one of token or refreshCredential is required; passing both is a ConfigError.

Use token for server integrations, and for browser-class tokens — those carry an aud claim pinning them to your origins, so they are safe to ship in page source, much like a Segment write key. Never put a server-class token (no aud) in a page.

If your backend mints a short-lived credential per session, supply a hook instead and the client calls it whenever it needs one, caching the result, coalescing concurrent refreshes, and re-invoking after a 401:

new FraiosAnalytics({
  refreshCredential: async () => (await fetch("/api/inbox-credential")).json(),
});

With a static token there is nothing to re-fetch: a 401 raises AuthError. That is intended — reissue or revoke the token rather than relying on refresh.


Source

source is the transformer-registered source name. It becomes the /inbox/{source} path segment and is stamped on every envelope as source.type. It defaults to web.

new FraiosAnalytics({ source: "web", token });
// POST /inbox/web        "source": { "type": "web" }

new FraiosAnalytics({ token }); // source omitted
// POST /inbox/web        "source": { "type": "Web" }

That field is not decoration: the service's source-claim guard reads source.type whenever the write key carries a source claim, and without it such tokens have every event dropped from the pipeline while the caller still sees HTTP 200 with event_count: 0.

The URL default is lowercase because the transformer lookup is an exact, case-sensitive match — /inbox/Web returns 404 where /inbox/web resolves. The claim comparison itself is case-insensitive, so a stamped "Web" still satisfies a claim of web.


Did it arrive?

HTTP 200 does not mean an event was ingested. The service answers 200 once it has accepted and routed the request; events that fail validation go to the dead-letter queue, and the response body reports how many made it through.

{ "status": "accepted", "messageId": "unknown", "event_count": 0 }    // nothing ingested
{ "accepted": 0, "rejected": "validation", "errors": [ … ] }          // nothing ingested
{ "status": "accepted", "messageId": "693c3a32-…", "event_count": 1 } // success

track(), page(), identify(), screen() and group() resolve on transport success and do not surface the body, so a resolved promise is not proof of ingestion.

Check that your source accepts batches before using sendBatch(). Whether a JSON array works depends on the transformer registered for your source. A naive JOLT identity spec ([{"operation":"shift","spec":{"*":"&"}}]) matches array indices at the root and shreds the batch: on web, an event accepted as a single object comes back {"accepted": 0, "errors": ["Event[0]: Missing required field 'type'"]} when wrapped in an array — still under HTTP 200. An array-aware transformer handles batches fine. Send one event as an array once and read event_count before relying on it.

To see what the pipeline did with an event — which fields were promoted to typed columns, which stayed in properties, what enrichment added — open the activity stream and send one while it is connected. It is a live feed of your own partition, after processing.

GET https://inbox.fraios.dev/api/activity/stream

  x-api-key   <your write key>
  Accept      text/event-stream

The consumer joins a shared Kafka group reading from LATEST, so allow ~25 seconds after connecting before you send — events sent during the group rebalance never reach the stream (they are still ingested). The first frame confirms which tenant your credential resolved to:

event:connection
data:{"status":"connected","partition":"acme.com","limit":50,"topicAvailable":true}

Leave that open, send an event from a second client, and it arrives as an event:semantic-event frame carrying the complete normalized record — the server-assigned event_gid, partition, received_at, resolved location, and a flags object naming what the pipeline expected and did not find. Fire one representative event of each kind you intend to send and read back where every field landed.

| Query parameter | Purpose | | --------------- | ----------------------------------------------------------------------------------------------------------------- | | match | Exact matches, comma-separated key:value. Keys: partition, region, eventType, or any path into the event. | | search | Free-text across the event. | | limit | Caps the events a connection will deliver. Default 50. No backlog is replayed — the consumer starts at LATEST. | | pause | Connect without streaming. |

The stream is scoped to the partition your credential resolves to; you never see another tenant's traffic. It is not a dry run — events sent while inspecting are ingested like any other.


Runtimes

| Runtime | Import | Notes | | ------------------- | ---------------------------------- | ----------------------------------------------- | | Browser (evergreen) | @fraios/inbox-client | localStorage anonymous-id, full context capture | | Node 20+ | @fraios/inbox-client | in-memory anonymous-id | | Cloudflare Workers | @fraios/inbox-client/workers | in-memory; degrades gracefully | | Vercel Edge | @fraios/inbox-client/vercel-edge | as Workers | | Bun | @fraios/inbox-client/bun | localStorage shim works out of the box | | Deno | @fraios/inbox-client/deno | localStorage works out of the box |

The runtime is selected by the exports field in package.json; most callers use the bare import and let the bundler resolve. All six are exercised in CI on every release.


Constructor options

new FraiosAnalytics({
  host?: string;               // default https://inbox.fraios.dev
  source?: string;             // default "web"; also stamped as source.type

  // exactly one of:
  token?: string;              // a platform-issued Ingress Token
  refreshCredential?: () => Promise<{ token: string; expiresAtMs?: number }>;

  debug?: boolean;             // request logging + payload warnings
  privacy?: { ipPolicy?, userAgentPolicy?, referrerPolicy? };
  refreshSkewMs?: number;      // default 60_000
  refreshTimeoutMs?: number;   // default 5_000
  fetchTimeoutMs?: number;     // default 30_000
  maxBatchSize?: number;       // default 1000 (server cap)
  maxPayloadBytes?: number;    // default 31_457_280 (30 MiB)
  legacySegmentMode?: boolean; // opt-in /api/s/{type} migration path
});

The full typed contract is specs/037-upgrade-client-sdks/contracts/client-js.d.ts in the monorepo. partition is rejected: the service resolves the tenant boundary from the verified write key.

Privacy

These cover what the visitor brings rather than what your site owns. Each takes "keep" (default), "strip" or "mask", fixed at construction — applications layer their own consent logic on top by choosing the policy.

| Option | strip | mask | | ----------------- | -------------------------------------------------------- | ----------------------- | | ipPolicy | removes context.ip | sets "0.0.0.0" | | userAgentPolicy | removes context.user_agent_raw and context.userAgent | sets both to "masked" | | referrerPolicy | removes context.page.referrer | sets "masked" |

These govern envelope contents only. The network-layer IP arriving via X-Forwarded-For is outside their reach — the service reads it from proxy headers regardless. Suppressing that is operator-side proxy configuration.


Errors

Every error extends InboxError and carries a klass string, so instanceof and e.klass === "…" both work.

| Class | When | | -------------------------------------- | --------------------------------------------------------------------------------------- | | ConfigError | Construction-time validation, or 404 — usually no transformer registered for the source | | AuthError | 401 — credential missing, invalid, expired, or lacking a claim | | PartitionDenyError | 403 — partition or scope denied | | ValidationError | 400/422, or a batch exceeding maxBatchSize / maxPayloadBytes | | InboxError with klass: "transient" | Network failure or 5xx. A CORS block lands here with no HTTP status. |

Auth, validation and partition-deny errors are not retried. Transient ones are queued.

Offline behaviour

  • Calls made before the first credential arrives queue and drain once it does.
  • Calls made while navigator.onLine === false buffer in memory and drain on the next online event.
  • No localStorage spillover, no persistence across tab close, no unload flush — events still queued when a tab closes are lost. For revenue-critical events, send from your server, or flush in your own page-lifecycle handler before unload.

Migrating from @jitsu/js

- import { jitsuAnalytics } from "@jitsu/js";
+ import { FraiosAnalytics } from "@fraios/inbox-client";

  const analytics = new FraiosAnalytics({
-   writeKey: "STATIC_WRITE_KEY",
+   token: process.env.INBOX_TOKEN,
  });

Method names and argument shapes are preserved: identify, track, page, screen, group, setAnonymousId, reset. jitsuAnalytics() and inboxClient() remain as factory aliases.

What differs: the static write key is replaced by a scoped, revocable credential, and routing is /inbox/{source} rather than destination plugins. Do not set partition — the client rejects that removed option. Jitsu's destination-plugin system is not included.

This is fraios-owned source, not a republished Jitsu bundle; @jitsu/[email protected] is the audited compatibility reference.


Contributing

This package lives in the fraios monorepo under apps/ingression/clients/js/. Releases are tagged clients/js/v<MAJOR>.<MINOR>.<PATCH> and published to npm with provenance attestation.

npm install
npm run generate:schema   # regenerate src/schema.generated.ts from shared/schemas/avro
npm run build             # rollup → dist/
npm run sync:static       # rebuild the Spring-hosted classic + ESM bundles
npm test                  # unit + Vitest runtime tests
npm run test:browser      # Playwright Chromium / Firefox / WebKit

check:schema and check:static run on publish and fail if either generated artifact has drifted from its source.

License

MIT. The compatibility reference is @jitsu/[email protected] from the Jitsu v2 line (MIT, © Jitsu Labs, Inc.) — see src/jitsu-vendor/ATTRIBUTION.md for provenance and deviations.