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

@webority/web-core

v0.80.0

Published

Platform layer for Webority React web portals: HTTP clients with single-flight token refresh, auth store, API error extraction, RTK Query base query, logger, formatters, storage, version check and route guards. No UI.

Readme

@webority/web-core

The platform layer for a Webority React web portal, with no UI: HTTP clients that refresh an expired token once for every caller, a token store, one reader for API errors, an RTK Query base query, a logger, formatters, storage, a "new version" check and the route guards. The components that draw things (the error screen, the toast host, the version bar) live in @webority/ui-react.

It is the web twin of @webority/mobile-core. The two packages split their concepts the same way, so a module has the same folder name and the same export names and TypeScript signatures on both sides. Only the parts that depend on the platform differ: web stores tokens in the browser and guards routes with react-router; mobile stores them in the secure keychain and checks the app store.

| Module | What it holds | | --- | --- | | api | publicClient, userClient, initUserAuth, single-flight token refresh, a cross-tab refresh lock, retry of safe requests, the client headers and the minimum-version header reader | | auth | the token store (getToken, storeToken, removeToken), the synchronous hasSession, the session user, clearSession and addSessionEndedListener, hasSessionEndedInTab, JWT helpers, "keep me signed in" | | config | setConfig, getConfig, getConfigValue: base URL, version, commit, build number, log settings, request timeout, retry count, correlation-id header | | errors | extractApiError: any thrown value to { code, message, fieldErrors, status, offline }, with code 'network', 'timeout' or 'offline' when no answer arrived (offline is true for all three, and 'offline' only when the browser reports no network); apiErrorMessage(error, fallback); isNoResponse | | formatters | house date, time, number, percent and money (formatMinor) formatters; India Standard Time by default, the viewer's zone on request | | logger | levelled Logger with secret redaction, and Logger.requestFailed for a failed service call | | network | useNetworkStatus and the online/offline listener | | query | the RTK Query base query over userClient, the createAppApi preset, paged-list and tag helpers | | redux | errorToastMiddleware, createRootReducer with resetState (clears every slice on sign-out), the serializable-check paths | | router | loader guards (requireAuth, publicOnly, requirePermission, withGuards), element guards (RequireAuth, PublicOnly, RequirePermission), chunk-reload retry with its shared window (hasRecentlyReloaded, markReloaded), route error classification | | static | staticUrl(key): the versioned address of an authored static file (a video, a sample document) listed in the repo's .webority/static.json | | storage | Storage (async, the same API as mobile) and syncStorage (synchronous, web only) over localStorage | | versionCheck | useVersionCheck and installReloadOnNavigate: tells the portal a newer build is live | | vite | weborityStatic, a Vite plugin that compiles .webority/static.json for staticUrl and can point the build's hashed files at a static host. Node only: import it in vite.config, never in app code |

Install

pnpm add @webority/web-core axios @reduxjs/toolkit react-redux react react-router

axios, @reduxjs/toolkit, react-redux, react and react-router are peer dependencies: the portal already has them, and a single copy of each is what makes the refresh lock, the store and the router context shared. Pin @webority/web-core to the same exact version as @webority/ui-react; the two release together.

The package is ESM and ships its own type declarations. Import from the root, or from one module (@webority/web-core/api) to keep a bundler that cannot tree-shake a barrel honest. Importing a module needs no browser: nothing touches window until a function runs.

Wire it up

Names below are the ones @webority/mobile-core uses; the web package keeps them.

// src/main.ts, before anything calls the API
import { initWebority } from "@webority/web-core";

initWebority({ baseUrl: settings.apiUrl, appVersion: settings.version, commit: settings.commit, appBuild: settings.build });

appBuild must be an integer the deploy stamps at build time (CI's run number, for example). The version check compares it with the API's X-Min-App-Version as a number; a string build id (<sha>-<time>) cannot be compared, and without an integer the update prompt never fires.

Every request carries a fresh X-Correlation-Id (a uuid; a retry keeps the id of its first attempt) and times out after timeoutMs. initWebority takes timeoutMs (default 30000), retries (default 3; 0 turns retrying off) and correlationIdHeader (default 'X-Correlation-Id'; null sends none). A single long call sets its own axios timeout. A timeoutMs or timeout of 0 means no timeout. baseUrl: "" sends every request to the page's own origin, for an API answered through a same-origin dev proxy.

// src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import {
    createApiBaseQuery,
    createAppApi,
    createRootReducer,
    errorToastMiddleware,
    initUserAuth,
    publicClient,
    resetState,
    SERIALIZABLE_IGNORED_ACTION_PATHS,
    setupApiListeners,
    userClient,
} from "@webority/web-core";

