The gradient

The gradient is not a feature the framework adds. It is what follows from a Frond naming no address: since nothing in it says which process it runs in, moving it to another one changes no business code and no page that calls it.

Five rungs, and the price of each

Fougere is progressive in one precise sense, and it is the one you can check: going up a rung never rewrites what you wrote. What a rung does cost is stated here rather than left for you to discover.

The stepWhat it buysWhat it costs
0one Frond, one processa running app — no network, no HTTP requirednothing
1a second domain, in fronds/billing/an ownership boundary, before any networkno reaching into another Frond's files (fougere check reports cross-frond-import), and a collector has to live in the Frond that consumes it — one that does not binds the request body to the parameter that wanted a user
2remotes: { blog: '…' }the Frond runs elsewhere, same codethe hop and its JSON; beyond loopback the receiver must be able to establish its caller, which is two commands at deployment; and two capabilities go missing — a named surface answers nothing for a remote Frond, and one address per Frond means the same Frond cannot be deployed twice
3the Frond moves to its own repoits own team, release cycle, deploymentthe scan no longer reads it: the contract must be copied (fougere sync), the carrier under fact dispatch is yours to write, and a fact is judged strictly — so its readers deploy before its sender
4the Frond is not TypeScripta Rust or Python host answering the same callsyou honour the card and the wire yourself

Rung 0 costs nothing, and it is where every demo in this repository sits. Rung 1 is reversible — the root Frond does not move when the second domain arrives — but it is not free: it is where a misplaced collector becomes a question about privilege rather than about layout. Rung 2 buys the hop and pays two capabilities for it. Rung 3 is the real border, because it removes the colocation the scan was reading: see where the code lives.

The topology statement

// fougere.config.ts
export default defineFougere({
  remotes: { blog: 'http://127.0.0.1:4100' },
});

A Frond declared in remotes is still scanned: its entities continue to provide metadata for forms, validation, and DI. Its operations instead run at the remote address. Removing the line restores local execution. The multi-Frond demo covers both configurations, including a production build:

pnpm dev:blog      # the blog Frond alone, in its own process (:4100)
pnpm dev           # the app — consumes it through the remotes line

The call contract

A call is a value: (entity, operation, invocation) with invocation = { params, query, body, state }.

  • createLocalRunner executes strictly locally;
  • createAppRunner follows the topology — local façades, remote doublures;
  • transports serialize this value without changing its structure.
One call, both topologies. The hop is inserted; the façade that judges, binds, presents and projects is the same one on either side.
One call, both topologies. The hop is inserted; the façade that judges, binds, presents and projects is the same one on either side.

The wire format

Process-to-process is JSON-RPC 2.0 on POST /_fougere/call:

// → request
{ "jsonrpc": "2.0", "id": 1, "method": "post.publish",
  "params": { "params": { "id": "" }, "query": {}, "body": null, "state": { "user": { } } } }

// ← success
{ "jsonrpc": "2.0", "id": 1, "result": { "id": "", "status": "published", } }

// ← domain failure — revived as FougereError on the calling side
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32000,
    "data": { "code": "CONFLICT", "message": "Already published",
              "entity": "post", "operation": "publish" } } }

method is entity.op and params contains the invocation. The browser sends the same frame to Nitro, but its state is ignored and rebuilt server-side.

The other half of the contract is what a host answers to rpc.discover: see The identity card, which specifies the document and what to honour to write a Frond in another language.

Loopback or signed

The receiver binds 127.0.0.1 and caps bodies at 1 MiB. Widening it is one line, and it comes with a condition:

await serve(runner, { hosts: ['0.0.0.0'] })   // refuses to start on its own
await serve(runner, { hosts: ['0.0.0.0'], verify })   // establishes its caller

A receiver reachable from outside this machine, with no way to establish who is calling, would believe whatever state it is handed — so it does not start. The address already carries the decision: on loopback the set of callers is "this machine", and widening hosts is where you write down that it is no longer.

What closes it is a signed envelope, verified against one public key. Identity across Fronds is that page.

Behaviour after a split

ElementBehaviour
resultsidentical shapes both sides, list results included
errorssame FougereError with code, message, and details, rebuilt on arrival
optional inputabsence is undefined; JSON omits the property; explicit null is preserved
statethe consuming app's session reaches remote collectors, carried in the signed envelope
callerinvocation.caller names the Frond that signed — absent when nothing was established
unreachable hosttyped SERVICE_UNAVAILABLE → status 503; calls resume after the host restarts
transporttimeout and retry on the envelope, without automatic command retries

The price of the split

The split adds an HTTP hop and the JSON encoding and decoding it entails. A call remains a value (entity, operation, invocation); the transport frames it and unframes it, then the receiving side runs the same façade as the local path. The cost therefore depends on the network, payload size and runtime: Fougere does not claim to make it zero.

No figure is quoted here: this repository ships no benchmark harness, and a number you cannot re-run is not a measurement.

Where the code lives

A Frond can also move to a separate repository and remain available through remotes. Three things are called "together" and only one is mandatory: the contract must travel, the code need not, the ports must reach each other.

Two commands cover the contract, depending on what you want to share:

What it doesWhen
fougere syncasks the host for rpc.discover and rebuilds its entities locallythe host is running; also the only way in for a Frond written in another language
fougere build-frondcompiles entities/** into an installable packageyou publish the contract as a dependency

The Frond's source code crosses in neither case. What stays open is the ports: see Deployment.

One recipient, and only one

remotes names an address per Frond, and Facade<T> resolves to exactly one façade — facadeKeyOf produces one key, the container returns one object. Every call in Fougere has a single recipient, by construction.

That covers more than it sounds like. A device behind NAT that cannot be called still declares remotes and sends to a gateway it names: one recipient, and the fact that the device must open the connection is a deployment property, not a different kind of call.

What is not covered is several recipients for one send:

who determines the recipients
a fleetthe sender names the set
a fact — a post was publishednobody: whoever declared an interest

Both need the same machinery — fan-out, a channel to members that carry no address, and a verdict when three of five succeed. Fougere has none of it. So a post being published, with search reindexing and the newsletter queueing, is written today by naming both at the emitting site: the emitter carries the list of everything that follows from its own action, and a third reader reopens it.

This section exists to name the half that is here. remotes reads like the topology statement and it is one of two; the other is designed and not shipped. Saying so keeps the gap visible rather than surprising.

Limits

Moving a Frond to a remote process adds network latency and failure modes. Fougere exposes them as typed errors but does not hide them. Local mode remains the reference mode; remotes changes the topology when the need arises.

Next: Surfaces — the same operations behind REST and GraphQL.

Built with Fougere — this site runs on the framework it documents.