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 }.

  • createLocalRunner executes strictly locally;
  • createAppRunner follows 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

ElementBehaviour
resultsidentical shapes both sides, list results included
errorssame FougereError with code, message, and details, rebuilt on arrival
statethe consuming app's session reaches remote collectors
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.