@industream/hub-react
v1.0.0
Published
React integration helpers for the Industream Hub (auth bridge, theme/language sync, route mirroring).
Readme
@industream/hub-react
React helpers for integrating an SPA with the Industream Hub: theme / language sync, JWT-aware fetch wiring, and route mirroring with the menu shell. Published on npmjs; apps inside the ironstream workspace link the sources instead. Releasing a version: see docs/publishing.md.
Layout
src/
├── hub-connector.ts Pure-TS façade around the menu-origin
│ <industream-login> web component.
│ Standalone-safe: returns null on failure.
├── HubProvider.tsx React Context provider + useHub() hook.
├── useHubAuth.ts Reactive auth-state hook (status + signIn).
├── auth-token.ts Module-level access-token getter consumed by
│ fetch wrappers outside the React tree.
└── useRouteAdvertiser.ts Hook that mirrors react-router navigation
to the parent iframe (and vice-versa).Consuming from another app
From another repository, install the published package:
pnpm add @industream/hub-reactThe @industream scope also exists on GitHub Packages, and npm routes a
scope to a single registry. If the machine or repository .npmrc sends
@industream to GitHub Packages, add a repository-local .npmrc that
sends it back to npmjs:
@industream:registry=https://registry.npmjs.org/Inside the ironstream workspace, link the sources instead (no build needed):
"dependencies": {
"@industream/hub-react": "workspace:*"
}Then:
import {
HubProvider,
useHub,
useHubAuth,
setAccessTokenGetter,
useRouteAdvertiser,
} from '@industream/hub-react'Peer deps: react >=18, react-dom >=18, react-router >=7. The
consumer must already depend on those.
Authentication
How the plumbing works
The <industream-login> element (loaded at runtime from the menu
origin) does not hold the session itself. It talks to a hidden iframe
on the menu origin, which connects to a SharedWorker — the single
holder of the Hub session for every app and tab on the machine:
matcat tab ──┐
menu tab ──┼─ hidden iframe (menu origin) ── SharedWorker ── hub /auth/*
other app ──┘ │
access token (15 min TTL)
refresh token (30 days, IndexedDB)The worker refreshes the access token proactively in the background
(at ~75% of its TTL, via POST /auth/refresh), so a user parked on the
UI for hours never sees an expiry. When a refresh finally fails (refresh
token expired), the worker broadcasts a login-required state and the
element reopens its modal.
Because the session lives in the worker, signing in anywhere signs in
everywhere: the worker pushes authenticated / logged-out events to
all connected apps, and the element re-emits them as DOM CustomEvents.
That is what subscribeAuthChanged listens to.
getAccessToken() vs signIn()
Two different jobs — both are needed:
| | getAccessToken() | signIn() |
|---|---|---|
| Prompting | never (interactive: false) | may open the login modal |
| Callers | fetch wrappers on every request; useHubAuth's probe | an explicit "sign in" user action only |
| Job | read the current session (fresh token or null) | acquire a session |
Never call signIn() on load or from background code: every probe or
API call without a session would pop the modal.
useHubAuth()
Reactive auth state for gate-style UX:
const { status, signIn } = useHubAuth()| status | Meaning | Typical UI |
|---|---|---|
| loading | hub connection not settled yet | spinner |
| authenticated | valid session available | render the app |
| unauthenticated | hub reachable, no session — signIn() can recover | "Sign in" button |
| unavailable | no hub configured / unreachable — signIn() is a no-op | error card / standalone |
Status is probed from getAccessToken() (when the connection
settles, after signIn(), and on every authenticated/logged-out
element event), not pushed with a payload: the probe is the single
source of truth. Two implementation details worth knowing before
touching the hook:
- Probe epochs — overlapping probes (hub swap + auth event) can resolve out of order; each probe takes a ticket and only the latest may publish, so a stale result never overwrites a fresher status.
- Shared in-flight sign-in — concurrent
signIn()calls (button double-click) share one attempt; the element's behavior on parallel interactive requests is undefined.
The hook never stores the token. HTTP clients keep pulling it per
request via getAccessToken() (auth-token.ts), which is what makes
background refresh transparent.
Standalone fallback
HubProvider reads the menu origin from a runtime config function the
consumer provides (so this package doesn't dictate where to put your
own runtime config). When the menu origin is empty or the menu can't be
reached within the timeout, the connector resolves to null and
consumers should fall back to local-only behavior (e.g. their own theme
selector + localStorage).
What it does NOT cover
- App-specific theme palettes (matcat-ui has 3 variants, the hub speaks
2 — every consumer maps its own way using
useHub().hub?.broadcast/subscribeCustom('set-theme', …)and theset-languageanalog). - App-specific HTTP clients (each consumer wraps its own fetch and pulls
the token via
getAccessToken()fromauth-token). - The menu shell side of the protocol — that lives in
industream-hub/packages/industream-menu.
