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

app-settings-js

v0.1.3

Published

TypeScript SDK for the App Settings API: role-based, layered application settings. No dependencies; runs in browsers, Node, Bun and Deno.

Readme

app-settings-js

A TypeScript SDK for the App Settings API.

  • No dependencies. Nothing but the platform's own fetch.
  • Runs anywhere. Browsers, Node 18+, Bun, Deno, and edge runtimes. ESM and CJS.
  • React-ready, React-free. No React import anywhere in this package. The store exposes the subscribe / getSnapshot pair useSyncExternalStore wants, so React is three lines away — and so are Vue, Svelte and Solid.
bun add app-settings-js   # or npm / pnpm / yarn

Quick start

import { AppSettingsClient } from "app-settings-js";

const client = new AppSettingsClient({
  baseUrl: "https://settings.example.com",
  apiKey: process.env.SETTINGS_API_KEY!,
  environment: "production",
});

const settings = await client.resolveUser("alice");

settings.boolean("dark_mode");          // false
settings.number("page_size", 25);       // 25 if unset
settings.string("theme");
settings.date("digest_at");             // a Date
settings.json<Limits>("rate_limits");

resolveUser returns a SettingsSnapshot: every setting the user can see, already collapsed to one effective value each. That is the call a product backend makes, and usually the only one it needs.

Where this runs. The API key is a server credential. Settings are meant to be read through your own backend, which authenticates the user first. Putting a key in a browser bundle hands every visitor whatever that key can reach. Front ends should call your server, and hydrate a store from what it returns — see Server rendering.

The snapshot

A snapshot is immutable, and its identity changes only when the data does, which is what lets a UI framework skip a re-render.

settings.has("dark_mode");          // is it visible to this role at all?
settings.isSet("dark_mode");        // did any layer, or the default, supply a value?
settings.source("dark_mode");       // "PERSONAL" | "SERVER" | "DEFAULT" | ...
settings.get("dark_mode");          // the whole ResolvedSetting
settings.toObject();                // { dark_mode: false, page_size: 25, ... }

for (const setting of settings) { /* iterable, in the server's order */ }

Typed accessors return the fallback when a setting is unset or invisible to the role, and throw when the setting exists but holds another type — which can only mean the wrong name was asked for:

settings.string("dark_mode");
// AppSettingsError: setting "dark_mode" is declared BOOLEAN and holds a boolean, not a string

list() covers SELECT, returning a single choice as a one-element array so no caller has to branch on multiple:

settings.list<string>("tags");   // ["a", "b"]
settings.options("tags");        // [["A", "a"], ["B", "b"]] for a picker

Overrides

