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)

  • *Fn files and package fn modules (@brioso/billing/billing-fn,
    @brioso/entitlements/redeem-fn, studio server/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/server side-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/db chunk still exists in .output client
    assets (onboarding-fn’s server-only helpers import tables inside
    functions). It is reachable only behind *Fn stub 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: default resolves src/empty.ts (a no-op,
    free on the server); browser resolves src/throw-server.ts, which throws
    with the fix if it is ever reached from a client graph.
  • @brioso/env-guards/client: inverse (node throws, default empty).

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:

  1. First import in the file: import "@brioso/env-guards/server";
  2. Add @brioso/env-guards as a workspace:* dependency of the package.
  3. 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 createServerFn call 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:

  1. 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.
  2. oxlint no-restricted-imports: static-grep-level ban on forbidden
    specifiers in client-reachable files (Phase 2).
  3. Bundle-marker tripwire (apps/web/vite-plugins/client-server-boundary.ts,
    slimmed in Phase 3): post-build generateBundle assert 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.
  4. 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.ts denylist (@infisical/sdk,
    @aws-sdk/* stubs) — slimmed Phase 3 (2026-09-11) to only
    @tanstack/start-server-core, which still value-reaches the client graph
    via apps/web/src/lib/taxonomy-cache.ts (root specifier re-exports
    request-response.js, which imports node: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.mock in tests never reaches a bundler.
  • createTestDb() (@brioso/db/testDb) and @brioso/jobs/testing
    are test-graph only, never imported from app code.
  • *Fn files under apps/web/src/server/** are legitimately
    graph-reachable — the compiler replaces handler bodies with client stubs.