Architecture

@hyperfrontend/features is the batteries-included layer on top of @hyperfrontend/nexus. Nexus owns the cross-window messaging protocol; this package owns the frontend glue around it — iframe management, display modes, lifecycle orchestration, shell generation, a CLI, and a dev server — so a feature app and a host app can be developed independently and composed at runtime.


System Overview

A feature app (the hostee) is a normal web app that declares a contract and connects through the hostee SDK. A host app builds a shell that mounts the feature in a display mode and exchanges contract-validated messages with it over a Nexus channel. The CLI turns a feature app into a self-contained shell package that any host installs; the dev server runs both sides locally with a debug UI.

The host never needs @hyperfrontend/features as a direct dependency: it installs the generated shell package, which inlines everything it needs. The two sides only agree on a contract (the actions each emits and accepts).


Design Principles

  1. Standalone core, isolated Nx adapter. The package, CLI, and dev server have no build-tool dependency. The optional Nx generators and executors live in an isolated /nx/* adapter that the core never imports, so Nx integration can be added or ignored without touching the SDK. The adapter mirrors the subset of Nx's contracts it consumes structurally (nx/model.ts) — Nx constructs and passes its own tree and context at runtime — and the init generator stages workspace edits through that virtual tree, so --dry-run previews them.

    // ✅ the core SDK
    import { createShell } from '@hyperfrontend/features/host'
    // ❌ the core never imports build-tool internals
    // import { …} from '@nx/devkit'
  2. Per-surface subpath isolation. Host, hostee, CLI, and server are independent entry points so a consumer pulls in only what it uses and bundlers tree-shake the rest.

    // ✅ a feature app ships only the hostee surface
    import { createFeature } from '@hyperfrontend/features/hostee'
    // ❌ never re-export the host/CLI/server surface from the hostee barrel
  3. Contract-validated messaging. Every action a side emits or accepts is declared in a FeatureContract; the contract drives validation, the shell type generator, and the debug UI. The contract is the only coupling between host and feature. Unknown inbound types are dropped and logged, and only accepted entries flagged required: true gate the connection (the counterpart must emit them), so adding actions to a contract stays backward compatible.

  4. Self-contained generated shells. build emits a shell package with zero runtime dependencies — the contract is inlined and direct deps are bundled — so a host installs one package and inherits no transitive install burden.

  5. Security is explicit. The envelope defaults to protocol: 'none' for local development; production builds must opt into v1 or v2 (@hyperfrontend/network-protocol).

    // ✅ production picks an envelope explicitly
    createShell({ url, container, protocol: 'v2', sharedKey })
    // ⚠️ 'none' is a local-only default
  6. Capability flows from the host, correct by construction. The feature declares the Permissions-Policy features it needs (permissions in feature.config.*, baked into the generated shell and disclosed in metadata.json); the host applies them as the frame's allow attribute and can replace the list. Containment (sandbox) is host-decreed, never baked, and the two hazardous tokens are managed rather than configurable: allow-scripts is always granted (the feature runtime is JavaScript) and allow-same-origin only to cross-origin feature URLs — so a script-less frame and a sandbox-shedding same-origin frame cannot be expressed.

    // ✅ delegation is reviewable, containment is the host's call
    shell.open({ permissions: ['fullscreen'], sandbox: { downloads: true } })
    // ❌ no raw token strings — the SDK owns the hazardous tokens
  7. Presentation is coordinated control — host-owned, contract-preconfigured. The feature declares which display modes it supports and their per-mode defaults (display in feature.config.*); the build bakes the declaration into the generated shell, which is built from only the declared mounts (createShell's explicit modes map) and narrows the generated types to the declared union — an undeclared mode is a compile error, a runtime throw, and absent from the bundle. At runtime the host picks the mode and is the single geometry authority: it announces the mode with the frame's initial dimensions over __hf:present, keeps the frame hidden until the session opens, and reports later size changes as exact pixels over __hf:viewport; the hostee SDK sizes its document to match and never announces geometry of its own. Dialog mode inverts the visuals, not the control: the host provides a full-viewport transparent pane, the feature draws the inner box (sized and positioned per the agreement) and backdrop, and dismiss interactions cross as __hf:dismiss for the host to apply its configured policy.

    // ✅ a shell is built from exactly the declared modes
    createShell({ modes: { embedded: mountEmbedded, dialog: mountDialog }, ...options })
    // ❌ no hostee mode requests, no hostee-driven frame sizing
  8. Pure generators, I/O at the edges. Generators are pure (config, contract, tree) => void functions that write to a @hyperfrontend/project-scope VFS tree; all filesystem I/O, prompting, and commits live in the CLI — never in the generators or the SDK.


Module Composition

ModuleSubpathResponsibility
shared.Contract types, contract/config/display/payload validation, DisplayMode, SecurityProtocol, control messages, presentation payloads + size formulas, the event emitter, the channel-wiring core both sides connect and route through, and the defineConfig/defineDevConfig helpers.
host/hostcreateShell factory (explicit modes map), the four display-mode mounts, iframe utilities, container measurement + viewport reporting, heartbeat watchdog, and the experience-plugin seam.
hostee/hosteecreateFeature factory, host-window resolution, presentation application (canvas sync, dialog layout, dismiss detection), feature lifecycle, and heartbeat emission.
cli/cliinit/build/dev command runners, the tiered feature.config.* / hf-dev.config.* loader, and CLI/flag parity; backs the hf bin.
generators/generatorsPure generators for the shell package, metadata.json, the write-once feature module, and contract .d.ts types.
server/serverStatic per-app hosting and the in-browser debug UI (display-mode, resize, message-log, security controls).
nx/nx/*Optional Nx adapter — init/feature generators and build/serve executors (via the /nx/generators and /nx/executors entry points) that delegate to the SDK, using the consumer's @nx/devkit when present and built-in equivalents otherwise. Zero @nx/devkit dependency.

Data Flow

Mounting a feature and exchanging messages:

Opening is asynchronous and deadline-bounded: the channel activates only when the Nexus wire handshake completes, and sends issued before then queue and flush on open. If the counterpart never completes the handshake within the deadline (openTimeoutMs / readyTimeoutMs, default 10 s), the shell tears the mount down and emits error with reason: 'open-timeout', and the feature's ready() rejects after emitting error with reason: 'ready-timeout'.

The opening handshake

Nothing opens without the three-frame exchange, and every gate that can refuse a session runs inside it:

Handshake frames replay idempotently and re-send on a retry cadence until answered, so neither side depends on the other having booted first. Refusal is symmetric too: whichever side decides emits a local error carrying a machine-readable reason, so a host that turns a feature down is never left waiting on the channel it refused. One asymmetry is deliberate — a security-policy rejection tells the refused requester only that it was not accepted, since naming the gate would disclose how this side judges connections.

Liveness is judged in four states, not a boolean. The feature pulses a hidden beat and reports its page visibility; the host watchdog counts misses only while both pages are visible (healthy), pauses while either is hidden (unobservable — throttled timers make silence weak evidence), runs the UnresponsivePolicy once per suspect episode (a recovering beat returns to healthy and re-arms it), and reports gone once the session closes. Transitions surface as the shell's status event.

Teardown is polite by default: close() on either side proposes the close, the counterpart receives a closing event while the channel still delivers — its flush window for unsaved work — then acknowledges, and each side fires a single close (an unacknowledged close completes at a deadline). The feature can declare unsaved work with setDirty(true); the host sees it as the dirty-state event and the isDirty flag and can take it into account before proposing a close. destroy() remains the impolite immediate teardown.

A feature that reloads itself ends its session without either side proposing it. The mount survives: the shell keeps the frame, its observers and its subscriptions, and re-announces __hf:present (re-measured) to the new document, which handshakes on the same channel. The consumer sees the session boundary as close with { reason: 'peer-reload' } followed by a fresh open — pending requests reject, isDirty resets, and anything session-scoped has to be sent again.


Display Modes

The feature declares which modes it supports; the host picks one per open. A shell is built from exactly the declared mounts, so an undeclared mode is a compile error, a runtime throw, and absent from the bundle.

The two iframe modes are the ones with a geometry agreement, and it runs one way: the host measures its own container and reports exact pixels; the hostee sizes its document to match and announces nothing of its own. The windowed modes stay deliberately thin — once a popup is open the browser and the user own it, and standalone is a plain _blank.


Shell Generation

hf build turns a feature project into a package a host installs and nothing else. The generators are pure functions over a VFS tree; the CLI owns every side effect.

What the build bakes becomes the shell's defaults, and the host may override all of them: the feature URL, the default display mode, the per-mode dimension defaults, the declared permissions, and the security protocol. The contract and the declared mode set are not defaults — they are applied after the host's options, so a shell always speaks its feature's contract and mounts only the modes that feature declared. sandbox is never baked — containment is the host's call alone. metadata.json restates identity, canonical version, URL, contract, modes, protocol, and permissions so a registry or reviewer can inspect a shell without unpacking the bundle.


The Control Plane

Both sides share one channel. Product traffic is whatever the contract declares; everything the SDK needs for itself rides the same channel under a reserved __hf: prefix, is validated by the same contract machinery, and is filtered out before consumer handlers run. There is no privileged side channel — and because the prefix is reserved, a contract must not declare action types that begin with it.

TypeDirectionCarries
__hf:presenthost → hosteeThe display mode, the frame's initial dimensions, and the agreed dialog box geometry. First message after open.
__hf:viewporthost → hosteeA later change to the frame's usable space, in exact pixels. Iframe modes only.
__hf:beathostee → hostThe liveness pulse the watchdog counts.
__hf:visibilityhostee → hostThe feature page's visibility, so silence while hidden is not read as failure.
__hf:dirtyhostee → hostWhether the feature holds unsaved work (setDirty).
__hf:dismisshostee → hostA backdrop press or in-frame Escape in dialog mode. A signal only — it tears nothing down.
__hf:requesteitherA correlated request envelope.
__hf:responseeitherThe correlated answer to one request.

Requests are the one control type consumers use directly. An envelope pairs a response to its request by correlationId and stamps the sending side, so a peer discards the echoes of its own traffic. One handler answers each request type — registering a second for the same type throws — and every request carries a deadline, 30 s unless the caller passes timeoutMs. Requests still pending when the session ends reject rather than hanging, including across a peer reload.

Assumptions the Session Model Rests On

These hold across the registry, the watchdog, and the control plane, so no single module can assert them:

  • One channel per counterpart window. addChannel deduplicates by target window and returns the channel that already exists, discarding the name and every setting except security. A second addChannel against the same window therefore inherits the first one's configuration silently.
  • One broker per counterpart document. A second broker inside the peer's document reads as a new incarnation and takes the session. That is the feature's own trust domain, not a boundary this SDK defends.
  • Each side's control handler claims its own types. Control traffic a side does not claim falls through to the correlated request peer.
  • The feature starts beating within roughly three seconds of open. The watchdog allows three missed beats at a one-second tick, which is what makes a slow first paint indistinguishable from a hung feature.
  • Contracts are plain JSON, and the feature owns its whole document. The hostee SDK resets the body and sizes the document root; a feature that fights that ownership breaks the sizing and transparency agreements.

Core Interfaces

// Host: build a shell, mount a feature, exchange messages.
function createShell(options: ShellOptions): ShellHandle

interface ShellHandle {
  open(options?: Partial<ShellOptions>): void
  close(): void
  destroy(): void
  send(type: string, data?: unknown): void
  request(type: string, data?: unknown, options?: RequestOptions): Promise<unknown>
  handle(type: string, handler: RequestHandler): () => void
  on(event: string, handler: (payload: unknown) => void): () => void
  readonly isOpen: boolean
  readonly isDirty: boolean
}

// Hostee: connect a feature app to whatever host embeds it.
function createFeature(options: FeatureOptions): FeatureHandle

interface FeatureHandle {
  send(type: string, data?: unknown): void
  request(type: string, data?: unknown, options?: RequestOptions): Promise<unknown>
  handle(type: string, handler: RequestHandler): () => void
  on(event: string, handler: (payload: unknown) => void): () => void
  setDirty(isDirty: boolean): void
  ready(): Promise<void>
  close(): void
  readonly displayMode: DisplayMode | null
}

// The only coupling between the two sides.
interface FeatureContract {
  emitted: ActionDescription[]
  accepted: ActionDescription[]
}

type DisplayMode = 'embedded' | 'dialog' | 'popup' | 'standalone'
type SecurityProtocol = 'none' | 'v1' | 'v2'

Links

  • Microfrontends from first principles — why the boundary is drawn here, derived from scratch. The canonical rationale for this design.
  • Security Model — the trust model, the browser/protocol/operator split, and the status of every control.
  • README.md — installation, quick start, and the API overview.
  • src/host/README.md · src/hostee/README.md · src/cli/README.md · src/server/README.md · src/generators/README.md — per-surface reference.
  • @hyperfrontend/nexus — the messaging layer this package builds on.