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

bozana-expo-version-json

v9.0.0

Published

Consumer-side version.json contract for ci-artifacts Expo version-state — strict read/validate/types. Zero runtime deps.

Readme

bozana-expo-version-json

Consumer-side version.json contract for the ci-artifacts Expo version-state chain. A zero-runtime-dependency TypeScript package that owns the read / validate / types side of the mobile version.json written by the ci-artifacts producer (ci/flagsmith/mobile_version_get.py).

It replaces the per-consumer inline logic that was triplicated across hive (app.config.ts), ru4m (apps/frontend), and implicit in the producer.

Requirements

Node >= 20.19 (or >= 22.12). Package-wide, not just for the ./metro subpath: engines.node is >=20.19.0. The package is ESM-only, and the canonical consumer snippet require()s ./metro from a CJS metro config — require(ESM) exists only from those releases, so on Node 18 that config crashes at load. An engines field advertising >=18 was a lie for the export the metro-cache gate (CI-223) requires, which is why the floor is a breaking change and this package's major.

Why a package

The producer writes a loose / bootstrap-friendly shape: per-platform nodes may be empty (bootstrap) or absent entirely (single-platform / iOS-only stores are producer-valid — the absent sibling key is not injected). The consumer validates only the platforms actually present; a present node carrying EITHER version or buildNumber must carry BOTH, valid (both-or-neither — a half-hydrated node is corruption and fails loud, so a wrongly-versioned binary never ships). Centralizing that logic in one typed package kills the cross-repo drift (kept in sync by hand across two languages and three places).

Install

Install from the public npm registry without tokens, keys or an npm account:

npm install bozana-expo-version-json

Only the publishing CI needs NPM_TOKEN. The package declares UNLICENSED; public distribution does not grant an open source license. See the root npm publication guide for release configuration and the verification required before declaring a public release.

main / types / exports point at the committed dist/ (compiled JS + .d.ts), so consumers import compiled JS — no build toolchain required and no transpile step inside @expo/config's evaluation of app.config.ts.

API

