FrontierJS

Junction · on npm

Write the service.
The API comes with it.

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

The CRUD, the checks and the paging are already written.

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.

  • The file name picks the model.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.
  • Validation is the field's own rules.@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.
  • Authorization is the gate, not a hook.@@gate answers 401 to a stranger and 403 to a caller a level short, in the schema where every other path meets it too.
  • A page has a ceiling.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
A scratch app on this repo's Junction and Litestone, run with curl on 2026-11-03: the schema and the 3-line service file above (import path shortened to the package name), with a stub auth for sign-in and the OpenAPI plugin around it. Responses are abridged, cut to the status and the lines that matter, and … 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

The email goes out only for a write that committed.

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.

  • One pipeline, on both sides. A service call runs 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.
  • After the method is not after the commit. Under 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.
  • If it must not be lost, enqueue it.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.
  • Delivered at least once. The relay hands the row to a Caravan job as soon as the call commits, and a sweep every five seconds by default picks up any row a crash left behind. A replayed handoff queues nothing new, but write your handlers to be idempotent.
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}
A real run: a Litestone client, a Junction app with 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

A write reaches every open screen, and nobody wrote the broadcast.

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.

  • One line, not a pub/sub layer.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.
  • A subscriber hears only what it may read. Both sockets sit on the same channel, and the row's read rule decides who gets the frame: in the run the customer got the row and the other user got nothing.
  • A job's write is announced too. A write made as the system reaches the same sockets, graded the same way. A bulk write names no row, so it sends a changed count, and a list reloads on it.
  • A dropped socket reconnects, and it does not replay. The client backs off from one second to thirty and reloads a live list after the reconnect, because the server queues nothing for an absent socket. The write made while the socket was down never arrived as a frame; the reload is what catches it up.
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: none
A real run of that service with two Bun WebSocket 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

Safe to retry, quick to give up.

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.

  • A retried write runs once. On by default, a mutating call carrying an 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.
  • A failing upstream fails fast. Add 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 checks where cookies make CSRF possible. Turning on cookie sessions installs 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.
  • Probes an orchestrator can read.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     ×3
A real run on Bun against packages/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

The usual dependencies are in. Change one without forking.

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.

  • Some are on before you ask.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.
  • The rest are opt-in, and say so. Mail, OpenAPI, health, webhooks and AI each take one 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.
  • A plugin adds a capability. It is an object with a 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.
  • A provider replaces an implementation. Mail takes any { 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
A real run of this app in a scratch directory, importing the package's own source. The app is abridged: the closing comment stands for the fetches and the stop. The output has the startup banner and the logger's shutdown lines cut, and the # 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

A service is the whole setup.

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.