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 step | What it buys | What it costs | |
|---|---|---|---|
| 0 | one Frond, one process | a running app — no network, no HTTP required | nothing |
| 1 | a second domain, in fronds/billing/ | an ownership boundary, before any network | no 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 |
| 2 | remotes: { blog: '…' } | the Frond runs elsewhere, same code | the 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 |
| 3 | the Frond moves to its own repo | its own team, release cycle, deployment | the 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 |
| 4 | the Frond is not TypeScript | a Rust or Python host answering the same calls | you 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 }.
createLocalRunnerexecutes strictly locally;createAppRunnerfollows the topology — local façades, remote doublures;- transports serialize this value without changing its structure.
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
| Element | Behaviour |
|---|---|
| results | identical shapes both sides, list results included |
| errors | same FougereError with code, message, and details, rebuilt on arrival |
| optional input | absence is undefined; JSON omits the property; explicit null is preserved |
| state | the consuming app's session reaches remote collectors, carried in the signed envelope |
| caller | invocation.caller names the Frond that signed — absent when nothing was established |
| unreachable host | typed SERVICE_UNAVAILABLE → status 503; calls resume after the host restarts |
| transport | timeout 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 does | When | |
|---|---|---|
fougere sync | asks the host for rpc.discover and rebuilds its entities locally | the host is running; also the only way in for a Frond written in another language |
fougere build-frond | compiles entities/** into an installable package | you 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 fleet | the sender names the set |
| a fact — a post was published | nobody: 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.