import {
  loadVersionData,
  assertVersionData,
  resolveBuildNumber,
  type VersionData,
} from "bozana-expo-version-json";
  • loadVersionData(dir): VersionData — reads <dir>/version.json. Present → assertVersionData (loose-but-present); absent → seed (local dev / fresh clone). Throws on corrupt / half-hydrated (no seed fallback on corrupt).
  • assertVersionData(raw): VersionData — build must be an object; each platform node (build.ios, build.android) is OPTIONAL — single-platform / iOS-only stores are producer-valid (the absent sibling key is not injected), so only the platforms actually PRESENT are validated. A present node may be EMPTY (legitimate bootstrap — the producer's rc=3 auto-seed writes empty nodes, and app.config.ts is evaluated against them during the fingerprint gate). A present node carrying EITHER version or buildNumber must carry BOTH, valid (non-empty string / positive integer) — a half-hydrated node is corruption and throws. Arrays / non-object roots & present-but-non-object nodes, and ota.<platform> non-objects, also throw.
  • resolveBuildNumber(raw, platform): number — null/undefined → 1; positive integer → passthrough; anything else (true, "7", 0, -1, 1.5, NaN) throws — no Number() coercion.
  • Types — Platform, PlatformBuild, PlatformOta, VersionData, HydratedVersionData, Environment.
  • seed — { build: { android: {}, ios: {} } }.

Native version projection

loadNativeVersionCoordinates(dir) returns only the validated native version/buildNumber pairs. It never returns ota, channel, dates, or other payload fields. The projection used by this API and withVersionJson is identified by NATIVE_VERSION_CONTRACT (expo-native-version-v1) and includes both platform slots, with absent/bootstrap coordinates represented as absent coordinates. Consumers may still derive a runtime version from the returned coordinates; they must not derive native identity from other version.json fields under this contract.

The ./probe export provides installVersionUsageProbe plus the versioned usage protocol CI installs while the real Expo config is evaluated. Protocol 2 is a capability, not an event feed: a collector supplies readNative(versionPath) and answers with the native descriptor derived from the bytes IT read, so loadNativeVersionCoordinates and the native withVersionJson branch ask for coordinates instead of declaring their own usage, and the payload never crosses the native path. Nothing published here writes usage events; the projection itself is nativeDescriptorFromText / absentNativeDescriptor, pure functions over text that apply exactly the assertVersionData + native-projection rules (no consumer transform or serializer) so CI never restates them.

Installation captures the collector's protocol, projection contract and readNative function as own data properties, then freezes the collector. Its object identity stays the same for CI's ownership check; config code cannot replace, delete or redefine the installed capability. Uninstalling with installVersionUsageProbe(undefined) remains supported.

loadVersionData and withVersionJson(..., { toExtra: true }) read the whole payload through ordinary node:fs, which is what CI classifies as full exposure — by observation, not declaration. When no compatible collector is installed (none, an older protocol, a foreign projection contract, or an answer the package's own rules would not produce), the helpers fall back to the same local read, so the conservative full-payload path is what CI reports. CI also audits Node filesystem opens, so descriptor, stream and async access cannot bypass attribution by omitting a path from the final read call. A projected shell additionally requires matching helper-use evidence from the actual native build, not just the fingerprint evaluation; build-time toExtra declines shell seeding. This protocol is evidence about the helper and observed Node reads; it does not certify arbitrary plugin behavior or other native inputs.

The CI-278 delivery treats this as cooperative instrumentation. It accepts the remaining startup, collection-prototype, child-delegation and report-write gaps described in the Maestro observation boundary. PR #829 remains open for that hardening; a supported protocol response alone does not establish a tamper-proof report.

Maestro's native-profile adapter also binds the producer's transitive config inputs to both cache tiers. Its dependency manifests require Node >= 22.15 for synchronous resolution hooks; this does not change the reader/plugin API's Node requirements. Ordinary app JS stays outside the native dependency graph.

Usage in app.config.ts

import { loadVersionData, resolveBuildNumber } from "bozana-expo-version-json";
import pkg from "./package.json";

const versionData = loadVersionData(__dirname);

// build.ios is optional (single-platform stores) — `?.` then fall back to pkg
// defaults on the no-file (seed) path OR an absent platform.
const versionIos = versionData.build.ios?.version ?? pkg.version;
const buildNumberIos = String(resolveBuildNumber(versionData.build.ios?.buildNumber, "ios"));

App identity / channel logic stays in each consumer's app.config.ts (app-specific; not part of the version contract).

Planned OTA Sentry identity

Schema v85+ consumers may opt into one OTA identity shared by the bundle, runtime SDK, version store, and CI stamp:

import { resolve } from "node:path";
import { otaSentryExtra } from "bozana-expo-version-json/app-config";

const otaExtra = otaSentryExtra({
  data: versionData,
  bundleIds: {
    ios: "com.example.app",
    android: "com.example.app",
  },
  service: "mobile",
  repoRoot: resolve(__dirname, "../.."),
});

export default {
  extra: {
    ...otaExtra,
    eas: { projectId: "..." },
  },
};

otaSentryExtra returns undefined unless CI_EXPO_OTA_EXPORT=1 and the effective service manifest enables observability.sentry.stamp.ota_identity. This explicit lane marker prevents a hydrated previous OTA from changing fingerprint preflight or being baked into a new native binary. When enabled, CI_EXPO_PLATFORM must be exactly ios or android; only that existing ota.<platform> node is read and only that identity is emitted. The sibling node is not required or added. The helper renders <bundle-id>@<version>+<build-number> and uses the positive decimal build number as Sentry dist.

Runtime code imports the separate React-Native-safe subpath:

import { sentryReleaseFromExtra } from "bozana-expo-version-json/sentry";
import Constants from "expo-constants";
import { Platform } from "react-native";

Sentry.init({
  ...(Platform.OS === "ios" || Platform.OS === "android"
    ? sentryReleaseFromExtra(Constants.expoConfig?.extra, Platform.OS)
    : {}),
  dsn,
});

An absent ciOtaSentry returns null, preserving the SDK's native identity. A present value must contain exactly the runtime's platform key. A mismatched, multi-platform, or otherwise malformed value throws instead of silently sending events to a release different from the one CI finalized.

metroEnvCacheVersion (./metro export) — env-derived Metro cacheVersion

babel-preset-expo inlines EXPO_PUBLIC_* VALUES into the bundle at transform time, but Metro's transform cache key hashes no env values — on a persistent CI runner a "fresh" build silently reuses transforms carrying the old inlined values (CI-223). Deriving cacheVersion from the inlined env makes a changed/rotated/added value a clean cache miss while an unchanged env keeps the warm cache. The bozana metro-cache gate REQUIRES every Expo consumer's metro config to carry exactly this canonical call:

// metro.config.js / metro.config.cjs
const { metroEnvCacheVersion } = require("bozana-expo-version-json/metro");

config.cacheVersion = metroEnvCacheVersion();
  • Hash of every EXPO_PUBLIC_* pair in process.env at metro-config load time, as env-<16 hex> (sorted, NUL-separated key\0value\0, sha256). Under the Expo CLI that env already includes .env-loaded values, so local dev gets the same staleness protection.
  • A set-but-empty variable hashes differently from an unset one — babel inlines them as "" vs undefined, which are different bundles.
  • Parameterless on purpose: the gate recognizes the canonical call, and an options surface would invite per-consumer drift.
  • The package is ESM-only; require() of the ./metro subpath needs Node >= 20.19 / >= 22.12 (require(ESM)). Kept out of the . reader so the fingerprint-hash path stays node:fs + node:path only.
  • Requires bozana-expo-version-json >= 5.0.0 — the version floor the metro-cache gate's remedy names.

Config plugin (withVersionJson — ./plugin export / root app.plugin.js)

A sync Expo config plugin that applies the hydrated version.json onto the Expo config, replacing the per-consumer app.config.ts glue shown above:

| Field | Applied from | Type | |-------|--------------|------| | expo.version | build.ios.version, else build.android.version | string | | expo.ios.buildNumber | build.ios.buildNumber | string | | expo.android.versionCode | build.android.buildNumber | integer |

Semantics:

  • A hydrated platform node wins over values already on the config — pipeline data is authoritative; static values in app.config.ts are only the fallback.
  • An absent node (single-platform store) or an empty bootstrap node leaves that platform's fields untouched.
  • Corrupt / half-hydrated payloads throw (via loadVersionData) — never a silent wrong version.
  • Reading is node:fs/node:path only — safe inside fingerprint evaluation; applied fields enter the fingerprint hash exactly like the manual wiring did.
  • dir resolution order: explicit option → _internal.projectRoot → cwd. _internal.projectRoot is attached by Expo's config machinery AFTER app.config.ts evaluation — so the direct-application form below runs at module-eval time and MUST pass { dir: __dirname } explicitly (what the old loadVersionData(__dirname) glue did). The string-id and function-reference plugin forms receive the resolved config with _internal.projectRoot and need no option; a wrong root silently hits the absent-file seed path and leaves static values in place.

Usage — direct TS application (primary form):

// app.config.ts
import { withVersionJson } from "bozana-expo-version-json/plugin";

export default withVersionJson(
  {
    // ...the rest of the consumer config...
  },
  { dir: __dirname },
);

String id in the plugins array (works from a plain app.json too):

{
  "expo": {
    "plugins": ["bozana-expo-version-json/app.plugin.js"]
  }
}

Function reference inside plugins: (same effect, no module resolution):

import { withVersionJson } from "bozana-expo-version-json/plugin";
export default { plugins: [withVersionJson] };

Options: { dir } overrides where version.json is read from (an empty string is treated as unset — the resolution chain applies); { toExtra: true } additionally copies the validated build + ota payload into config.extra.versionJson as a detached snapshot, readable at runtime via Constants.expoConfig.extra.versionJson (useful e.g. for Sentry tags: channel / fingerprint / gitCommitHash). The bare id "bozana-expo-version-json" is NOT a valid plugin id — the main entry is the reader and exports no default.

getVersion (hydrator — ./getVersion export + expo-version-json-get bin)

The . export above is the zero-dep READER. The separate ./getVersion export (and the expo-version-json-get bin) is the HYDRATOR: it reads bozana.config.json, resolves the service's release.source contract, fetches the version, and writes <service.path>/version.json. Wire it as a prestart / prebuild hook so a fresh checkout materializes the file the reader then loads. Kept out of the . export on purpose — its network/git code never enters app.config.ts evaluation / the fingerprint hash.

expo-version-json-get mobile
# service arg optional — inferred from cwd when omitted

Sources, by bozana.config.json release.source (+ source_config):

  • git-conventional (default) — version from the latest matching git tag, bumped by conventional-commit analysis since that tag. source_config.version_base (v22 closed enum, default tag): tag keeps the git-tag anchor; storage re-anchors on the version-state store — the reader then reads the stored per-platform baseline directly (no bump), so local dev gets the REAL dev version.
  • flagsmith — GET /flags/?feature=<flag> + X-Environment-Key.
  • file — version string from source_config.path.
  • manual — MANUAL_VERSION env.

Same-version Bozana builds pass BOZANA_REPEAT_BUILD_NUMBER to select the allocated native number after source validation. EAS_BUILD_PLATFORM scopes the override to the selected platform; other coordinates and metadata remain intact. The override must be a positive safe integer. Without it, hydration keeps the source's normal build-number precedence.

version_base: storage — backend selection + env contract

The store backend is chosen from the manifest, NOT from an env var: services.<service>.build.expo.fingerprint_gate.backend ∈ infisical | flagsmith | git | backoffice (backoffice added in services v25). There is no default — an absent backend fails loud, matching the ci-artifacts producer (ci/version_state/factory.py raises when no manifest backend is resolved; CI_VERSION_STATE_BACKEND only selects the test-only memory/none backends). Only the choice lives in the manifest; per-backend addressing + credentials stay in env (secrets out of the repo). The non-persistent backends (memory / none) fail loud — there is no state to read.

Each backend tracks the matching ci-artifacts producer backend and fails loud on absent/corrupt state (never a silent wrong version):

infisical — secret STATE_<env>_<logical_env>_<service>_<key>_<sha12> (the 12-hex suffix hashes the un-sanitized env\0logical_env\0service\0key tuple; overflow past 100 chars trims key then service). logical_env is load-bearing — a single storage-env pool serves multiple logical envs. The formula is byte-for-byte parity with ci/infisical/version_state_backend.py:_build_secret_name, pinned by versionStateStore.test.ts vectors generated from the real Python output.

| Var | Purpose | |-----|---------| | INFISICAL_API_URL / INFISICAL_PROJECT_ID | endpoint + workspace | | INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET | universal-auth machine identity | | (storage env slug) | not an env var — derived from bozana.config.json environments.<env>.infisical_env, then the logical env, then prod (mirrors the producer; the old INFISICAL_ENV / VERSION_STATE_INFISICAL_STORAGE_ENV inputs were removed) | | VERSION_STATE_INFISICAL_LOGICAL_ENV | CI logical env — optional override; else derived from BITBUCKET_DEPLOYMENT_ENVIRONMENT / CI_LOCK_LOGICAL_ENV / EXPO_PUBLIC_CHANNEL / SENTRY_RELEASE_ENV (fail-loud if none resolves), mirroring the producer's resolve_version_state_env | | VERSION_STATE_INFISICAL_FOLDER | secret folder (default /version-states/) |

flagsmith — reads the admin featurestates API (NOT the public /flags/ endpoint the release.source: flagsmith source uses). Feature name = fingerprint_gate.key. Mirrors ci/flagsmith/version_state_backend.py.

| Var | Purpose | |-----|---------| | FLAGSMITH_SERVER_API_KEY | Authorization: Token … (required) | | FLAGSMITH_ENVIRONMENT_KEY_<SERVICE> or bare FLAGSMITH_ENVIRONMENT_KEY | environment key | | FLAGSMITH_API_BASE_URL | default https://api.flagsmith.com/api/v1 | | VERSION_STATE_FLAGSMITH_FEATURE_NAME | override the feature name (rare) |

git — git show <remote>/<branch>:<logical_env>/<service>/<key>.json. Mirrors ci/git/version_state_backend.py.

| Var | Purpose | |-----|---------| | VERSION_STATE_GIT_LOGICAL_ENV | path segment — optional override; else derived from the deploy context (BITBUCKET_DEPLOYMENT_ENVIRONMENT / CI_LOCK_LOGICAL_ENV / EXPO_PUBLIC_CHANNEL / SENTRY_RELEASE_ENV), fail-loud if none resolves | | VERSION_STATE_GIT_REMOTE | default origin | | VERSION_STATE_GIT_BRANCH | default ci-version-state |

backoffice (services v25+) — GET <VERSION_STORE_API_URL>/api/version-store/state ?key=&service=&environment= against the centralized backoffice version-store, Bearer auth. The stored payload is the full version.json; a 200 must carry it plus a positive integer revision (the CAS coordinate) or it fails loud as corrupt. A 404 {"code":"NOT_FOUND"} is the empty-store signal; a bare 404 is a bad URL/route. Mirrors ci/backoffice/api/api.py (get_state).

| Var | Purpose | |-----|---------| | VERSION_STORE_API_URL | base host, e.g. https://backoffice.infopuls.app (path built in code) | | VERSION_STORE_TOKEN | Authorization: Bearer … (Passport token with version-store:read) | | VERSION_STATE_BACKOFFICE_ENV | environment scope segment — optional override; else derived from the deploy context (BITBUCKET_DEPLOYMENT_ENVIRONMENT / CI_LOCK_LOGICAL_ENV / EXPO_PUBLIC_CHANNEL / SENTRY_RELEASE_ENV), fail-loud if none resolves |

The User-Agent (ci-backoffice/1.0) is load-bearing: the Cloudflare WAF on *.infopuls.app returns 403 error code: 1010 for a default library UA.

Develop

bun install       # devDeps only (typescript, @types/node)
bun test          # present / absent / corrupt / partial + resolveBuildNumber edges
bun run typecheck # tsc --noEmit
bun run build     # tsc → dist/ (JS + d.ts)

The version.json shape and the strict consumer semantics are owned here; the producer (Python) and the release orchestration (bozana.config.json / bump → tag) live in the rest of ci-artifacts.