export const api = createAppApi({
    reducerPath: "api",
    baseQuery: createApiBaseQuery({ client: userClient, publicClient }),
    tagTypes: ["Project"],
    endpoints: () => ({}),
});

export const store = configureStore({
    reducer: createRootReducer({ [api.reducerPath]: api.reducer, profile }, { preserve: ["theme"] }),
    middleware: (getDefault) =>
        getDefault({ serializableCheck: { ignoredActionPaths: [...SERIALIZABLE_IGNORED_ACTION_PATHS] } }).concat(
            api.middleware,
            errorToastMiddleware({ showToast: (message) => toast.error(message) }),
        ),
});

initUserAuth(store, {
    logout: resetState,
    refresh: { path: "/api/account/refresh", headers: () => ({ "X-Installation-Id": getInstallationId() }) },
    legacyKeys: { accessToken: "app.accessToken", refreshToken: "app.refreshToken" },
});
setupApiListeners(store.dispatch);

A session ends in one place. When the refresh endpoint refuses the refresh token, or the API reports the session dead (a 401 with X-Security-Stamp-Invalid, a termination code, or a sign-out reason), the library calls clearSession('rejected' | 'terminated') itself and then dispatches logout. Every addSessionEndedListener((reason, { signOutReason }) => ...) listener hears it once, with the server's reason from the X-Sign-Out-Reason header (signOutReasonHeader renames it; null turns it off). A 401 that names a sign-out reason is a revoked session, so no refresh is tried. Cross-origin, the API lists the header in Access-Control-Expose-Headers.

A request never runs in a workspace other than the one it was made in. initUserAuth's accountId provider is read once, when a request is made, and sent as X-Account-Id; a retry, a replay after a refresh and a replay after a session replacement send that same id and never read the provider again. A request refused (401) for a session that has since been replaced is sent again with the stored token only when the provider still names the account it was made in (or neither names one). When the session moved to another workspace, it is not sent again: it rejects with WorkspaceChangedError (code: 'workspace_changed', status: 409) and the session is kept. An API answer of 409 with { code: 'workspace_changed' } rejects the same way, is never retried and never logs out. extractApiError maps both to code: 'workspace_changed'; branch with isWorkspaceChangedError(error), which accepts the error or the ApiError made of it. Mark a call that ends the session and returns a new one (a workspace switch) with replacesSession: true, so requests refused while it is open wait for its tokens.

legacyKeys moves a session an app kept under its own keys before it adopted the library: the first token read that finds the library's keys empty moves it (bare token strings are fine; an access token's expiry comes from its JWT) and deletes the old keys, so the deploy signs nobody out. Each move is a Logger.breadcrumb, the counter for retiring the option.

hasSession() answers synchronously whether a session is stored (a refresh token, or an access token whose expiry has not passed), for a first render that must not wait on a promise. RequireAuth and requireAuth() send a signed-out visitor to loginPath?returnTo=<address>, except in a tab whose session ended (hasSessionEndedInTab(), kept in sessionStorage so it survives a reload): that goes to plain loginPath, so the next person to sign in lands on the home page.

// src/router.tsx
import { importWithReload, publicOnly, RequireAuth, requireAuth, requirePermission, withGuards } from "@webority/web-core/router";
import { createBrowserRouter } from "react-router";

export const router = createBrowserRouter([
    { loader: publicOnly({ redirectTo: "/home" }), children: [{ path: "/login", lazy: importWithReload(() => import("./routes/login")) }] },
    {
        loader: requireAuth(),
        element: <RequireAuth />, // or signedIn/pending from the store
        errorElement: <RouteErrorPage />, // useRouteErrorReport() + <AppRouteError kind={kind} />
        children: [
            {
                path: "/billing",
                loader: withGuards([requirePermission("BillingManage", { getPermissions })], billingLoader),
                lazy: importWithReload(() => import("./routes/billing")),
            },
        ],
    },
]);
// in a thunk or a component
import { extractApiError } from "@webority/web-core/errors";

