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 (why)
2fronds: { 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 validated 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 starts. Rung 1 is reversible — a second domain goes beside the first under fronds/, and nothing moves — but it is not free: it is where a collector has to sit with what consumes it, and the boot says so when it does not. 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({
  fronds: { blog: 'http://127.0.0.1:4100' },
});

A Frond given an address in fronds: 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. demos/nuxt-blog 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 address in `fronds:`

What the app is made of

An address says where one Frond answers. fronds: says what the app is made of, and the address is one of the four things it can say about an entry:

// fougere.config.ts
export default defineFougere({
  fronds: {
    shop: {},
    cart:    { extends: 'shop' },      // cart and pricing inherit shop's code
    pricing: { extends: 'shop' },
    blog: 'http://127.0.0.1:4100',     // answers elsewhere — the same as `fronds:`
    '@fougere/log': {},                // a package: every process logs its calls
  },
});

The Fronds under fronds/ are still found by the scan and need no entry. The tree adds two things a flat list could not: what a package brings, and who inherits whose code.

Nesting says that one thing and nothing else. cart resolves the services, repositories, ports and middlewares shop declared — its scope hangs off shop's — and it still reaches pricing through a façade or an announced fact, exactly as a neighbour would. Nesting grants no right to call.

A Frond a family inherits from serves nothing, and the boot refuses it if it does. With no façade, nothing can call it and nothing can place it elsewhere: it stands in every process that carries one of its children, which is what makes inheriting from it safe at every rung of the gradient.

The call contract

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

  • createLocalRunner executes strictly locally;
  • createAppRunner follows the topology — local façades, remote stand-ins;
  • transports serialize this value without changing its structure.
One call, both topologies. The hop is inserted; the façade that validates, binds, presents and projects is the same one on either side.
One call, both topologies. The hop is inserted; the façade that validates, 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": {}, "input": 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",
              "address": "post", "operation": "publish" } } }

method is address.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. A caller may send maxBodyBytes — 2 MiB unless fougere.config.ts says otherwise, read at every door — and a hop keeps 64 KiB above it for what it adds around the call (trace, identity, state), so a body that passes in process passes split. Widening the address 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 on REST, the error of the JSON-RPC answer on /_fougere/call; 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 (address, 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 fronds:. 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 per call

fronds: 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 fronds: 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.

Several recipients for one send comes in two kinds, and only one is here:

who determines the recipients
a fact — a post was publishednobody: whoever declared an interestEmit<T> and Fact<T>
a fleetthe sender names the setnot shipped — fronds: names a kind of Frond, never an instance

A fact names a subject rather than a recipient, so the emitter carries no list of what follows from its action, and a third reader reopens nothing. A fleet is addressed in the data instead, the way emit-fleet reaches one device.

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; fronds: changes the topology when the need arises.

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

Built with Fougere and Nuxt, hosted on GitHub Pages.