Surfaces
A surface adapts a protocol to Frond operations in three steps: parse the request, call invoke, then format the response. Business logic remains in the handlers.
The framework is not the process
Fougere opens no socket. @fougere/core depends on the container and the schema — on
no server. An operation is a function; HTTP is one projection among
several, not the place it lives.
@fougere/http declares an interface, HttpRouter, and has no dependencies, not even
peer ones. You build your Hono or your Fastify, hand it to createHonoRouter(app) or
createFastifyRouter(app), and the surfaces program against the interface.
RequestContext.request is a Web Standard Request.
| What you bring | What Fougere plugs into it |
|---|---|
| Hono, Fastify | createHonoRouter / createFastifyRouter, then REST |
| your GraphQL server (Apollo, Yoga…) | registerAll(builder, app) returns a Pothos schema |
| Nuxt / Nitro | the module mounts the envelope and the REST catch-all |
| nothing at all | fougere serve exposes the Frond over JSON-RPC, fougere call invokes it from a shell |
| your own loop | invoke — a call is a value, not a request |
Two practical consequences. Changing HTTP server moves your routes, not your handlers. And a test calls the operation directly, without opening a port.
And the validator goes wherever JavaScript goes
The same declaration runs on both sides of the network. The validation engine
(@cfworker/json-schema) was chosen to run in edge environments, and
@fougere/core/contract ships as a subpath with no Node builtin in its import graph. The
validator refusing a form in the browser is therefore the same object as the one refusing the
body at the façade — not a rule copied faithfully, the class itself.
One caveat, because the nuance matters: it is the contract that is ubiquitous, not the
app. The app boots from its statement — frond() calls a build wrote, or you did — and a storage
engine that may need Node. What travels to a browser is the declaration, its validation and the
call format; a Worker runs the app itself, from the same statement.
The envelope transmits the call value directly. REST translates verbs and paths; GraphQL translates a query. These surfaces then call the same façade.
REST — mounted
With protocols: { rest: {} } in fougere.config.ts (and import '@fougere/adapter-rest'), every
address gets its routes, entity or not. A URL has three parts, and each one is derived:
| Part | Read from | To change it |
|---|---|---|
/api — where the app mounts REST | the convention | protocols: { rest: { prefix: '/v1' } } in fougere.config.ts |
/blog — the frond | the frond's name | protocols: { rest: { path: '/journal' } } in its frond.config.ts |
/posts/{id}/publish — the operation | the class and the signature | operations: { 'post.publish': { rest: { path, method, status } } } in its frond.config.ts |
The plural is derived from the address (post → posts, category → categories), so
checkout.pay answers POST /api/shop/checkouts/pay:
| Route | Operation |
|---|---|
GET /api/blog/posts | post.list |
POST /api/blog/posts | post.create |
GET /api/blog/posts/{id} | post.findById |
PATCH /api/blog/posts/{id} | post.update |
DELETE /api/blog/posts/{id} | post.delete |
GET /api/blog/posts/by-slug | post.findBySlug — explicit query kind below; a named operation wins over {id} |
POST /api/blog/posts/{id}/publish | post.publish(id: Post['id']) |
The path is read off the signature, never off the operation's name. A parameter typed by the
entity's primary — publish(id: Post['id']) — points at one row, so it comes before the
operation: /posts/{id}/publish. Another required value comes after it — byTag(tag: string)
answers /posts/by-tag/{tag} — and an optional one is read from the query string. Typing the
id as Post['id'] also means its rules are declared once, in the entity, and the value is
judged against them before the method runs.
The method is read off the contract too:
| The operation | Method |
|---|---|
reads (its kind is query) | GET |
its name starts with delete or remove | DELETE |
points at one row and takes Partial<Post> | PATCH |
points at one row and takes only fields of it — retitle(id: Post['id'], title: Post['title']) | PATCH, the fields in the body |
points at one row and takes a whole Post | PUT |
| anything else | POST |
The path points at the row, and the body carries what changes:
retitle answers PATCH /api/blog/posts/{id}/retitle with { "title": "…" }. A parameter that is
not a field of the row — retitle(id, title, reason: string) — makes it an action, so POST.
update merges what it is sent, so it answers PATCH; a PUT there answers 405.
The kind comes first. Core reads a finite, whole-word convention (list…, find…, get… are
queries; create…, publish…, delete… are commands). A name with no evidence, or contradictory
evidence such as getOrCreate…, is refused by fougere check and at boot, with the line to write
— see business logic. The key is the operation's address and name,
'post.findBySlug', so two handlers of one frond that both have a findBySlug are told apart.
The verb is not decorative: the routes above are the canonical table @fougere/adapter-rest
derives, and every host matches against it rather than deriving a second one. A path served
under another verb answers 405 with an Allow list — it never falls through to a different
operation.
fougere.config.ts may also name the route of an operation, for a frond it cannot edit — one a
package brings, like betterAuth():
protocols: { rest: { overrides: { 'session.get': { path: '/me' } } } },
For a frond of the project, the boot refuses it and gives the line to write in that frond's
frond.config.ts instead. A host that routes by file — SvelteKit, TanStack Start, React Router,
Next — mounts REST where its route file sits; when that file is not under the prefix, the answer
is a 404 that says so.
Four properties worth knowing, the same on every host:
- Same façade. A REST body uses the same validation, unknown-key rejection, and
collectors as the envelope. A body that is not JSON is refused with
415. - Same errors. Failures project through
toHttpError: the real HTTP status from the code table, the full typed value indata. - Statuses read off the contract, never off the op's name:
201for aPOSTon the collection that answers one row,204for an op that answers nothing or aDELETEthat answerstrue,404for a row that is not there (aDELETEansweringfalse, ornullwhere the op promises one),200otherwise.protocols.rest.statuswins over all of them. - List shape. An op answering a page serializes as
{ items, total, hasMore, endCursor }. - A door follows its frond. A process serves the REST of the fronds it hosts. A frond placed
elsewhere by
fronds:is answered there —fougere serve blogserves/api/blog/…beside the call envelope — and the process that calls it no longer projects its routes.
Around a request — a REST middleware
A middleware of the Frond stands around every CALL, whichever door it came through. A REST middleware stands around a REST REQUEST, in REST's own terms — it reads the headers before the body is read, refuses by throwing, and decorates the answer:
// fronds/blog/middlewares/UploadLimit.ts
import { ErrorCode, FougereError } from '@fougere/core';
import { RestMiddleware, type ResponseResult, type RestRequest } from '@fougere/adapter-rest';
export default class UploadLimit extends RestMiddleware() {
async around(request: RestRequest, next: () => Promise<ResponseResult>): Promise<ResponseResult> {
if (Number(request.headers.get('content-length')) > 1000) {
throw new FougereError({ code: ErrorCode.PAYLOAD_TOO_LARGE, message: 'Posts stop at 1000 bytes.' });
}
return next();
}
}
RestMiddleware() stands around every route of the Frond. Handed a handler, it stands around that
handler's routes only, one method per operation it names, and may answer in the operation's place:
export default class PostRest extends RestMiddleware(PostHandler) {
async findById(request: RestRequest, next: () => Promise<ResponseResult>): Promise<ResponseResult | RestResponse<301, { location: string }>> {
if (request.params.id === 'old-slug') return { status: 301, headers: { location: '/api/blog/posts/p1' }, data: null };
return next();
}
}
A method naming no operation of the handler refuses the boot. What it may answer besides the
operation — the 301 and its location above — is read off its return type by the scan and
published on the route, which is what OpenAPI
lists; a frond() written by hand states it as { ctor: PostRest, answers: { findById: [{ status: 301, headers: ['location'] }] } }. A refusal thrown by a REST middleware becomes the answer where it is
thrown, so one standing outside it still decorates it. It is ordered with the Frond's other
middlewares by the same middlewares: list. GraphQL runs none: a document spans several addresses.
OpenAPI — what REST serves, published on request
protocols: { rest: { openapi: {} } } publishes an OpenAPI 3.1 document at /api/openapi.json.
Without the line nothing is published: the document names every op and what each refuses at once.
A page reading it is a second decision, since it loads code from a CDN: openapi: { ui: true }
serves Swagger UI at /api/docs, and ui: 'scalar' serves
Scalar instead, its telemetry turned off. Each file is pinned to a version and to its hash
(integrity), so a browser refuses a file the CDN changed rather than running it.
Nothing is declared for it. It is read off the routes the process serves:
- each route — its path, its method,
operationIdas<address>.<op>, the method's doc sentence as its description, the parameters its signature puts in the path or the query; - its body — the op's input view, or the fields of the row a change carries;
- its answers — the status the door answers with and what it answers, a page as
{ items, total, … }; what a door middleware may answer in the op's place, with its headers; and what the op can refuse, at the status a refusal is answered with — the codes the scan found in its code beside the ones the framework implies, each status naming its own codes.
A schema is the card's own descriptor, since a card IS JSON Schema: an entity
is written once under components, and a view cut from it is named by where it is used
(PostUpdateInput). An op an announcement calls has no route, so it is not in the document.
GraphQL — a standalone projection
@fougere/adapter-graphql derives Pothos types, inputs and CRUD operations from the same
getFields() metadata every other adapter reads. Under Nuxt, protocols: { graphql: {} }
mounts it on /graphql; anywhere else it is a projection you hand to the GraphQL server of
your choice.
registerAll — the whole schema, one call
import SchemaBuilder from '@pothos/core';
import { registerAll, registerGraphQL } from '@fougere/adapter-graphql';
const builder = new SchemaBuilder({});
builder.queryType({});
builder.mutationType({});
registerAll(builder, app); // ← every address, every op, every relation
registerGraphQL(router, builder.toSchema()); // mount it on /graphql
registerAll walks the scanned Fronds. For each address a handler serves, it registers one
field per exposed operation, and a type for each schema those operations return — an
entity's type once, whichever address answers it. ref and many relations connect the
registered types; computed fields from a Presenter become
resolvers.
Each generated resolver calls the handler façade and therefore keeps its validation,
unknown-key rejection, and collectors. A custom resolver that calls the storage directly must
apply those rules itself. Use registerType and registerOperations for fields the
projection cannot derive.
Two options, both rarely needed:
registerAll(builder, app, { surface: 'graphql' }); // honour frond.config.ts surfaces
registerAll(builder, app, { filter: (address) => address !== 'auditLog' });
demos/schema-ecommerce runs exactly this under Apollo Server on :4000.
demos/schema-ecommerce10 files/**
* Adapter GraphQL — schema Pothos généré depuis les entités fougere
*/
import SchemaBuilder from '@pothos/core';
import { registerType, registerInput, registerOperations } from '@fougere/adapter-graphql/pothos';
import { categoryStorage, productStorage, customerStorage, orderLineStorage, orderStorage } from './db.js';
import {
Category, Product, Customer, OrderLine, Order,
CreateProduct, UpdateProduct,
} from './entities.js';
const builder = new SchemaBuilder<{}>({});
// ─── Types ─────────────────────────────────────────
const CategoryType = registerType(builder, {
name: 'Category',
entity: Category,
});
const ProductType = registerType(builder, {
name: 'Product',
entity: Product,
exclude: ['categoryId'],
relations: {
category: {
type: CategoryType,
resolve: (p: any) => categoryStorage.findById(p.categoryId),
},
},
});
const CustomerType = registerType(builder, {
name: 'Customer',
entity: Customer,
exclude: ['email'],
});
const OrderLineType = registerType(builder, {
name: 'OrderLine',
entity: OrderLine,
exclude: ['productId'],
relations: {
product: {
type: ProductType,
resolve: (p: any) => productStorage.findById(p.productId),
},
},
});
const OrderType = registerType(builder, {
name: 'Order',
entity: Order,
exclude: ['customerId'],
relations: {
customer: {
type: CustomerType,
resolve: (p: any) => customerStorage.findById(p.customerId),
},
lines: {
type: OrderLineType,
list: true,
resolve: (p: any) => orderLineStorage.findAllBy({ orderId: p.id }),
},
},
});
// ─── Inputs ────────────────────────────────────────
const CreateProductInput = registerInput(builder, { name: 'CreateProductInput', schema: CreateProduct });
const UpdateProductInput = registerInput(builder, { name: 'UpdateProductInput', schema: UpdateProduct });
const CreateOrderLineInput = builder.inputType('CreateOrderLineInput', {
fields: (f) => ({
productId: f.string({ required: true }),
quantity: f.int({ required: true }),
}),
});
const CreateOrderInput = builder.inputType('CreateOrderInput', {
fields: (f) => ({
customerId: f.string({ required: true }),
note: f.string(),
lines: f.field({ type: [CreateOrderLineInput], required: true }),
}),
});
// ─── Read-only operations via registerOperations ──
builder.queryType({});
/** Helper: create a read-only operations map (list + findById). */
function readOnlyOps() {
return new Map<string, any>([
['list', {
kind: 'query',
signature: {
name: 'list',
params: [{ name: 'options', type: { raw: 'ListOptions', name: 'ListOptions' }, optional: true }],
returnType: { raw: 'ListResult', name: 'ListResult' },
},
}],
['findById', {
kind: 'query',
signature: {
name: 'findById',
params: [{ name: 'id', type: { raw: 'string', name: 'string' } }],
returnType: { raw: 'any', name: 'any', nullable: true },
},
}],
]);
}
registerOperations(builder, {
name: 'Product',
type: ProductType,
facade: {
list: (ctx: any) => productStorage.list(ctx.body),
findById: (ctx: any) => productStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Category',
type: CategoryType,
facade: {
list: (ctx: any) => categoryStorage.list(ctx.body),
findById: (ctx: any) => categoryStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Customer',
type: CustomerType,
facade: {
list: (ctx: any) => customerStorage.list(ctx.body),
findById: (ctx: any) => customerStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Order',
type: OrderType,
facade: {
list: (ctx: any) => orderStorage.list(ctx.body),
findById: (ctx: any) => orderStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
// ─── Mutations ─────────────────────────────────────
builder.mutationType({});
(builder as any).mutationFields((f: any) => ({
createProduct: f.field({
type: ProductType,
args: { input: f.arg({ type: CreateProductInput, required: true }) },
resolve: async (_: any, { input }: any) => {
const v = CreateProduct.validate(input);
if (!v.success) throw new Error(v.errors.map((e: any) => `${e.path}: ${e.message}`).join(', '));
// active/createdAt are realised by the storage (SQL default, auto timestamp) —
// the caller only supplies what it owns.
return productStorage.create(input);
},
}),
updateProduct: f.field({
type: ProductType,
nullable: true,
args: {
id: f.arg.string({ required: true }),
input: f.arg({ type: UpdateProductInput, required: true }),
},
resolve: async (_: any, { id, input }: any) => {
const v = UpdateProduct.validate(input);
if (!v.success) throw new Error(v.errors.map((e: any) => `${e.path}: ${e.message}`).join(', '));
const updates: Record<string, unknown> = {};
for (const [k, val] of Object.entries(input)) {
if (val != null) updates[k] = val;
}
// An empty patch has no SET clause to run — just hand back the current row.
if (Object.keys(updates).length === 0) return (await productStorage.findById(id)) ?? null;
return productStorage.update(id, updates);
},
}),
createOrder: f.field({
type: OrderType,
args: { input: f.arg({ type: CreateOrderInput, required: true }) },
resolve: async (_: any, { input }: any) => {
let total = 0;
const lines: { productId: string; quantity: number; unitPrice: number }[] = [];
for (const line of input.lines) {
const product = await productStorage.findById(line.productId);
if (!product) throw new Error(`Product ${line.productId} not found`);
total += (product as any).price * line.quantity;
lines.push({ productId: line.productId, quantity: line.quantity, unitPrice: (product as any).price });
}
const order = await orderStorage.create({
customerId: input.customerId,
status: 'pending',
total,
note: input.note ?? null,
});
for (const line of lines) {
await orderLineStorage.create({ ...line, orderId: order.id });
}
return order;
},
}),
}));
export const graphqlSchema = builder.toSchema();
Every door runs one cycle
REST, GraphQL, the call envelope and the terminal are four doors over one cycle, app.answer(door, received): a door package brings decode, run and encode in its own terms, and core runs them —
entering the request so every call it makes says which one it answers, announcing received,
decoded, encoded and failed beside the calls to whoever observes the app, keeping the
caller's state, and making a refusal public by one rule before the door
says it. Core names no door: the cycle is the same for a door it has never heard of.
Writing your own
A custom surface such as RSS, webhook, or CLI follows the same sequence. The invoke page includes a complete RSS example.
Next: Deployment — what a Fougere app needs at runtime.