Easy Clients: Host and Module
How to design a hand-written single-page client ("Easy client") for an FCS component so that it never falls out of step with the FCS it edits. This page is the design; the hands-on recipe is Build a Component and Its Easy Client.
Status: experimental, proven end to end on 2026-10-07 with the reference component EasyPlank (
HiStructOrg/fcs-lab-01, branchdeve/easy-plank) on a local platform built from the branchdeve/easy-timemachine. Nothing of it is deployed on a shared platform yet. Sections marked proposal are design that has not been built.
1. The problem it solves
Every component is pinned to one commit of its FCS (the prototype string ends with a sha). A configuration created a year ago still runs the FCS of a year ago, while new configurations run today's. The platform's generated editor always fits, because it is generated from the input items of that very commit.
A hand-written client is different: it knows paths, list keys, scene block names and the order of writes of one version of the FCS. Served as one bundle for all configurations, it is either wrong for the old ones or it freezes the FCS. HBC Easy is in exactly that situation.
The fix: the FCS-dependent part of the client becomes part of the commit. The component's existing pin then selects the matching client — a time machine for the client, the same way the platform already has one for the FCS.
2. The split: host and module
A client is cut into two parts along one question: does this code have to change when the FCS changes?
| Host (constant tier) | Module (FCS-dependent tier) | |
|---|---|---|
| Deployed | once, on a server, like any web app; always the latest | published into the content store; named by the FCS commit |
| Pinned? | no — it reads the pin | yes — through ClientReference in that commit's Definition.fcscdm |
| Knows the FCS? | no paths, no identifiers, no list keys | yes — this is where all of that lives |
| Examples | logo and frame, first impression / progress, session switcher, "new configuration" button, sign-in, asking the platform which client a component needs, loading and mounting it, error pages | input panels, labels and units for inputs, the 3D viewer and what scene blocks mean, outputs shown, product texts, the order of writes, validation messages |
| Changes when | the platform or the brand changes | the product changes |
| Who writes it | platform (or the product team, once, for its brand) | the product author, in the same pull request as the FCS |
Two rules decide borderline cases:
- If it must run before you know which commit you are on, it is host. The host is the thing that finds out the commit, so it cannot be pinned to one.
- If it would break when an input is renamed, added or removed, it is module. A dropdown for
input.inEndShapeis module code even if it looks generic.
Keep the host thin. Everything you move into it becomes a promise to every module version, old and new.
3. How it works at runtime
host platform content store
│ open component 490067 │ │
├── GET Model/GetComponentClient?id=…&privateKey=… ─►│ │
│◄── {Prototype: "…EasyPlank.fcc*f7bbe19…", │ │
│ ClientReference: "sha256:db7eacb9…"} ──────────┤ │
├── GET /cas/manifests/db7eacb9….json ──────────────────────────► │
│◄── {hostApi:"1", entry:"assets/main-CQ60XCRm.js", css:[…]} ──── │
├── import("/cas/files/assets/main-CQ60XCRm.js") ───────────────► │ (+ its chunks, e.g. three-…js)
├── module.mount(el, ctx) ── the module now talks to the platform API itself
│ switch to another component → unmount() → repeat
- The platform answers from the component's own pinned commit: it reads
ClientReferencefrom that commit'sDefinition.fcscdm(cached per absolute prototype — a commit never changes). A component whose commit declares no client getsnull; the host then falls back (today: a message; in production: the generated editor). - The store is content-addressed and immutable: a manifest's name is the sha-256 of its content, and file names are Vite's content hashes. Everything can be cached forever; two client versions that share an unchanged chunk share the file.
- The host keeps loaded modules in memory: going back to a version that already ran in the page downloads nothing; two components on the same client version keep the mounted module.
- The pin never changes during a session. A session is one component, and its commit is fixed when the component is created. The module never has to cope with the FCS changing underneath it.
New configuration. In the experiment the host creates the component from the palette's branch reference and then asks which client it got — the new component's own pin decides, never the palette. Proposal: resolve the palette entry to an absolute sha first and create the component at exactly that sha, so host, client and component cannot disagree.
4. The contract between them (host API 1)
The module is an ES module that exports three things:
export const hostApi = "1";
export function mount(el: HTMLElement, ctx: EasyContext): void | Promise<void>;
export function unmount(): void;
and receives:
interface EasyContext {
hostApi: "1";
apiBase: string; // prefix for platform calls ("" = same origin); never ends with "/"
space: string; // e.g. "deve-rv"
lang: string; // for the Content-Language header, e.g. "en"
componentId: string;
privateKey: string;
prototype: string; // the absolute prototype string the component is pinned to
onStatus?(text: string): void;
}
Rules, all learned while building EasyPlank:
mountownselcompletely; the host never reaches into it. The host awaitsmount: resolve when the first load is done; do not reject for engine errors — show them. A rejection means "this module cannot run at all".unmountmay come at any time, even duringmount. Cancel in-flight work and leave no listeners, timers, WebGL contexts or DOM behind — the next module version may be mounted into the same page.- One instance per page in host API 1. No global CSS: scope every rule under your root.
- The module talks to the platform API directly (load-properties, update-properties, hi-scene-update — see the recipe page). The host does not proxy or interpret it.
Versioning. The contract has its own version, hostApi, declared by both the module and its store
manifest. A host refuses a module that needs a host API it does not speak. Because old modules live
forever, the host may only ever add to the contract; a breaking change means a new host API version
that the host serves in addition to the old one. The platform API the module calls is a second
contract with the same obligation: what a published module calls must keep working.
The authoritative text is CONTRACT.md in HiStructOrg/easy-plank.
5. The developer's workflows
Your freedom is unchanged: any framework, any bundler, any library (EasyPlank uses Vite, TypeScript
and three.js). The only requirements are on the output: ES modules, the entry's exports preserved,
a build manifest, relative chunk imports (base: './').
- Local loop.
npm run devin the module folder runs a small harness page that mounts the module against a real test component (Vite proxies/apito the platform). Hot reload, no publishing. - Release. Build →
publish-clientcopies the output into the store and prints the address → put"ClientReference": "sha256:…"intoDefinition.fcscdm→ commit it together with the FCS change it belongs to → push. Publish first, reference second: a commit may only name a client that already exists in the store. Builds are deterministic (verified across machines, Node versions and Windows/Linux once line endings are normalised with.gitattributes), so republishing the same source gives the same address.
A commit that changes only FCS keeps the previous ClientReference — legitimate when the change does
not affect the client (EasyPlank's colour fix), or a deliberate intermediate step.
6. Guidelines for module authors
- Build the input panel from what
load-propertiesreturns (range, unit, list options) rather than hard-coding them — the module is pinned, but the less it duplicates, the less can be wrong. - Assume the inputs of your commit exist. Do not write compatibility code for older FCS versions; older components get older modules.
- Split large libraries into their own chunk so unchanged ones are shared across versions. A tree-shaken chunk still changes when you start using a new part of the library.
- Never trust HTTP 200 on a write — check
validationresult.Exception. - Keep product texts in the module; keep brand (logo, frame) in the host.
- Choose where the module source lives (both are proven):
- next to the FCS,
MyProduct.fcc/client/— one pull request changes both; good while one team owns product and client; - in its own repository, the fcc keeping only
ClientReference— the natural fit when the client belongs to a product with its own site, team and release rhythm. The publish tool records the source repository, commit and version in the manifest, so every address leads back to its code, and it refuses to publish uncommitted or unpushed code.
- next to the FCS,
7. Where this is going (proposal)
The experiment proves the host/module cut. The fuller design splits further — summarised here so the direction is visible:
- Platform SDK — sessions, create, edit, undo, history, scene transport as a versioned npm package bundled into each module, instead of every module re-learning the raw API.
- Generated bindings — typed paths and list keys emitted from the FCS at each commit, so a module that names a non-existent input fails to compile instead of failing silently.
- Tenant configuration and space data — brand, defaults and catalogues supplied by the space at runtime and validated against the pinned module, not baked into either part.
- Production serving — the store behind the platform (immutable, cacheable), the host deployed once per brand, and the generated editor as the fallback for components without a client.
What exists today
- Platform branch
deve/easy-timemachine: theClientReferencefield and the two endpoints (Model/GetComponentClient,api/static/{space}/prototype-client). - Lab repo
fcs-lab-01, branchdeve/easy-plank:EasyPlank.fcc— the FCS and itsClientReference. Until commitf7bbe19the client lived inEasyPlank.fcc/client/; sincef672bcbit lives in its own repository: HiStructOrg/easy-plank(private):module/(the client),host/,tools/publish-client.mjs,CONTRACT.md, and a headless check of which client each component gets.- Verified: four generations of EasyPlank in one host page — no client, client v1 on FCS v1, client v1 on FCS v2, client v2 on FCS v2 — each component getting the client of its own commit; revisiting a version downloads nothing. Then a fifth: the client built in its own repository — byte-identical files, so the browser fetched only its 320-byte manifest.
- Still open: where the durable store lives. Today it is a git-ignored folder; a reference in an fcc is only as durable as the store holding its bytes.