@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-routeraxios, @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").formatCurrencythen writes in that locale too and prints the configuredempty. 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 offormatDateTime:"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 aRangeError.formatNumber,formatPercentandformatCurrencypass Intl options through: any option beyond the house keys (style,unit,notation,signDisplay, ...) sends the number toIntl.NumberFormatwith 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 returnsnullfor 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 absolutehttps://origin with no path and no trailing slash. A blank value counts as unset. - The plugin finds the nearest
.webority/static.jsonabove the Vite root (manifestnames another path). With no list, the virtual module isnull,staticUrlthrows, and nothing else changes. - When a list is found,
authoredOriginis required. The build fails without it, and on an origin in the wrong shape or a list entry without a 64-character sha256. buildOriginis optional. Unset, the site serves the build exactly as Vite writes it. Set, avite buildrewrites the URL of every hashed file inindex.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 frompublic/stay on the site, andvitein development keeps its own URLs. An absolutebuildOriginneeds CORS headers, because module scripts load cross-origin.- Types for the virtual module:
declare module "virtual:webority-static-assets"with a default export ofStaticAssetsConfig | 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 strictA change here that changes a name or signature also changes @webority/mobile-core in the same
release.
