Client/server boundary
Client/server boundary
How Brioso keeps Node-only code out of the browser bundle and browser-only
code out of the server. Enforcement lives in @brioso/env-guards (sentinel),
the oxlint client-glob restrictions, the slimmed client-server-boundary
bundle-marker tripwire, and bunx vite build in CI.
Incident history
Two production incidents motivated this architecture. On 2026-08-24 a beta
deploy broke: @aws-sdk/lib-dynamodb’s fromNodeProviderChain (pulled in via
@brioso/db -> @brioso/config -> @infisical/sdk through a static import
chain reachable from client components) failed to resolve in the browser
bundle, hard-failing the build. On 2026-09-11 a posthog-node import reached
the client graph and crashed at hydration. Both were the same class of bug:
a static import of server-only code from a module that TanStack Start
compiled into the browser bundle.
The three zones
Every module is exactly one of:
/shared— browser and server safe. No Node builtins, no
browser-only globals./client— browser only. Reached from server code = leak./server— Node only. Reached from the client graph = leak (the common case).
Per-package semantics
| Package | Zones | Notes |
|---|---|---|
@brioso/db |
server-only + /onboarding split |
root . and /client are server (sentinel on the barrel); /onboarding is pure browser-safe reference data (BRI-238 country/enum lists); /testDb is test-graph only |
@brioso/storage |
server-only (whole package) | /types and /zip are pure; /r2 etc. pull aws-sdk; all wired entries carry the sentinel |
@brioso/dispatch |
server-only (whole package) | fully sentinel-wired |
@brioso/jobs |
server-only + pure subpaths | sentinel on queue, lease-queue, completion, creation, delivery, lookup, notify, receipt, recovery, thread, thread-access, thread-history, tracks, fallback; browser-safe exempt subpaths: /constants, /disposableEmailDomains, /duration, /customMusicRequest, /editing-params, /errors, /email, /state, /validation, /retention, /quota-read, /thread-id, /testing |
@brioso/billing |
server-only (whole package) | stripe SDK; webhook/subscription-checkout/portal sentinel’d (Phase 1) |
@brioso/entitlements |
server-only (whole package) | sentinel on promo-codes/redeem/resolve; redeem-fn is *Fn-stub reachable so no bare sentinel (see exemptions below) |
@brioso/ai-workflow |
server-only (whole package) | sentinel on run/model/run-agent/refine-config/preferences; /types, /errors, /refine-schema, /preferences-schema are pure |
@brioso/obs-js |
split | /client (posthog-js), /shared, /analytics-identify, /useFeatureFlag browser-safe; /server, /nitro, /logtape server |
@brioso/auth-core |
split | /otp, /rate-limit, /session-check, /route-guard etc. shared; /session-server, /session-config-bound, /worker-secret sentinel’d |
@brioso/sharing |
split | /token and /types pure; /store sentinel’d with lazy table resolution (its old static shareLinks import bridged drizzle into the client graph) |
@brioso/contract |
isomorphic (model only) | imported directly by web + worker; never served over HTTP |
Lint layer (Phase 2): a client-glob oxlint override bans value-imports of
@brioso/db, @brioso/obs-js, @brioso/storage, @brioso/billing,
@brioso/dispatch, @infisical/sdk, stripe, posthog-node,
@aws-sdk/*, @brioso/db/client, @brioso/db/testDb,
@brioso/obs-js/{server,nitro,logtape} and
@brioso/auth-core/session-server from apps/web/src/{components,routes}
and packages/verticals/*/src/{components,routes}. Type-only imports are
erased and always allowed.
Known exemptions (documented, reviewed)
*Fnfiles and package fn modules (@brioso/billing/billing-fn,
@brioso/entitlements/redeem-fn, studioserver/session.ts, …) are
legitimately graph-reachable: the compiler replaces handler bodies with
client stubs. They must keep every server import dynamic inside handler
bodies and must NOT carry a bare@brioso/env-guards/serverside-effect
import. The browser-condition throw stub would otherwise be merged into
a client chunk by rolldown and evaluated at page load (found during the
Phase 2 sweep). Server-only-ness for these files is enforced by the lint
layer + boundary plugin instead.- The dynamic
@brioso/dbchunk still exists in.outputclient
assets (onboarding-fn’s server-only helpers import tables inside
functions). It is reachable only behind*Fnstub call sites that never
execute in the browser; the boundary plugin’s generateBundle marker
assert keeps that chunk free of posthog-node/node builtins.
New packages default to one zone; split only when a real consumer needs both.
The sentinel: @brioso/env-guards
packages/env-guards exports two sentinel subpaths using package.json
export conditions:
@brioso/env-guards/server:defaultresolvessrc/empty.ts(a no-op,
free on the server);browserresolvessrc/throw-server.ts, which throws
with the fix if it is ever reached from a client graph.@brioso/env-guards/client: inverse (nodethrows,defaultempty).
Declaring server-only-ness is now one line (import "@brioso/env-guards/server"; as the first import) plus a doc-table row. It
is vendored rather than the npm server-only package because TanStack Start
does not set the react-server condition that package keys on; the
browser condition is the one vite reliably resolves for the client
environment.
Recipe for a new server-only entrypoint:
- First import in the file:
import "@brioso/env-guards/server"; - Add
@brioso/env-guardsas aworkspace:*dependency of the package. - Add a row to the table above.
Rules for apps/web
createServerFn()splits per handler body, not per module. Only the
.handler(fn)closure becomes a client stub; the rest of the file ships to
the browser.- Server imports go inside the handler body as
await import(...), or in
a non-exported helper referenced only from handler bodies. - Files whose value is a
createServerFncall end in*Fn(billing-fn.ts,
refine-fn.ts). Their module-level code still runs in the browser, so they
must not import server modules statically either. - Server-only app code lives under
apps/web/src/server/**; the boundary
plugin blocks client resolution of anything climbing into that tree.
Enforcement map (final, Phase 3 — 2026-09-11)
Layered, cheapest first:
- Sentinel (
@brioso/env-guards): runtime/build-time throw if a server
module is resolved in the wrong environment. Wired into ~38 server
entrypoints across db, storage, obs-js, auth-core, billing, jobs,
sharing, entitlements, dispatch, ai-workflow. - oxlint
no-restricted-imports: static-grep-level ban on forbidden
specifiers in client-reachable files (Phase 2). - Bundle-marker tripwire (
apps/web/vite-plugins/client-server-boundary.ts,
slimmed in Phase 3): post-buildgenerateBundleassert that fails the
build if an emitted client chunk contains a server-only marker
(posthog-node,POSTHOG_PERSONAL_API_KEY,node:async_hooks). Its old
resolveId package denylist was removed — the sentinel owns that. - CI
vite build: the whole pipeline runs on every web change.
Retired enforcement layers:
scripts/check-client-server-leak.ts(static grep) — deleted Phase 3
(2026-09-11); superseded by the lint layer (2).apps/web/vite-plugins/server-only-module-stub.tsdenylist (@infisical/sdk,
@aws-sdk/* stubs) — slimmed Phase 3 (2026-09-11) to only
@tanstack/start-server-core, which still value-reaches the client graph
viaapps/web/src/lib/taxonomy-cache.ts(root specifier re-exports
request-response.js, which importsnode:async_hooks). Delete the plugin
when taxonomy-cache stops importing the root specifier.
Escalation triggers for adding new bundler-level checks — each requires a
plan doc:
- a leak class the sentinel cannot see (e.g. dynamic-import chains entered
via non-condition paths); - a third environment (worker, edge runtime) with different conditions;
- two or more sentinel false-positive incidents in a quarter.
Hard rule: denylists must never accrete in plugin code. New
server-only-ness is one sentinel line plus a doc-table row. The Phase 3
retired the last plugin denylist (isServerOnlySpec); only the marker list
remains, and it must stay at the three node-identity markers above.
False positives and exemptions
Known-safe patterns that must not trip the guards:
- Type-only imports (
import type) are erased at compile time. vi.mockin tests never reaches a bundler.createTestDb()(@brioso/db/testDb) and@brioso/jobs/testing
are test-graph only, never imported from app code.*Fnfiles underapps/web/src/server/**are legitimately
graph-reachable — the compiler replaces handler bodies with client stubs.