@12-apps/app-shell
v5.7.0
Published
The shell three SPAs of one product share (12-18). Framework-free core (.): the typed JSON API client and its ApiError, the WCAG brand-palette correction, pt-BR formatting, the stale-chunk recovery and the consent wire. Browser half (./react: createWebApp
Readme
@12-apps/app-shell
The shell several SPAs of one product share, as an installable package: the typed
API client and its ApiError, the MUI theme built from a tenant's brand seed, the
session, the route error boundary, route-level code splitting that survives a
deploy, the terms/privacy consent gate — and the small backend surface that gate
needs.
pnpm add @12-apps/app-shellAdoption contract, the required knobs and the sharp edges: ADOPTING.md.
Why it is a package
Three SPAs cannot share nothing, so they share a private package — and a private
package is one nobody else can install. This one was 5218 LOC of exactly the things
every multi-tenant product needs in a browser, and its subsystems have been going
out to their own packages one at a time (@12-apps/realtime,
@12-apps/notifications, @12-apps/observability-frontend, @12-apps/auth). What
remained is the shell itself, and it is what all of them mount inside.
The two halves
const shell = createWebAppShell({ /* config */ }); // browser
const { routes } = createApiAppShell({ /* config */ }); // backendshell.Provider around your routes is the whole browser wiring — the route error
boundary is inside it, so onCrash is reached without you mounting anything.
Place a second boundary below your own chrome if you want a crashed page to keep the
sidebar; the nearest one catches, so nothing doubles.
| Subpath | What is in it |
|---|---|
| . | Framework-free: apiFetch / ApiError, restResult, joinApiPath / stripTrailingSlashes, the WCAG brand-palette correction, pt-BR money and duration formatters, the stale-chunk recovery, the consent wire. |
| ./react | createWebAppShell — the provider tower (boundary included), the theme, lazyRoute, the consent gate, useDeviceDetection, useServerDataViews, and the collapsible nav's persisted state (useCollapsedSections, useSidebarRail). |
| ./server | createApiAppShell — the consent status/accept descriptors, framework-neutral. |
| ./hono | The forty-line adapter. hono is an optional peer. |
| ./vite | appShellOptimizeDeps() — the dependency pre-bundling preset. Compiled, and it has to be. |
Two clients, and the split is by CALLER
apiFetch throws ApiError on a non-2xx. restResult returns
{ ok: false, error, fieldErrors?, status? }. Both speak the { data } /
{ error, issues } envelope every createApi* package answers in, so a screen
can mix them without a second adapter.
Reach for apiFetch for reads and for anything whose failure is somebody else's
to handle — a query, a prefetch, a background refresh. Reach for restResult
for a write a human is waiting on: a submit has two ordinary outcomes, one of
which is "the server refused it and named the fields", so writing that as a
throw costs every handler a try/catch whose catch is the main path.
status is deliberately ABSENT on a network failure. That is the only thing
separating "the server said no" from "nothing answered" — one is a message to
show, the other is a retry — and it is what lets a concurrent surface tell a 403
("not yours", say so beside the button) from a 409 ("somebody was faster",
refresh).
useServerDataViews — the URL is the query
A DataViewsGrid in server mode filters and sorts nothing itself: it EMITS a
DataViewQuery and the host fetches. This hook is the router-driven answer —
it maps that query into search params and replaces them, and the page's query
hook, keyed on those params, re-fetches. Keeping the state in the URL is most of
what separates an admin list somebody can work in from one they fight: it makes
a filtered view linkable, reloadable and back-button-able.
const server = useServerDataViews({
totalCount, page, pageSize,
toParams: (query) => ({ q: query.search || undefined, type: query.filters.type }),
});Two behaviours are load-bearing and are pinned by cases rather than left to comments:
- Only free text debounces. A filter chip or a page number leaves the search unchanged and applies synchronously; a click that waited 250ms feels broken.
- It merges against the LIVE URL, not the router's functional snapshot. A
debounced search commits from a timer scheduled before an interleaving write
by another owner of the URL (a row click writing
?view=), and the router's snapshot can lag it — so the row selection would vanish a moment after the operator made it.
The basename is read from the ROUTER (useHref('/')), not from the bundler's
configured base. Those are two sources for one fact that agree only while they
are configured identically, and reading the bundler's would make the module
unloadable outside it.
Three things worth knowing before you read the code
ApiError is load-bearing for another published package.
@12-apps/entitlements' upsell channel decides whether a rejection is a plan
denial by reading status and body off one of these. The three fields are pinned
by a test for that reason.
Nothing here reports success it cannot back. The consent endpoint propagates a
failed write as a 500 rather than answering 204, because a 204 over a failed write
tells the user they accepted while every guard keeps refusing them. onCrash,
isCurrent, consent and brand.name are required rather than defaulted, because
each of their plausible defaults fails silently in exactly the direction nobody
checks — and the boundary onCrash is reported from is mounted rather than
documented, for the same reason.
A colour a tenant TYPED is not a colour you can paint. A brand hex is chosen to
look good on a sign, not to be legible as 14px text on a white card: one real seeded
tenant's #7ED957 renders its prices at 1.76:1 against a 4.5:1 floor. The palette
keeps the hue and moves only the lightness, so the tenant recognises their colour and
cannot pick an unreadable one.
The consent story, in one paragraph
POST /consent/terms could always FIX a user whose acceptance had gone stale.
Nothing could TELL them, so nobody ever called it: bumping the terms version turned
every previously-consented user into a pending one silently — still signed in,
avatar and cart intact — and the first thing they heard was a bare
401 {"error":"Unauthorized"} at the payment step, with a retry that could never
succeed. GET /consent/status is the missing half and the gate is what renders it,
which is why both halves are in one package: the two ends agreeing on a path is the
whole feature.
Tests
pnpm test # the package's own suitesThe consumer proof lives in harness/ at the repo root: a page driving the real
createWebAppShell against a real createApiAppShell mount, both installed from
packed tarballs.
