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 bringWhat Fougere plugs into it
Hono, FastifycreateHonoRouter / createFastifyRouter, then REST
your GraphQL server (Apollo, Yoga…)registerAll(builder, app) returns a Pothos schema
Nuxt / Nitrothe module mounts the envelope and the REST catch-all
nothing at allfougere serve exposes the Frond over JSON-RPC, fougere call invokes it from a shell
your own loopinvoke — 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:

PartRead fromTo change it
/api — where the app mounts RESTthe conventionprotocols: { rest: { prefix: '/v1' } } in fougere.config.ts
/blog — the frondthe frond's nameprotocols: { rest: { path: '/journal' } } in its frond.config.ts
/posts/{id}/publish — the operationthe class and the signatureoperations: { '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:

RouteOperation
GET /api/blog/postspost.list
POST /api/blog/postspost.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-slugpost.findBySlug — explicit query kind below; a named operation wins over {id}
POST /api/blog/posts/{id}/publishpost.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 operationMethod
reads (its kind is query)GET
its name starts with delete or removeDELETE
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 PostPUT
anything elsePOST

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 in data.
  • Statuses read off the contract, never off the op's name: 201 for a POST on the collection that answers one row, 204 for an op that answers nothing or a DELETE that answers true, 404 for a row that is not there (a DELETE answering false, or null where the op promises one), 200 otherwise. protocols.rest.status wins 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 blog serves /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, operationId as <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
Local only
src/adapter-graphql.ts
/**
 * 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.