Junction · on npm
Junction is the API layer for FrontierJS, on Bun. A short service over a model gives you the endpoints, the live updates and the production defaults — and an effect runs only once the write it follows has committed.
A service from a model
A service file over a Litestone model answers find, get, create, patch and remove, and grades every call by the model's own rules. You write only what the model cannot say.
tasks.service.ts is the service tasks over Task, autoloaded, and the OpenAPI document lists exactly what it answers, so narrowing methods: narrows the spec too. @length(1, 80) @trim is the 400 on create, and a filter on a column that does not exist is refused by name instead of ignored. @@gate answers 401 to a stranger and 403 to a caller a level short, in the schema where every other path meets it too. paginate: { default, max } sets the size, the answer carries total and hasMore, and a request over the max is refused with the number it may ask for. // db/schema.lite
model Task {
id Int @id
title String @length(1, 80) @trim
done Boolean @default(false)
createdAt DateTime @default(now())
@@gate(0.4.4.5) // read: anyone · create, update: signed in · delete: level 5
}
// api/src/services/tasks.service.ts
import { createService } from '@frontierjs/junction'
export default createService({ paginate: { default: 3, max: 50 } })POST /api/tasks {"title":" Ship the docs "} (signed in)
201 {"id":1,"title":"Ship the docs","done":false,"createdAt":"…"}
POST /api/tasks {"title":""}
400 {"message":"title: title must be at least 1 character",
"data":[{"field":"title", …}]}
POST /api/tasks {"title":"anon"} (no token)
401 {"message":"Authentication required"}
DELETE /api/tasks/1 (signed in)
403 {"message":"\"Task.delete\" requires level 5, user has level 4"}
GET /api/tasks
200 {"data":[{"id":1, …},{"id":2, …},{"id":3, …}],
"total":6,"limit":3,"offset":0,"hasMore":true,"endCursor":"…"}
GET /api/tasks?$limit=2&$offset=1&$orderBy=-id
200 {"data":[{"id":5, …},{"id":4, …}],
"total":6,"limit":2,"offset":1,"hasMore":true}
GET /api/tasks?$limit=500
400 {"message":"$limit 500 is above this service's maximum page of 50. …"}
GET /api/tasks?nope=1
400 {"message":"Unknown filter key 'nope' — did you mean 'done'?. Filterable
fields on Task: createdAt, done, id, title. …"}
GET /api/openapi.json
openapi 3.1.0
/api/tasks get, post
/api/tasks/{id} get, patch, delete… marks what was cut. Read more: createService over a Litestone model · narrowing the surface with methods · OpenAPI · the schema grades the caller
Effects that run when they should
Every call runs through one hook pipeline, and two verbs say when an effect happens: ctx.afterCommit(fn) waits for the commit, and ctx.enqueue(job, payload) survives a crash.
around, before, after and error hooks, plus a validated phase for rules that read the database. A UI resource runs the same four around its network call. transactional:, a later hook that throws rolls the row back, even after an earlier hook sent the email. A callback given to ctx.afterCommit runs only once the call succeeded and its transaction committed. afterCommit does not survive a crash. ctx.enqueue writes an OutboxMessage row inside the call's own transaction, so the intent commits with the write or rolls back with it. app.configure(caravan())
app.configure(outbox())
createService({
name: 'orders',
transactional: ['create'],
async create(ctx) {
const order = await $.db.order.create({ data: ctx.data })
await ctx.enqueue('receipt.email', { orderId: order.id })
return order
},
hooks: {
after: {
create: [
(ctx) => {
say('after hook: email sent from the hook itself')
ctx.afterCommit(() => say('afterCommit: receipt for order ' + ctx.result.data.id))
},
(ctx) => {
if (ctx.data.total > 1000) throw new Error('over the credit limit')
},
],
},
},
})// 1. total 5000: the second after hook throws, the transaction rolls back
after hook: email sent from the hook itself (order 1)
create threw: GeneralError: over the credit limit
orders 0 outbox rows 0
// 2. total 40: the same call commits
after hook: email sent from the hook itself (order 1)
afterCommit: receipt for order 1
orders 1 outbox rows 1 deliveredAt null
job receipt.email ran payload {"orderId":1}
// 3. the relay marked the row it handed over
outbox rows 1 deliveredAt set attempts 1
queue job outbox:bce1eaf0... name receipt.email status done
app.outbox.deliver() -> {"delivered":0,"failed":0}caravan() and outbox(), and two in-process calls to this service. Abridged: the schema and the job handler are cut, and so are a few output lines. The crash replay is not in this run; it is the replay test in Caravan's test/outbox-relay.test.ts. Read more: afterCommit and enqueue · Junction hazards · the relay, end to end · resource hooks in the UI
Live by default
Name a channel on the service and each write is pushed over the WebSocket to the clients allowed to read it. The browser client applies the push to the list on screen.
channel: 'orders' on the service pushes every create, patch and remove as an event named for it, such as orders created. The only other code is the line that joins a connection to the channel. changed count, and a list reloads on it. model Order {
id Int @id @default(autoincrement())
customerId Int
status String @default("pending")
@@gate(1.4.4.5)
@@allow(read, customerId == auth().id || auth().isAdmin)
}
app.services.register(createService({
name: 'orders',
model: 'Order',
channel: 'orders',
}))
app.configure(channels(a => {
a.channels.on('connection', (_session, conn) => a.channel('orders').join(conn))
}))// two sockets on the channel: customer 5, and another signed-in user
POST /orders (as admin) -> 201
owner {"type":"event","event":"orders created",
"data":{"id":1,"customerId":5,"status":"pending"}}
other (no frame)
// a route that writes as the system: one create, one bulk update
owner orders created {"id":2,"customerId":5,"status":"pending"}
owner orders changed {"model":"Order","operation":"updateMany","count":2}
other orders changed {"model":"Order","operation":"updateMany","count":2}
// the owner's socket is closed, one POST /orders, the owner reconnects
frames for the write made while away: noneWebSocket clients, on a scratch port with an in-memory Litestone client. The first write was a POST over HTTP. Abridged: the handshake frames are left out. The other user's changed frame carries a count and no row, and a reconnecting socket is not sent what it missed. Read more: channels and the browser client · how a broadcast is graded · the real-socket tests
Built for production
The parts an API usually gains after its first incident come in the package: a key that makes a retried write run once, a breaker for an upstream that is down, a CSRF check for cookie sessions and probes for the orchestrator.
Idempotency-Key from a signed-in caller runs once, and its repeat replays the first answer for 24 hours, from that process's cache. A duplicate still in flight is a retryable 409, the same key on a different body is a 422, and a call that failed frees its key. circuitBreaker() as an around hook and, after a run of consecutive failures (five by default), the service answers 503 without calling out for 30 seconds, then tries again, and one more failure reopens it. It counts every error the call throws. csrf(), which refuses a write from another site or a sibling subdomain unless its origin is on your list. Bearer-token calls need no check, and a request with no origin information at all passes unless you set allowMissingOrigin: false. health() mounts /health, a 503 when a check you name fails or the app is draining, and /health/live, which consults no check. Outside development /metrics stays unmounted until you give it a token or an auth function, since it names the pid and every service. $ curl -i -X POST localhost:8461/charges -H 'Authorization: Bearer …' \
-H 'Idempotency-Key: ord-7f3a' -d '{"amount":4200}'
HTTP/1.1 201 Created
{"id":"ch_1","amount":4200,"run":1}
# the same request again
HTTP/1.1 201 Created
{"id":"ch_1","amount":4200,"run":1}
# the same key, a different amount
HTTP/1.1 422 Unprocessable Entity
{"name":"Unprocessable","message":"Idempotency key reused with a different
payload (charges.create). A key names one request; use a new one.", … "retryable":false}
# a new key
HTTP/1.1 201 Created
{"id":"ch_2","amount":4200,"run":2}
# the server's log for all four
[handler] charges.create ran (run #1)
[handler] charges.create ran (run #2)$ curl localhost:8461/rates # five times, upstream down, threshold 3
{"name":"GeneralError","message":"ECONNREFUSED: …","code":500,"data":null} ×3
{"name":"Unavailable","message":"Circuit open for rates.find — too many recent failures","code":503,"data":null}
{"name":"Unavailable","message":"Circuit open for rates.find — …","code":503,"data":null}
# the server's log: three calls reached the upstream, not five
[handler] rates.find reached the upstream ×3packages/junction: a scratch app with a charges service whose create counts its own runs, and a rates service whose find calls an upstream that is not listening, under circuitBreaker with a threshold of 3. The caller signs in through the stub auth in @frontierjs/junction/testing. Abridged: the security headers on each answer and the repeated curl lines are cut, long bodies are shortened with …, and the # lines and ×3 are ours. Read more: Idempotency-Key · circuit breaker · CSRF protection · health and metrics · what bites
Batteries and extension points
A cache, an event bus and a scheduler exist the moment you call createApp. Mail, OpenAPI, health checks and webhooks are one app.configure() each. What you need to change, you change with a plugin or a provider.
app.cache, app.events and app.scheduler are built by createApp. The scheduler is held until the app serves, so a job fires only in a process that is serving. configure. Before that, reading app.mail throws and names the line to add; 'mail' in app is the question to ask instead. The job queue, Caravan, is its own package. name and any of register, boot, work, ready and shutdown. register runs inside configure(), so its routes exist when it returns, and requires fails startup if a plugin it needs is missing. { send, batch }, AI any IAIModel, auth any IAuth. The code that calls app.mail.send does not change when the mailer behind it does. import { createApp, health } from '@frontierjs/junction'
import { mail } from '@frontierjs/junction/mail'
import { openapi } from '@frontierjs/junction/openapi'
// A provider: mail() takes any { send, batch }. This one prints instead of sending.
const printMailer = {
send: async (m) => { console.log('mailer.send', m.to); return { id: '1', message: 'printed' } },
batch: async (ms) => Promise.all(ms.map(printMailer.send)),
}
// A plugin: a name and the phases it wants.
let beats = 0
const pulse = {
name: 'pulse',
register(app) { console.log('register'); app.get('/pulse', () => ({ beats })) },
boot(app) { console.log('boot'); app.registerReadiness('pulse', () => true) },
work(app) { console.log('work'); app.scheduler.every('1 second', () => { beats++ }) },
shutdown() { console.log('shutdown') },
}
const app = createApp({ config: { name: 'batteries', port: 4731 } })
console.log('before configure: cache', typeof app.cache.get,
'· scheduler', typeof app.scheduler.every, '· mail', 'mail' in app)
app.configure(pulse)
app.configure(mail({ mailer: printMailer }))
app.configure(openapi({ title: 'Batteries', version: '1.0.0', ui: '/docs' }))
app.configure(health())
await app.start()
await app.mail.send({ to: 'alice@example.com', subject: 'Welcome', text: 'hi' })
// then fetch /pulse, /health, /docs and /openapi.json, wait two seconds, fetch /pulse, app.stop()$ bun run app.ts
before configure: cache function · scheduler function · mail false
register
boot
work
… # the startup banner, cut
mailer.send alice@example.com
GET /pulse 200 {"beats":0}
GET /health 200 {"status":"ok","app":"batteries","version":"1.0.0","uptime":0,
"checks":{"pulse":{"status":"ok","latencyMs":0}}, …}
GET /docs 200 <!DOCTYPE html>
GET /openapi 3.1.0 # the "openapi" field of /openapi.json
GET /pulse 200 {"beats":2} # two seconds later
shutdown# notes are ours. The plugin's readiness check appears in /health beside the app's own, and the timer ran while the app served. Read more: interface-first adapters · mail · health and metrics · OpenAPI · webhooks · Caravan, the job queue
Start
Install it on its own or inside a FrontierJS app, where the same models also seed the database and the UI.
@frontierjs/junction on npm, MIT licensed. Bun only, by construction.