@12-apps/app-shell

中文文档
5.14.1发布于 2026/10/27MITTypeScriptES Module构建来源已验证近 30 天下载 3.4万

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

原始使用文档
文档目录7 节
  1. Why it is a package
  2. The two halves
  3. Two clients, and the split is by CALLER
  4. useServerDataViews — the URL is the query
  5. Three things worth knowing before you read the code
  6. The consent story, in one paragraph
  7. Tests

@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-shell

Adoption contract, the required knobs and the sharp edges: ADOPTING.md.

WebAppShellConfig.theme (and createAppTheme directly) also takes a density — 'compact' | 'normal' | 'comfortable' or a raw factor, mirroring @12-apps/ui's own UiThemeOptions.density. Setting it moves the type scale, the spacing unit and the field height together, carries density's own MUI component overrides, and sets theme.density. It also changes how a host's own theme.components merges with the factory's: with no density, a host's entry for a component REPLACES the whole entry; with one, the two merge by SLOT instead (defaultProps shallow-merged, variants concatenated) — see AppThemeOptions.density's own docblock and ADOPTING.md before combining a density with your own component overrides.

density alone does nothing — pass densityTheme too. This package refers to the density IMPLEMENTATION only by type, so a host that never sets a density never ships its modules on the first paint. A host that wants density imports the implementation itself and passes both:

import { densityThemeOptions } from '@12-apps/ui/tokens';

createAppTheme('light', { density: 'compact', densityTheme: densityThemeOptions });

density with no densityTheme builds the no-density theme and console.warns once, in development, naming the missing option.

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 */ });    // backend

shell.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.

SubpathWhat 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.
./reactcreateWebAppShell — the provider tower (boundary included), the theme, lazyRoute, the consent gate, useDeviceDetection, useServerDataViews, and the collapsible nav's persisted state (useCollapsedSections, useSidebarRail).
./servercreateApiAppShell — the consent status/accept descriptors, framework-neutral.
./honoThe forty-line adapter. hono is an optional peer.
./viteappShellOptimizeDeps() — 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 suites

The 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.

版本与发布记录

@12-apps/app-shell 共发布 30 个版本,首次发布于 2026/08/13;过去 12 个月发布了 30 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
5.14.1latest2026/10/2710 天前
5.14.02026/10/2512 天前
5.13.12026/09/291 个月前
5.13.02026/09/291 个月前
5.12.02026/09/281 个月前
5.8.2app-shell-5.8.x2026/09/251 个月前

关于 @12-apps/app-shell 的常见问题

@12-apps/app-shell 是什么?

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

如何安装 @12-apps/app-shell?

在项目目录运行 npm install @12-apps/app-shell,或使用 pnpm add / yarn add / bun add。

@12-apps/app-shell 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@12-apps/app-shell 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/app-shell 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/app-shell 可以商用吗?

@12-apps/app-shell 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/app-shell 的下载量有多少?

根据 npm 官方下载统计,@12-apps/app-shell 在 2026/10/04 – 2026/11/02 这 30 天内被下载 33,590 次,日均约 1,120 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/app-shell 还在维护吗?

最新版本 5.14.1 发布于 2026/10/27(10 天前);至今共发布 30 个版本,其中过去 12 个月发布了 30 个;首次发布于 2026/08/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。