The gradient
The gradient lets a Frond move from local execution to a remote process without changing its business code or the pages that call it.
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.
The hop is loopback by default
The receiver binds 127.0.0.1 and caps bodies at 1 MiB. Widening it is one option:
await serve(runner, { hosts: ['0.0.0.0'] }) // a container, saying so
The default is not caution, and the option is not a hole — it is where a fact meets a
deployment. A receiver reads the caller's identity off the wire and re-establishes
nothing, so whatever can reach the port can claim to be any user. On loopback that set is
"this machine". Widen hosts and it becomes whatever the network lets through, which is
then the operator's to close — a firewall, a sidecar, a mesh.
Authenticating the link would not replace it either: a shared secret says a process may call, never which user it speaks for. Identity at the Frond is the open question.
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 |
| state | the consuming app's session reaches remote collectors |
| 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.