try {
    await userClient.post("/api/projects", body);
} catch (error) {
    const { message, fieldErrors, offline } = extractApiError(error);
}

extractApiError reads errors as a ModelState dictionary or as a list of { field, message } entries, both into fieldErrors keyed by the camelCase field path. For the sentence a screen shows, apiErrorMessage(error, "We couldn't save the project.") returns the server's curated message, or the screen's fallback when the server said nothing usable (a 5xx without a curated message, an empty body), or the connection message when no answer arrived, which wins over the fallback. A 5xx whose body has a code other than internal_error and a curated message shows that message. apiErrorMessage(error, null) says nothing (returns null) when the server sent nothing curated, for a panel that shows only the server's own sentence. isNoResponse(error) says whether an answer arrived at all; code tells a timeout (TIMEOUT_CODE) from a network failure while online (NETWORK_CODE) and from a browser that reports no network (OFFLINE_CODE).

Errors raise a toast by default; a thunk or endpoint opts out with meta: { silent: true }, the same on web and mobile. onRejected gives the product its say over each rejection before the default rules: return 'toast' to show one the defaults would hide, 'silent' to drop one, or nothing. It runs for silent rejections too, so a side effect fires whichever screen asked:

errorToastMiddleware({
    showToast,
    onRejected: (action, error, { dispatch }) => {
        if (error.code === "subscription-expired") {
            dispatch(announceReadOnly());
            return "silent";
        }
        // A 401 with a code is a refused sign-in; one without is the session ending.
        return error.status === 401 && error.code !== null ? "toast" : undefined;
    },
});

Formatters

Called with no options, the house formatters print (zone IST, locale en-IN, empty -): formatDate "08-Apr-2026", formatTime "8:05 PM", formatDateTime "08-Apr-2026, 8:05 PM", formatRelative "5 minutes ago", formatNumber "1,23,456.789", formatPercent(0.42) "42%", formatMinor(15000000) "₹1,50,000.00", and - for a missing value. formatCurrency(150000) prints "₹1,50,000" and '' for a missing amount.

  • configureFormatters({ zone: "viewer", locale: navigator.language }) shows the viewer's own zone with its label and hands the words and separators to Intl for that locale: month names ("08-Sept-2026" in en-IN, the DD-MMM-YYYY order kept), digit separators ("1.234,5" in de-DE), the 12- or 24-hour clock and its AM/PM casing ("08:05 pm"), zone names ("IST") and relative phrases ("yesterday"). formatCurrency then writes in that locale too and prints the configured empty. With no locale configured, every formatter keeps the house text above.
  • configureFormatters({ numberStyle, dateOrder }) applies a person's own choice, and can be called again when they change it. numberStyle (NumberStyle) sets the decimal mark and group separator of every number and amount: "PointWithCommaGroups" 1,234.5, "CommaWithPointGroups" 1.234,5, "CommaWithSpaceGroups" 1 234,5, "PointWithSpaceGroups" 1 234.5 (a no-break space). Only those two characters change: en-IN keeps its lakh grouping ("₹12.34.567,50") and a locale keeps its symbol placement. dateOrder (DateOrder) sets every date, including the date half of formatDateTime: "DayMonthNameYear" 09-Oct-2026 (the house order), "DayMonthYear" 09/10/2026, "MonthDayYear" 10/09/2026, "YearMonthDay" 2026-10-09. Times, zones and relative phrases stay as they are. Left unset, both keep the output above; an unknown value throws a RangeError.
  • formatNumber, formatPercent and formatCurrency pass Intl options through: any option beyond the house keys (style, unit, notation, signDisplay, ...) sends the number to Intl.NumberFormat with every option, so it reads as the browser's Intl reads it.
  • A long fraction (maximumFractionDigits: 20) prints the shortest decimal, never float noise.
  • formatRelative(value, { intl: true }) gives Intl's phrase without configuring a locale.
  • addDays(value, n) moves a date by calendar days and returns null for an unreadable date.

Storage