A group override is either advisory (a group-wide default the user's own value beats) or enforced (a policy that beats it). Orthogonally, an override may be marked invisible, meaning the user is not meant to observe it.

settings.isEnforced("retention_days");     // disable the control
settings.visibleOverride("retention_days"); // the override you may tell them about
settings.override("retention_days");        // the raw one, visible or not
settings.editable();                        // personal scope, not enforced

visibleOverride is the accessor a UI should reach for: it withholds an override the server marked visible: false, so a snapshot cannot leak one by accident.

The store

createSettingsStore keeps one resolution current and tells subscribers when it changes. It handles the parts that are tedious to get right: no fetch until something is listening, one request shared between concurrent refreshes, stale responses dropped, and the last good snapshot kept when a refresh fails.

import { createSettingsStore } from "app-settings-js";

const store = createSettingsStore(client, {
  userId: "alice",
  refreshIntervalMs: 60_000,
  revalidateOnFocus: true,
});

store.subscribe(() => console.log(store.getSnapshot().snapshot?.toObject()));
await store.set("dark_mode", true);   // optimistic, rolled back if rejected
await store.clear("dark_mode");       // fall back to the layer beneath

getSnapshot() returns a SettingsState:

| Field | Meaning | | --- | --- | | status | idle before the first load, then loading, ready or error | | snapshot | the current SettingsSnapshot, or null before the first one | | error | why the last load failed; cleared by a successful one | | isValidating | a request is in flight, including a background refresh | | updatedAt | when the current snapshot arrived |

refresh() never rejects — the failure lands in error — so a caller needs no try/catch. set() and clear() do reject, because a rejected write is something the caller has to react to.

React

There is no React module here, and there does not need to be one. Paste this into your app:

import { useSyncExternalStore } from "react";
import { createSettingsStore, type SettingsStore } from "app-settings-js";

const store = createSettingsStore(client, { userId: currentUserId });

function useSettings(store: SettingsStore) {
  return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
}

function DarkModeToggle() {
  const { snapshot, status } = useSettings(store);
  if (!snapshot) return status === "error" ? <Failed /> : <Spinner />;

  return (
    <label>
      <input
        type="checkbox"
        checked={snapshot.boolean("dark_mode")}
        disabled={snapshot.isEnforced("dark_mode")}
        onChange={(event) => store.set("dark_mode", event.target.checked)}
      />
      Dark mode
      {snapshot.visibleOverride("dark_mode") && <Badge>Set by your team</Badge>}
    </label>
  );
}

getSnapshot returns the same object until something actually changes, which is exactly the contract useSyncExternalStore enforces, and getServerSnapshot makes the store safe to render on a server.

The same two functions adapt anywhere: Svelte's readable, Vue's shallowRef plus onScopeDispose, or a Solid signal.

Server rendering

Resolve on the server, serialise the response into the page, and hand it to the store so the first render already has data and no request is made:

// server
const settings = await client.resolveUser(userId);
return { props: { settings: settings.toJSON() } };

// client
const store = createSettingsStore(client, { userId, initialData: props.settings });

Writing values

Each layer has its own namespace, mirroring the API's routes:

await client.values.server.set(settingId, true);
await client.values.personal.set(settingId, "alice", "solarized");
await client.values.group.set(settingId, groupId, false, {
  enforced: true,   // beats the user's own value
  visible: false,   // the user is not meant to observe it
});

await client.values.personal.clear(settingId, "alice");

undefined is refused before a request is made — clearing a value is clear(), which is a DELETE. null goes through, because it is a legitimate JSON value.

Definitions, groups, taxonomy and keys

await client.settings.list({ scope: "PERSONAL" });
await client.settings.create({
  name: "page_size",
  type: "NUMBER",
  scope: "PERSONAL",
  platform: "web",
  typeConfig: { min: 10, max: 100, integer: true },
  defaultValue: 25,
});
await client.settings.delete(id, { cascade: true });   // required if values exist

await client.groups.create({ name: "beta", members: ["alice"], priority: 10 });
await client.groups.addMembers(groupId, ["bob"]);

await client.taxonomy.roles();
await client.taxonomy.upsertRole("auditor", { rank: 25 });

const minted = await client.keys.create({ name: "web backend", scopes: ["resolve"] });
minted.token;   // the only time it is ever available

Datetimes

DATETIME accepts only a strict RFC 3339 value with an offset, because an offset is what makes a value an instant rather than a wall-clock reading. A Date satisfies that automatically; <input type="datetime-local"> does not.

import { localToInstant, parseInstant, toDateTimeLocal, toInstant } from "app-settings-js";

toInstant(new Date());                              // "2026-01-02T20:04:05.250Z"
toInstant("2026-01-02T15:04:05-05:00");             // "2026-01-02T20:04:05.000Z"
toInstant("2026-01-02T15:04");                      // throws, naming the problem

localToInstant("2026-01-02T15:04", "America/New_York");  // "2026-01-02T20:04:00.000Z"
toDateTimeLocal(value, "America/New_York");              // back into the input
parseInstant(settings.value("digest_at"));               // a Date

Passing the timezone to localToInstant is the point: the same reading is a different moment in every zone, so the SDK makes you say which one rather than guessing. store.set() converts a Date for you.

Errors

Everything throws one class. Branch on code, which is stable, not on the message, which is written for a human.

import { AppSettingsError, isNotFound, isForbidden } from "app-settings-js";

try {
  await client.settings.get(id);
} catch (error) {
  if (isNotFound(error)) return null;
  if (isForbidden(error)) throw new Error("this key is fenced out of that environment");
  if (AppSettingsError.is(error)) {
    error.code;       // "not_found" | "forbidden" | "network_error" | "timeout" | ...
    error.status;     // 404
    error.requestId;  // the server's X-Request-ID, worth quoting in a bug report
    error.retryable;
  }
  throw error;
}

A key fenced out of an environment gets not_found rather than forbidden for a setting inside it — the server declines to reveal that it exists.

Failed requests are retried automatically on network errors, 5xx and 429, with exponential backoff and jitter, honouring Retry-After. POST is never retried, being the only non-idempotent method in the API. Tune it per client:

new AppSettingsClient({ baseUrl, apiKey, retries: 5, retryDelayMs: 100, timeoutMs: 5_000 });

Every call also takes { signal, timeoutMs, headers } for a one-off override.

Testing against it

The client takes any fetch, so tests need no network and no mocking library:

const client = new AppSettingsClient({
  baseUrl: "https://settings.test",
  apiKey: "as_test",
  environment: "test",
  fetch: async () => new Response(JSON.stringify(fixture), { status: 200 }),
});

Requirements

Any runtime with a global fetch: Node 18+, Bun, Deno, or a browser. Pass your own fetch for anything older. TypeScript is optional; when used, the declarations are checked against TypeScript 5.9 and 7.x.

Development

just            # list every recipe
just check      # typecheck + test
just build      # ESM, CJS and declarations into dist/
just test       # bun test

From the repository root, just sdk <recipe> reaches these, and just check-all runs the server's checks and the SDK's together.

Releasing

The SDK shares one version with the Go server and the React registry, and publishes from CI when a v* tag is pushed. See Releasing.

just preflight       # tests, build, and the packaging checks below

just verify runs @arethetypeswrong/cli and publint against the real tarball, which is what catches the two failure modes this package is shaped around:

  • The require path needs its own declarations. package.json is type: module, so a lone index.d.ts describes the CommonJS build as ESM. The build emits a .d.cts beside every .d.ts and the exports map points each condition at the matching one.
  • sideEffects is deliberately absent. Bun 1.4.0's bundler tree-shakes every module out of a pure re-export barrel when that field is present, emitting export { … } with nothing bound — valid-looking output, a zero exit code, and a package that throws SyntaxError on import. bun run build asserts the bundle contains an implementation so this cannot ship again.