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

@volter/twin-expo

v0.1.35

Published

Local Expo push-notification-service twin (exp.host/--/api/v2/push) - device registration, per-recipient tickets, receipts. Built on @volter/world-core.

Readme

@volter/twin-expo

A local, stateful, vendor-faithful twin of Expo's push notification service — exp.host/--/api/v2/push/*, the Expo surface a backend talks to and the one the official expo-server-sdk speaks. Point an unmodified expo-server-sdk at it (EXPO_BASE_URL) and it registers devices, sends notifications and reads receipts against local state, with no notification ever leaving the process.

Built on the shared @volter/world-core kernel: every device registration and every accepted ticket is an action in the append-only kernel log, and every read uses the kernel’s tree. No side-store, no wall clock in the serve path, no network egress outside the connector's injected client.

Coverage

Honest and partial by construction. Expo is two products behind one brand, and this pack models one of them.

  • Modeled and verified — the push service:

    • POST /--/api/v2/push/getExpoPushToken — device registration. One ExponentPushToken[…] per (experience, device), stable across re-registration, distinct per device and per project.
    • POST /--/api/v2/push/send — one push ticket per recipient (an array-valued to expands, which is the count expo-server-sdk asserts its response against). The single-ticket rule is the vendor's own recipient-based one — data is a lone ticket object only for a single message to a single recipient, so an object body addressed to two tokens still answers a two-element array. Plus the vendor's verbatim DeviceNotRegistered error ticket for a token this service never issued, the published PUSH_TOO_MANY_NOTIFICATIONS (>100) and PUSH_TOO_MANY_EXPERIENCE_IDS refusals with their complete published messages, the ?useFcmV1=false query parameter, an optional Authorization: Bearer, and gzip request bodies — which is what the SDK sends for any payload over 1024 bytes.
    • POST /--/api/v2/push/getReceipts — a receipt map keyed by ticket id that omits ids with no receipt, the documented MessageTooBig fold at the 4KiB ceiling, and the published PUSH_TOO_MANY_RECEIPTS refusal (>1000 ids). It is a pure read: a read-only twin serves it.
    • A non-POST on any push path answers the real service's wire: 405, allow: POST, content-type: text/plain, body Method Not Allowed — not a JSON error envelope, because expo-server-sdk's error path branches on exactly that difference. (Evidence scope: GET is the method that was probed against exp.host; the twin applies the same answer to every non-POST because the push API is POST-only, but HEAD/OPTIONS/PUT/DELETE were not observed.)
    • A connector that observes a real account's receipts over an injected client and folds them onto the twin's own tickets — after which the twin's getReceipts serves the observed result. A pulled row carries only what was observed: no fabricated ticket status, and the outbox marks it observed_only so a receipt pulled for an id this twin never issued can never be read as a send this twin accepted.
  • Todo (real vendor surface, enumerated, unbuilt) — the EAS platform: builds, submissions, updates/branches/channels, workflows (api.expo.dev/v2/workflows/*), credentials, devices, metadata, projects, accounts and webhooks over api.expo.dev/graphql; the EAS Update manifest protocol on u.expo.dev; and the push-security auth posture. See expo-capabilities.ts for the full ledger.

    ⚠ What the coverage percentage does and does not say. The push half is enumerated per-operation and is genuinely near-complete. The EAS half is enumerated per-domain (the granularity linear uses for GraphQL, and the one Expo's own CLI command groups use), so its ~30 rows stand in for a GraphQL schema with hundreds of RootQuery/RootMutation fields. Read an EAS row as "this domain is unbuilt", never as "this domain is one operation". Deepening that denominator to per-field granularity is the honest next revision, and it will make the percentage drop — which is success, not regression. No notification ever leaves this process. The twin reproduces the acceptance contract faithfully — per-recipient tickets, the DeviceNotRegistered refusal, receipts and their error codes — and accepts whatever deviceToken a registration call carries, issuing a faithful ExponentPushToken for it, which is the whole of what the server side of the protocol can observe. The published 600-notifications-per-second ceiling and its TOO_MANY_REQUESTS code are modeled as vendor knowledge in expo-budget.ts; the served response stays a pure function of (request, stored state), so the twin never fabricates a timing-dependent 429. The EAS build control surface (submit, list, view, cancel) is a filed todo.

Everything else — every unmodeled route — answers a loud [twin gap] 404 inside Expo's own { errors: [...] } envelope, never a fake success.

No UI mirror

The API is the product. When someone does Expo's core job — building, submitting and updating a React Native app, or sending a notification — they write code and drive a CLI (eas build, eas submit, eas update; on a server, expo-server-sdk). expo.dev exists, but it is a build-log / credentials / billing console around work that happens elsewhere, and eas-cli is itself just a client of api.expo.dev/graphql. The surface this pack serves settles it beyond argument: the push service has no human surface at all — it is a machine-to-machine endpoint a backend calls. This pack therefore ships no mirror, declares no UI capabilities and registers no journeys (census.json's ui slice records the ruling).

Hosts and world wiring

exp.host is the only host claimed by the injector. api.expo.dev (EAS) and u.expo.dev (update manifests) are real Expo hosts this twin does not model, and claiming them would break a world more quietly than leaving them alone: a real EAS call would meet this pack's [twin gap] 404 instead of the real API. They become hosts entries in the same revision that models them.

A world can also point an unmodified SDK here with nothing but an env var: EXPO_BASE_URL is the vendor SDK's own base-URL override, read at module load in expo-server-sdk-node's src/ExpoClientValues.ts (process.env['EXPO_BASE_URL'] || 'https://exp.host'). That is the pack's declared endpointEnv.

Fidelity

expo-sdk.integration.test.ts drives the real, unmodified expo-server-sdk over real HTTP at the twin: it chunks, gzips, POSTs, validates the response length against its own _getActualMessageCount, parses the envelope, and reads its receipts back. The SDK major is pinned to ^3.15.0 — the major our own consumer ships (Ponder's apps/server depends on expo-server-sdk@^3.7.0). "Unmodified" here means un-patched, not un-configured: the base URL is set through the SDK's own documented EXPO_BASE_URL, exactly as an integrator would.

The store door

Expo's push API has no listing endpoint of any kind — send is fire-and-forget and getReceipts is keyed by an id the caller must already hold. So the pack declares two named deterministic projections over stored state, served at the kernel's store door and listed in GET /twin:

  • GET /twin/store/devices — the registered push tokens.
  • GET /twin/store/outbox — the accepted push tickets.

These are the only way to observe what the twin accepted, and they are what makes this pack's determinism checkable at the resource level (invariant R9) rather than only at the manifest door.

Grounding

Every literal in this pack traces to a first-party Expo source, read-only on 2026-09-02:

| Fact | Source | |---|---| | send / getReceipts contracts, error codes, 100 & 1000 caps, 4KiB ceiling, 600/s | expo/expo docs/pages/push-notifications/sending-notifications.mdx, faq.mdx | | device registration payload and response | expo/expo packages/expo-notifications/src/getExpoPushTokenAsync.ts | | wire URLs, chunk limits, gzip threshold, optional bearer, ticket-count assertion | [email protected] (ExpoClientValues.ts, ExpoClient.ts) | | EAS denominator | Expo's first-party GraphQL schema — introspection at api.expo.dev/graphql, pinned at expo/eas-cli packages/eas-cli/graphql.schema.json |

Three modeled details are disclosed approximations, not vendor facts, and each is called out in the capability title that claims it:

  1. The exact error envelope for a malformed request. Expo documents the whole-request errors[].code vocabulary and the complete message text for each code, but not the envelope for every validation case, so the twin's VALIDATION_ERROR code and its HTTP 400 are modeled choices — the errors: [{ code, message }] shape and the four published codes are not.
  2. The byte measurement behind MessageTooBig. The twin measures the message minus its to field; the real service measures the provider-specific payload it builds, which a local twin cannot construct. The 4096-byte ceiling itself is published.
  3. Registration validation. Expo documents no error shape for a getExpoPushToken call missing deviceId or an experience; the requirement is grounded in getExpoPushTokenAsync's own payload, the refusal envelope is the twin's.

Deliberately not approximated, because the real wire was probed: the method-not-allowed reply is plain text, not JSON; the four whole-request error messages are the complete published sentences; and the push-token seed is JSON.stringify([project, device]) rather than a :-joined string, because both operands come off the wire and a : delimiter provably collides (('acme', 'phone:1') and ('acme:phone', '1') minted one token, silently rebinding a device's experience).