Write a surface
REST, GraphQL, the call envelope and the terminal are four doors onto the same operations. A surface of your own — a webhook, a feed — is one more door, written the same way.
A door
A door says how a request becomes calls, and how their outcome becomes an answer — in its own terms.
Core runs every door through one cycle, app.answer(door, received):
import { createAppRunner, type Door } from '@fougere/core';
/** A payment provider's webhook: one POST, one call. */
const webhook: Door<Request, { id: string; status: string }, Response> = {
name: 'webhook',
async decode(request) {
const event = await request.json();
return { id: event.data.id, status: event.type };
},
run: ({ id, status }) => call({ address: 'order', op: 'settle' },
{ params: { id }, query: {}, input: { status }, state: {} }),
encode(outcome) {
return 'refused' in outcome
? Response.json({ error: outcome.refused.code }, { status: 400 })
: new Response(null, { status: 204 });
},
};
const call = createAppRunner(app);
hono.post('/hooks/psp', (c) => app.answer(webhook, c.req.raw));
decode | turns what arrived into what the calls need — read within maxBodyBytes |
run | makes the calls: one, or several for a request that asks for several things |
encode | says the outcome — { answered } or { refused } — in the door's shape |
What the cycle adds, the same for every door:
- the request is entered, so every call it makes says which request it answers;
received,decoded,encodedandfailedare announced to whoever observes the app, beside the calls;- the caller's state is read and written, when the door says what carries it (
carries); - a refusal is made public by one rule before the door says it — an
INTERNAL_ERRORnever shows its message, unless the door istrusted(the terminal, whose caller is the operator).
Core names no door: the cycle is the same for one it has never heard of. A simpler bridge — parse,
call, format, outside any cycle — is invoke.