Storage is async, the same API as mobile-core's, for code shared with an app. syncStorage is web only: getItem, setItem, removeItem, getJson, setJson straight on localStorage, for a preference a first render cannot wait for. Neither throws: a blocked store reads as empty and a write returns false.

Static assets

Large files a person adds to a product (a hero video, sample PDFs) are listed in the repo's .webority/static.json and served from a static host. Link one by its key, never by a bare path:

import { staticUrl } from "@webority/web-core/static";

staticUrl("samples/report.pdf"); // "/samples/report.pdf?v=0123456789ab"

staticUrl returns authoredOrigin + "/" + key + "?v=" + the first 12 hex of the file's sha256, and throws on a key that is not in the list. A same-origin authoredOrigin of "/" gives /samples/.... The list is compiled into the bundle by the Vite plugin:

// vite.config.js
import { weborityStatic } from "@webority/web-core/vite";

export default defineConfig({
    plugins: [weborityStatic({ authoredOrigin: settings.STATIC_AUTHORED_ORIGIN, buildOrigin: settings.STATIC_BUILD_ORIGIN })],
});
// before the app renders
import staticAssets from "virtual:webority-static-assets";
import { initWebority } from "@webority/web-core";

initWebority({ baseUrl: settings.apiUrl, staticAssets });
  • Both origins are "/" (this host) or an absolute https:// origin with no path and no trailing slash. A blank value counts as unset.
  • The plugin finds the nearest .webority/static.json above the Vite root (manifest names another path). With no list, the virtual module is null, staticUrl throws, and nothing else changes.
  • When a list is found, authoredOrigin is required. The build fails without it, and on an origin in the wrong shape or a list entry without a 64-character sha256.
  • buildOrigin is optional. Unset, the site serves the build exactly as Vite writes it. Set, a vite build rewrites the URL of every hashed file in index.html, scripts and styles to <buildOrigin>/_static/<head>/<file>, where <head> is the Vite root folder name lowercased with dots turned into dashes. Files copied from public/ stay on the site, and vite in development keeps its own URLs. An absolute buildOrigin needs CORS headers, because module scripts load cross-origin.
  • Types for the virtual module: declare module "virtual:webority-static-assets" with a default export of StaticAssetsConfig | null.

Chunk reload

importWithReload reloads once when a lazy chunk from an older deploy fails, and reloads the page being opened (path, query and hash), not the page being left: the failed route renders once the navigation commits and reloads from there. A form posted to that route reaches the route's error element with the load error, and useRouteErrorReport reloads it once. lazyWithReload and useRouteErrorReport reload the same way. The reload is claimed in sessionStorage for that page with a time stamp; if the page's chunk still fails after its reload, inside 60 seconds, the route's error element shows it and useRouteErrorReport warns once, with no further reload. An app's own error boundary shares the claim: reload only when !hasRecentlyReloaded() && markReloaded().

Logger

Logger is mobile-core's Logger: debug, info, warn, error, breadcrumb, recordError, requestFailed, setUser, setAttribute, trackScreen, with secrets redacted before anything is printed or forwarded. Logger.requestFailed(error, { label, expectedStatuses }) logs a failed service call: a cancel as info, an expected status as info (never a console warning), anything else as a recorded error with its status, method, path (no query string), the server's code and traceId, and the correlation id. setConfig({ logLevel, loggingEnabled }) decides what reaches the console; setRemoteLogger forwards info and above (never debug) to an error-reporting service, and errors are forwarded even with logging off. The library strips nothing at runtime. To drop debug calls from a production bundle, mark them pure in the bundler: Vite 8 build: { rolldownOptions: { treeshake: { manualPureFunctions: ["Logger.debug"] } } }; Vite 7 and earlier esbuild: { pure: ["Logger.debug"] }. This matches the call by name, so import Logger under its own name. Do not strip Logger.info: info lines also feed the remote sink and are already filtered by logLevel. On the web, track the matched route pattern with trackScreen, never the concrete URL.

Develop

pnpm --filter @webority/web-core build       # vite (JS, one file per module) + tsc (dist/types)
pnpm --filter @webority/web-core test        # vitest, jsdom
pnpm --filter @webority/web-core typecheck   # source and tests under strict

A change here that changes a name or signature also changes @webority/mobile-core in the same release.