npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@frontierjs/junction

v0.1.9

Published

The API realm of FrontierJS — services, the hook pipeline and HTTP/WS transport over a Litestone schema, on Bun

Readme

Junction

A pragmatic, batteries-included backend framework for Bun. Built on three ideas: Total.js's transport performance, Feathers's service model, and Bun's native SQLite. The goal is a framework you can understand completely in one sitting, ship real products with immediately, and extend without fighting the internals.


Install

bun add @frontierjs/junction

Bun only, and permanently so. The transport is Bun.serve, static files and logs go through Bun.file, and the cache battery imports bun:sqlite. Under Node the import itself fails — Node refuses to strip types inside node_modules — and a build step would only move that failure later, so there is no Node path to have. engines says the same thing.

@frontierjs/litestone is an optional peer: install it and createService generates all five CRUD methods, validation and File column wiring from the schema. Without it, services are hand-written and everything else works unchanged.

Quick start

Working on Junction itself, from a clone of the monorepo:

cd packages/junction
bun run dev        # starts example/app.ts with --watch

# In another terminal — fire up the interactive REPL:
bun run repl

Or test manually with curl:

curl http://localhost:3000/api/health
curl http://localhost:3000/api/users
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"[email protected]"}'

Run the test suite:

bun run test

The example app keeps its data in a local SQLite file (demo.db) and needs no external services.

In your own app, fli new my-app (or npm create frontier@latest my-app) scaffolds the whole layout below; junction has no scaffolder of its own.


Where Junction lives in an app

A FrontierJS app puts one directory at the root per realm, and Junction owns api/:

my-app/
├── db/                      ← Data realm — schema.lite, migrations
│   └── schema.lite
├── api/                     ← Junction. Everything below is yours
│   ├── index.ts             ← bun --watch entry
│   ├── config/              ← junction.config.js — plugins, middleware
│   ├── src/
│   │   ├── app.ts
│   │   ├── core/            ← env, Litestone client, auth, hooks
│   │   ├── services/        ← *.service.ts — autoloaded at boot, found without being declared
│   │   └── routes/          ← a file's path is its URL: routes/hooks/calendar.ts → /hooks/calendar
│   └── test/
└── web/                     ← UI realm — Sierra + Mesa

Every sub-project uses the same folders — config/ src/ public/ test/ dist/ deploy/ — so api/config/junction.config.js sits exactly where web/config/sierra.config.js does.

api/ and web/ are peers, and db/ belongs to neither — the schema is shared, so it sits above both. The API points Litestone's createClient at that one file, and the UI build reads the same one; nothing is copied down into either. The full layout is in the root README; fli new scaffolds it.


Structure

The package itself:

packages/junction/
├── index.ts              ← the main entry: the four axes of a call and the host
│
├── src/core/
│   ├── app.ts            ← createApp() — lifecycle, plugins, service routing
│   ├── service.ts        ← createService(), callService()
│   ├── context.ts        ← ServiceContext, CallOptions, RequestMeta (ALS)
│   ├── envelope.ts       ← THE result envelope — wrap/unwrap, one owner
│   ├── hooks.ts          ← around/before/after/error pipeline
│   ├── hooks-builtin.ts  ← authenticate, requireRole, paginate, protect, …
│   ├── hooks-resilience.ts ← circuitBreaker, rateLimit
│   ├── litestone.ts      ← Litestone adapter, gateAuth, autoValidate
│   ├── schema.ts         ← createSchema(), v.* — zero-dep validation
│   ├── loader.ts         ← auto-discovery of *.service.ts files, and where they are
│   ├── logger.ts         ← ILogger — pretty dev / JSON prod
│   ├── env.ts            ← defineEnv() — typed, validated at startup
│   └── errors.ts         ← named HTTP error classes
│
├── src/transport/
│   ├── http.ts           ← Bun.serve wrapper + public fetch() for tests
│   ├── router.ts         ← two-tier route cache (O(1) fixed + linear dynamic)
│   ├── bridge.ts         ← THE hard boundary between transport and services
│   ├── channels.ts       ← WebSocket channels, announce() hook, channels() plugin
│   ├── presence.ts       ← presence tracking + heartbeat
│   ├── health.ts         ← /health + /metrics endpoints (health)
│   ├── body.ts           ← JSON / multipart / urlencoded parser
│   ├── static.ts         ← range requests, etag, gzip, cache headers
│   ├── types.ts          ← TransportContext, WsContext, WsHandlerSet, SSE types
│   └── middleware.ts     ← cors, helmet, rateLimit, requestLogger, correlationId
│
├── src/plugins/
│   ├── manifest/         ← manifest — schema + migration state at /manifest
│   ├── openapi/          ← openapi() plugin, generateOpenAPI() — 3.1 from the registry
│   ├── email/            ← mailer plugin, system + campaign senders
│   ├── webhooks/         ← at-least-once delivery, IWebhookStore, SQLite adapter
│   ├── devtools/         ← devtools plugin + admin UI
│   └── outbox/, backfill/, commitments/, export/, metrics/  ← each holds its engine.ts
│
├── src/auth/
│   └── types.ts          ← IAuth, SessionContext
│
├── src/config/index.ts   ← loadConfig(), layered config files, deepMerge
├── src/events/index.ts   ← IEventBus (in-process, Redis-swappable interface)
├── src/cache/index.ts    ← ICache — in-memory
├── src/scheduler/index.ts ← cron + interval + once, aligned ticks
├── src/mail/index.ts     ← IMail + SMTP/Resend adapters
├── src/ai/index.ts       ← IAIModel interface — adapters reach a vendor through conduit
├── src/client/index.ts   ← browser/Sierra client — service(), resource()
├── src/testing/index.ts  ← createTestApp(), request(), withTestMeta()
│
├── tools/                ← repl.ts and the snapshot/inspection scripts
├── example/              ← runnable apps (elegant.ts is the modern demo)
└── test/

The main entry is the four axes of a call — admission, call, carriage, announcement — and the host: createApp, plugins, config (FJS-D639). Everything else is a battery reached by its subpath: /mail, /ai, /cache, /scheduler, /webhooks, /openapi, /outbox, /backfill, /commitments, /manifest, /devtools, /export, /metrics. A battery types its app.<slot> by augmenting an empty App* interface (AppMail, AppAI, AppCache, AppScheduler, AppOutbox, AppCommitments, AppWebhooks), so importing the subpath is what brings the type (FJS-D640). Junction opens no database: createApp({ db }) is the one way in.


The REPL

The fastest way to explore a running API. Start your app, then in a second terminal:

bun run repl                              # connects to localhost:3000
bun run tools/repl.ts --port 4000
bun run tools/repl.ts --host staging.myapp.com --https
──────────────────────────────────────────────────────
  Junction REPL  → http://localhost:3000
──────────────────────────────────────────────────────
  tab  completes commands, paths & $vars
  type help for all commands  ·  ctrl+c to exit

○ › health
○ › get /users
○ › post /users {"name":"Alice","email":"[email protected]"}
● › auth my-token
● › set id = $_.id
● › get /users/$id
● › watch /health 2000

Tab autocomplete — commands, live API paths (fetched from /metrics on connect), and $variable names.

Variables — $_ is auto-set to every response body. Named variables with set:

set payload {"name":"Alice","role":"admin"}
post /users $payload

set id = $_.id               ← capture a field from the last response
get /users/$id            ← expand in a path

last .data[0].email           ← dot-path extraction without a variable
inspect $_                    ← pretty-print last response

Watch — poll an endpoint on an interval. ctrl+c stops the watch without exiting.

History replay — !3 replays history entry 3.

Shortcuts — health, metrics, services hit the built-in endpoints directly.

Tutorial — embedded interactive guide for onboarding new developers:

tutorial                    ← start from the beginning
tutorial auth               ← jump to the auth chapter
tutorial list               ← list all chapters (basics, auth, variables, power)
skip                        ← skip the current step
quit tutorial               ← pause (progress saved)

Four chapters, 18 hands-on steps. The REPL stays live throughout — you run real commands against your actual app and the tutorial validates what happened.


The secret sauce

1. The transport bridge — transport/bridge.ts

This is the most important file in the codebase.

HTTP request
    ↓
bridge.toContext()   ← THE hard boundary
    ↓
hook pipeline
    ↓
service method
    ↓
bridge.toResponse()
    ↓
HTTP response

Nothing above the bridge ever touches req/res. Nothing below it knows what HTTP is. Services are completely transport-agnostic — the same service handles HTTP, WebSocket, and internal calls identically.

bridge.toContext(httpCtx, 'users', 'users', 'http', app)  // HTTP
bridge.toContext(wsCtx,   'users', 'users', 'websocket', app) // WebSocket
bridge.internal('users',  'create', { name: 'Alice' })        // internal — zero HTTP

The call context. What every hook and service method sees, on every transport. There is no ctx.params — it was an open bag whose contents propagated into sub-calls, which is the FeathersJS shared-mutation footgun. It is four typed fields instead, each with one propagation rule:

ctx.auth.user       // WHO is calling. Frozen. Propagates to internal calls.
ctx.caller.headers  // caller environment — ip, userAgent, headers. Read-only,
ctx.caller.ip       //   propagates. `{}` on internal calls.
ctx.route.roomId    // path captures ({id}, {room}). Router-only; {} internal.
ctx.locals          // per-call scratch. FRESH {} every call, does NOT propagate
                    //   — a sub-service cannot reach its caller's locals.

Alongside them, the two halves of what used to be one query object:

ctx.query       // FILTERS ONLY — becomes the WHERE clause. Never holds a
                //   `$`-prefixed key.
ctx.directives  // { limit, offset, orderBy, select, … } — how to SHAPE the
                //   result. The bridge translates `$limit` on the wire into
                //   this; no `$`-prefixed key survives it.

Internal callers pass directives under their own key — a flat { limit: 10 } is not a directive and is ignored:

await app.service('posts').find({ status: 'open' }, { directives: { limit: 10 } })

Request-wide values — correlation id, idempotency key, locale — belong to the whole request rather than any one call, so they ride an AsyncLocalStorage store instead of being threaded through arguments:

import { requestMeta } from '@frontierjs/junction'
const { correlationId } = requestMeta() ?? {}   // readable at any call depth

A TransportContext — what a raw route or a WS handler receives — says ctx.route for the same thing: app.get('/x/{id}', ctx => ctx.route.id). One word per realm, so an idiom carried from one context to the other reads undefined at worst rather than silently meaning something else. Route patterns use {id}, not :id — a :id segment is matched literally and the route silently never fires.

Work that outlives the request has no store to read, so it opens its own:

await app.runAs(userId, async () => {
  await app.service('orders').patch(id, { status: 'shipped' })   // runs as them
})

app.principal() is the other half — who is in scope, asked from somewhere holding no ctx, which is how a job records who enqueued it. See Running as somebody below.

2. The hook pipeline — core/hooks.ts

around:enter → before → [method] → after → around:exit
                              ↓
                         error (if thrown)

Cross-cutting concerns — auth, validation, rate limiting, caching, real-time push — all live in hooks and compose without touching service method bodies.

createService({
  name: 'deployments',
  hooks: {
    around: { all: [logTiming(logger)] },
    before: {
      all:    [authenticate, requireWorkspace()],
      create: [requireRole('developer'), validate],
    },
    after: {
      all: [announce((_, ctx) => app.channel(`workspace:${ctx.auth.user?.workspaceId}`))],
    },
    error: { all: [logError] },
  },
})

Short-circuit: set ctx.result in a before hook to skip the method. This is how caching hooks work.

after means after the METHOD, not after the call succeeded. Hooks run in sequence, so an after hook that sends an email runs, and a later after hook throwing then makes the whole call report failure — the client sees a rejected create whose email has already gone.

For the write, declare transactional: on the service:

createService({ name: 'orders', model: 'Order', transactional: true })
createService({ name: 'orders', model: 'Order', transactional: ['create', 'pay'] })

One transaction spans the whole pipeline — before hooks, the method, and the after hooks — so a later after hook throwing rolls the write back instead of leaving a committed row behind a rejected response. find/get are never wrapped. Declaring it without a Litestone client on $.db throws, naming the service.

It also makes a bulk write all or nothing. A bulk create or filtered patch/remove that fails on any row writes none of them and answers 422, with the { data, error } pairs in the error's data.errors. A partial-success envelope never leaves a transactional call.

Two things it does not do, and both matter:

  • It does not make side effects atomic. A transaction rolls back rows, not SMTP. An email an earlier after hook already sent stays sent. Put an irreversible effect last in the after chain, or dispatch it to @frontierjs/caravan and let the queue own delivery — noting that Caravan's queue is its own SQLite file, so dispatching buys retries, not atomicity.
  • It holds SQLite's single write lock for the whole pipeline, after hooks included, so an after hook doing network I/O serializes every write in the app behind it. That is why it is off by default, and why irreversible work belongs in a job rather than in an after hook.

Built-in hooks, from @frontierjs/junction/hooks: authenticate, requireRole, paginate, protect, allow, timestamps, logTiming, circuitBreaker, rateLimitHook.

protect() supports dot-path notation for nested fields:

// Top-level field
service.hooks({ after: { all: [protect('passwordHash')] } })

// Nested field — strips user.meta.internal from every result
service.hooks({ after: { all: [protect('meta.internal', 'auth.refreshToken')] } })

rateLimitHook — per-service, per-method rate limiting. Keys on userId for authenticated requests, falls back to IP for anonymous. Different from the rateLimit middleware, app.configure(rateLimit(...)), which applies globally to all HTTP routes.

import { rateLimitHook } from '@frontierjs/junction/hooks'

// Per-service: limit create to 10/minute per user
app.services.register(createService({
  name: 'posts',
  hooks: {
    before: {
      create: [rateLimitHook({ max: 10, window: '1 minute' })],
      all:    [authenticate],
    }
  }
}))

// Custom key — rate limit by organization
hooks: {
  before: {
    create: [rateLimitHook({
      max:     100,
      window:  '1 hour',
      key:     (ctx) => ctx.auth.user?.accountId ?? ctx.caller.ip,
      message: 'Organization limit reached',
    })]
  }
}

Note: rateLimitHook uses an in-process counter — correct for single-instance deployments. For multi-instance, provide a custom key function and an external counter (Redis, etc.) via a custom hook.

IEventBus.onAny() — subscribe to all events with the event name included:

// Named subscription — no event name
app.events.on('orders:created', (data) => { /* data only */ })

// onAny — event name + data, unsubscribe function returned
const off = app.events.onAny((event, data) => {
  logger.info('event', { event, data })
})

// Useful for audit trails, metrics, and webhook fan-out

IEventBus.stats() — how many are listening, and to what. hasListeners() answers a yes/no, which is the wrong question when an announcement goes missing:

app.events.stats()
// { events: { 'orders:created': 2, '__any__': 1 }, total: 3 }

'*' and '__any__' keep their own keys — a subscriber to everything is a different fact from a subscriber to one thing — and an event whose last handler unsubscribed is omitted rather than reported as zero.

3. Custom methods as first-class citizens

Custom methods are defined directly on the service alongside CRUD — no separate wrapper needed.

createService({
  name: 'servers',

  async reboot(ctx)    { /* ... */ },
  async heartbeat(ctx) { /* ... */ },
  async events(ctx)    { /* ... */ },

  hooks: {
    before: { reboot: [requireRole('developer')] },
  },
})

Dispatch is by header, not by path — over HTTP. A custom method follows the same transport rule as CRUD: the browser client sends it over the WebSocket when one is connected (a service_call frame naming the method, no URL involved), and falls back to HTTP otherwise. The HTTP form calls the service's own URL with X-Service-Method, which keeps the URL space flat and stops a method name from colliding with a record id:

curl -X POST http://localhost:3000/servers \
  -H 'x-service-method: reboot' -H 'content-type: application/json' -d '{"id":1}'

There is no POST /servers/1/reboot route — that 404s. CRUD names are blocked from header override, and the case you write is the case that dispatches (getStats stays getStats).

Custom methods run through the full hook pipeline — identical to CRUD methods. Hook targets use the method name as the key.

Works the same when the service is model-backed — the generated CRUD and your custom methods sit side by side:

createService({
  name:   'servers',
  model:  'servers',
  schema: jsonSchema,

  async reboot(ctx) {
    const db = $.db
    return db.servers.update({ where: { id: ctx.id }, data: { status: 'rebooting' } })
  },

  hooks: {
    before: { reboot: [authenticate, requireRole('developer')] },
  },
})

4. WebSocket routing — app.ws()

Same {param} path syntax as HTTP routes. Handlers receive WsContext — params, query, headers, user all pre-resolved, no raw Bun types:

app.ws('/chat/{roomId}', {
  open(ctx) {
    // ctx.route.roomId — extracted from the path
    // ctx.user          — auth resolved before open() is called
    ctx.send({ type: 'welcome', room: ctx.route.roomId })
  },
  message(ctx, msg) { ctx.send({ echo: msg }) },
  close(ctx, code, reason) { },
})

app.ws is the raw door (FJS-D688): the path takes apiPrefix as app.post does, and junction sends the peer no connected and no {type:'error'} frame, since a vendor's socket speaks its own protocol. Per-socket state is ctx.locals, and a handler's throw goes to onError. A third argument sets the route's own bounds:

app.ws('/media/{ticket}', handlers, {
  limits:  { maxFramesPerSecond: 0, maxInFlight: 0, maxConnectionsPerIp: 0 },   // over config.http.ws, field by field
  upgrade: req => { if (!known(req)) return new Response('no such ticket', { status: 404 }) },
})

A frame past a route's bound closes the socket 1008 with the code (rate_limited, too_many_in_flight) as the reason. maxPayloadLength is Bun's, one for the whole server, so it is not a route option.

A frame sent the instant the socket opens waits until open() has settled, an async one included, and so does close(). Frames are not ordered among themselves: each message() starts when its frame arrives, so a handler that awaits I/O can see the next frame before the current one has finished.

5. Channels — real-time without polling

app.configure(channels(app => {
  app.channels.on('connection', (session, conn) => {
    if (session?.workspaceId)
      app.channel(`workspace:${session.workspaceId}`).join(conn)
  })
}))

// In a service hook
after: {
  create: [announce((_, ctx) => app.channel(`workspace:${ctx.auth.user?.workspaceId}`))],
}

Client: { type: 'event', event: 'deployments created', data: { ... } }

A room is a channel bound to one row (FJS-D596). Presence and channel(name).send are not graded per recipient, so the room's membership is kept true instead:

app.configure(channels(setup, { rooms: { 'page:{id}': 'Page' }, presence: ['page:*'] }))

// in the connection hook: graded at the Data boundary, answers whether it admitted
app.channels.on('connection', async (session, conn) => {
  const id = conn.data.query.page        // the upgrade's search: /ws?page=7
  if (id) await app.channel(`page:${id}`).join(conn)
})

A service call cannot reach the socket it arrived on, so a room is chosen at the upgrade. The client says which with socketQuery, kept across a reconnect; client.setSocketQuery({ page: '8' }) cycles the socket into another room, the way setToken cycles it for another principal.

The join reads the row as the system and asks the joiner's read verdict on it. After every committed write to a model the row's read policy reaches (db.$readReach), each member is asked again; one who lost the row leaves the room and is sent presence:sync with no members. With a claims resolver installed the policy's reach cannot be read, so every committed write asks again. Needs createApp({ db }), or createApp({ tenants }) with the tenant in the pattern ('{tenant}:page:{id}': 'Page'): the room reads its row through that tenant's client, and a write in one tenant asks no room of another.

6. Compiled hook pipelines

Hook pipelines are merged and compiled once per service during app.start(), after all plugins have registered. Per-request cost is a plain array lookup — no allocation, no merging.


Response helpers

ctx.json(data, status?)
ctx.text(data, status?)
ctx.html(data, status?)
ctx.empty(status?)
ctx.redirect(url, status?)
ctx.file(path, download?)
ctx.stream(readable, type, status?)

// Pagination envelope with next/prev links built from the request URL
ctx.paginate(rows, total, { limit, offset })
// → { data, total, limit, offset, next: '/posts?$limit=20&$offset=20', prev: null }

// Server-Sent Events
const { response, send, close, onDisconnect } = ctx.sse()
send({ event: 'update', data: { ts: Date.now() } })

// onDisconnect — fires when the client closes the connection.
// Use it to clean up timers and subscriptions — no more hard timeouts needed.
const off = app.events.on('orders:created', (order) => send({ event: 'order', data: order }))
const heartbeat = setInterval(() => send({ event: 'ping', data: {} }), 30_000)
onDisconnect(() => { off(); clearInterval(heartbeat) })

return response

The frame is @frontierjs/toolbelt/sse's formatEvent, and a page reads it back with readEvents from @frontierjs/sierra/fetch — over fetch, because EventSource cannot send a bearer token. send refuses an event or id holding a line break.

Build a stream with ctx.stream() or ctx.sse(). To a caller that accepts gzip the transport reads a compressible body to its end before it sends it, and a new Response(readable) of your own looks like any other body, so a text/csv or application/x-ndjson stream written that way arrives in one piece when it closes. The helpers mark their response as streamed. A route that must build its own sets content-encoding on it, which exempts the body. Outside production the transport warns, once per route, when a body is held over a second for compression.


Methods the transport answers for you

Three answers no app registers:

| Request | Answer | | --- | --- | | HEAD /things where GET /things is registered | the GET route runs, headers intact, body dropped by the runtime | | DELETE /things where only GET/POST are | 405 with Allow: GET, HEAD, OPTIONS, POST | | OPTIONS /things where nothing claimed it | 204 with the same Allow | | any method on a path nothing answers | 404 — the URL is wrong, not the verb |

A route registered with router.head(...) or router.options(...) wins over either fallback, which is how cors()'s OPTIONS /* keeps every preflight. Allow lists the methods of THAT path, so a read-only resource does not advertise a POST it does not have.


Health and metrics

import { health } from '@frontierjs/junction'

app.configure(health())

// With auth, custom path, and custom readiness checks:
app.configure(health({
  path:  '/_internal',
  token: process.env.METRICS_TOKEN,
  checks: {
    redis:      async () => { await redis.ping(); return true },
    thirdParty: async () => (await fetch('https://api.x.com/ping')).ok,
  },
}))

There is no built-in database check — junction opens no database, so the app names its client's check in checks.

GET /health — readiness: 200 when healthy, 503 when any check fails or the app is draining. GET /health/live — liveness: consults no check, so a draining process is still alive. Point a readinessProbe at the first and a livenessProbe at the second.

GET /metrics — process memory, request counts, response types, WebSocket connections, cache hit rate, service registry. Outside development — NODE_ENV anything but unset, development or test — it is not mounted unless token or authFn is set, since it names the pid and every service. Either option gates /health too.

A plugin contributes its own block through app.registerMetricsSource(name, fn) rather than mounting a second endpoint: Caravan adds jobs, the outbox adds outbox. The function must be synchronous — its return value is assigned straight into the body, so a promise would serialize as {}; anything that needs a query caches it rather than running one per scrape.

A plugin's own readiness check goes through app.registerReadiness(name, fn) — Caravan registers jobs, the outbox outbox — and is answered in /health's checks beside the app's.

Exporting to a collector — otlp()

import { otlp } from '@frontierjs/junction/otlp'

app.configure(otlp({
  endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,   // e.g. http://collector:4318
  headers:  { authorization: `Bearer ${process.env.OTEL_TOKEN}` },
}))

The one door from the process to an OpenTelemetry collector or vendor, as OTLP/JSON over fetch. It sends:

  • spans — one per service call, a child per hook and per query. A call made from inside another call is that call's child. A request carrying a traceparent continues its trace; otherwise the trace id is the request's correlationId.
  • the log, each line carrying the trace and span it was written inside, with the logger's secret-key redaction applied.
  • every metric source (/metrics's numbers) as a gauge, once a minute.

It never sends the audit trail, and a query span names the table, operation and row count but never the SQL, its params or its args. Sampling follows the caller: a traceparent with the sampled flag off exports no spans; anything else is exported, and a collector drops what it does not want. A collector that is down costs telemetry, not requests — the queue is bounded, a failed POST is not retried, and exported/dropped/failed appear in /metrics under otlp. The timers run in work(), so a one-shot process exports nothing.

Announcing a second listener

An app that starts its own sidecar — a dev mail catcher, a stand-in payment provider — has a process nothing here can see: the boot banner is derived from mounted routes and a sidecar has none.

const sink = startMailSink()          // your process, your handle
app.registerDevService('mail', {
  url:  'http://localhost:8111',
  note: 'inbox: http://localhost:8111/',
})

It gets a line on the boot banner and a devServices entry in /manifest. Announcing is all it does: junction never starts, stops or health-checks the process — you already hold the handle that can.


Middleware

import { cors, helmet, rateLimit, requestLogger, correlationId, csrf } from '@frontierjs/junction'

app.configure(cors({ origins: ['https://myapp.com'] }))
app.configure(helmet())
app.configure(rateLimit({ max: 100, window: 60_000 }))
app.configure(requestLogger())

// Stamps ctx.requestId and echoes it as X-Request-ID on every response
app.configure(correlationId())

// CSRF protection for cookie-session APIs — see section below
app.configure(csrf({ origins: ['https://myapp.com'] }))

One id per request. A request's correlation id is the trace id of the traceparent it states, else its X-Request-ID, else 32 hex junction mints, and the log line, the audit row, a job's row and conduit's outbound call all carry it. An inbound id that is malformed (a traceparent that does not parse, an X-Request-ID outside 1–128 characters of [A-Za-z0-9_.:@-]) is replaced, not refused (FJS-D660).

Rate limiting is per-process. Counters live in memory and are not shared across workers or machines. For single-process deployments this is not an issue. For multi-process deployments, apply rate limiting at the load balancer layer (nginx limit_req, Cloudflare, Fly.io, etc.) in addition to or instead of this middleware.


CSRF protection

The bearer token pattern (Authorization: Bearer ...) is inherently CSRF-safe — a cross-origin page cannot set arbitrary request headers, so it can never forge a bearer-token request on behalf of a victim. csrf() is needed with cookie-based sessions, and cookie mode installs it for you (FJS-D610): auth(auth, { cookieAuth: true }) or config.auth.cookie turns it on at start, with middleware.csrf (or else middleware.cors.origins) as the list of other origins allowed. SameSite=Lax alone is not enough — a cross-site form POST to /auth/login carries no cookie and sets one.

// Run cors() first so preflight is handled before csrf() checks the origin
app.configure(cors({ origins: ['https://myapp.com'] }))
app.configure(csrf())                                       // the app's own origin only
app.configure(csrf({ origins: ['https://admin.myapp.com'] })) // and these

For every POST/PUT/PATCH/DELETE request: Sec-Fetch-Site: same-origin or none passes; an Origin (else the origin of Referer) on the list passes; Sec-Fetch-Site: cross-site or same-site is refused, the latter because a sibling subdomain is the same site and SameSite sends it the cookie; a browser with no Sec-Fetch-Site passes when its Origin names the request's Host; and a request with no origin information at all passes, since only a browser can be made to send a forged request. Behind a proxy the Host comparison needs the host header to survive the hop.

FRONTIER_CORS_ORIGINS — the list a deploy derives. A comma-separated environment variable that fli deploy sets on the container (the web domain's https:// origin when the API has a domain of its own, plus a desktop shell's tauri://localhost and http://tauri.localhost). It is joined onto middleware.cors.origins, and onto middleware.csrf.origins when that declares its own list; a declared '*' stays as written, and unset changes nothing. cors.credentials is still the app's to set.

combineOrigins() — share one list between cors and csrf:

import { combineOrigins } from '@frontierjs/junction'

const origins = combineOrigins(['https://myapp.com', 'https://admin.myapp.com'])

app.configure(cors(origins.forCors()))   // passes origins as-is (supports '*')
app.configure(csrf(origins.forCsrf()))   // same list, '*' coerced to allow-all

Full options:

// Function predicate — wildcard subdomains etc.
app.configure(csrf({ origins: (o) => o.endsWith('.myapp.com') }))

// Refuse a request with no Origin, Referer or Sec-Fetch-Site (the default allows it)
app.configure(csrf({ allowMissingOrigin: false }))

// Custom rejection handler — log instead of throwing
app.configure(csrf({
  origins:    ['https://myapp.com'],
  onRejected: (ctx, reason) => logger.warn('CSRF blocked', { reason, ip: ctx.ip }),
}))

With cookie sessions from any auth library:

import { cors, csrf, combineOrigins } from '@frontierjs/junction'

const origins = combineOrigins(['https://myapp.com'])

// 1. cors() first — handles OPTIONS preflight before csrf() runs
app.configure(cors({ ...origins.forCors(), credentials: true }))

// 2. csrf() second — protects POST/PUT/PATCH/DELETE against cross-site requests
//    Needed whenever a session cookie exists
app.configure(csrf(origins.forCsrf()))

If you use bearer tokens only (no cookies), omit csrf() entirely — bearer tokens are already CSRF-safe.


Circuit breaker

import { circuitBreaker } from '@frontierjs/junction/hooks'

service.hooks({
  around: {
    all: [circuitBreaker({
      threshold: 5,         // open after 5 consecutive failures
      timeout:   30_000,    // wait 30s before probing again (OPEN → HALF_OPEN)
      onOpen:    (ctx) => logger.warn(`Circuit open: ${ctx.service}`),
      onClose:   (ctx) => logger.info(`Circuit closed: ${ctx.service}`),
    })]
  }
})

Throws Unavailable (503) in open state so error hooks and logging still run.


API versioning

const app = createApp({
  config: { ...config, apiPrefix: '/api/v1' },
})
// All services now at: /api/v1/users, /api/v1/notes, etc.

Every route registered through app.get/app.post/… moves with it, not just the service routes: /api/v1/health, /api/v1/auth/login, /api/v1/webhooks. A route that must sit at a fixed path — a callback URL somebody else owns, a probe path an orchestrator owns — is registered on app.http.router directly, the layer beneath the shortcuts, which applies no prefix. Every route handler, on either layer, runs inside the request scope.

Default is '' — services mount at /{service}. Handles missing/extra slashes automatically, so api, /api and /api/ all mean the same thing.

The browser client takes the same option and must be given the same value:

createJunctionClient({ url, apiPrefix: '/api/v1' })
// client.auth.signIn() then posts to /api/v1/auth/login — `authPrefix` is the auth
// plugin's own prefix and stays relative to this one.

Idempotency-Key

A mutating request carrying an Idempotency-Key header executes once. The repeat replays the first call's answer without running the pipeline again — no second hook, no second row, no second broadcast.

curl -X POST localhost:3000/orders -H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: 8f3c…' -d '{...}'   # runs
curl -X POST localhost:3000/orders -H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: 8f3c…' -d '{...}'   # replays

The key is scoped to the service, the method and the principal: replay skips the hook pipeline, so it skips the auth checks in it, and a key shared across callers would hand one caller another's answer. A caller with no principal has nothing to scope by, so its key is ignored. Reads are never replayed.

Two cases worth knowing:

  • A failed call releases its key. A caller is entitled to retry a request that did not succeed, and remembering the failure would answer the retry with the failure.
  • A duplicate arriving while the first is still running is a 409 with retryable: true — the first may yet succeed, and then the request is answerable. It is not executed in parallel, which is the outcome the key exists to prevent.
config: {
  idempotency: {
    enabled:    true,          // default
    ttl:        '24 hours',    // how long a completed key is remembered
    pendingTtl: '2 minutes',   // how long the in-flight marker lives
  }
}

Backed by the app cache, so it is per-process: two instances behind a load balancer do not share it. Nothing is stored unless a caller sends a key.


Bulk operations

Bulk patch and remove without an id are disabled by default — a guard against accidental whole-table mutations:

createService({
  name:      'logs',
  allowBulk: true,    // opt in explicitly
  bulkMax:   1000,    // optional — rows one filtered write or one array create may carry (default 1000)
})
// DELETE /logs?status=archived  ← now allowed
// DELETE /logs                  ← still rejected (no filter conditions)

A filtered write runs one statement per row, and answers the same envelope a bulk create does — the rows it wrote in data, one { data, error } pair per row it could not in errors:

{ "kind": "list", "data": [ { "id": 1, "status": "shipped" } ], "total": 1,
  "errors": [ { "data": { "id": 2 }, "error": { "name": "TransitionViolationError", "message": "…" } } ] }

Row by row rather than one UPDATE because that is what applies the schema. Litestone skips @@transitions on updateMany and bumps @version without requiring it, so a single UPDATE walks past a state machine that refuses the same move by id. What follows from the loop:

  • bulkMax refuses before it writes. One statement per row means an unbounded filter is unbounded work under SQLite's single write lock. Over the cap the call is a 400 naming the count. An array create is the same loop and the same cap: more rows than bulkMax in one POST is refused before the first is written.
  • Only rows the caller can read are touched. Selecting the targets applies the read policy; the write applies the update/delete one.
  • A caller-supplied @version is refused — one value cannot be right for N rows. Each row is written against the version selected with it, so a row that moved underneath lands in errors as a VersionConflictError instead of being overwritten.
  • No atomicity, the same trade bulk create makes. transactional: true on the service buys all-or-nothing back: one failed row is a 422 carrying the failures, and nothing is written.

Config options

| Key | Type | Default | Description | |---|---|---|---| | port | number | 3000 | HTTP port. FRONTIER_PORT_BE (the slot fli dev and fli deploy hand the process), else a bare PORT (a PaaS's), beats the file; a port passed in createApp's config beats both | | hostname | string | '0.0.0.0' | Bind address | | apiPrefix | string | '' | Prefix for every route the app registers — '' mounts services at /{service} | | paginate | { default, max } | { default: 20, max: 100 } | Page size of every model service that declares no paginate — default when $limit is unstated, max the most a wire $limit may ask for (above it is a 400). A service's own paginate wins. In junction.config.js it goes under app: | | http.drainTimeout | number | 5000 | ms a socket gets to finish during shutdown | | shutdown.timeout | number | 15000 | ms the WHOLE shutdown gets. Past it the process exits 1 rather than waiting — a hung step used to end with node exiting 0 | | shutdown.pluginTimeout | number | 5000 | ms each plugin's shutdown() gets. One that never settles is logged and skipped instead of taking the rest of the list with it | | shutdown.crashHandlers | boolean | true | install unhandledRejection/uncaughtException handlers that log, stop the app and exit 1. Skipped where the app already has its own | | http.compress | boolean | true | gzip responses | | http.maxBodySize | number | 262144 | max request body bytes. A body declaring no length is read incrementally and cancelled at the bound, so it is a memory limit and not only a check | | http.idleTimeout | number | the runtime's 10 | seconds a connection may go without progress before the RUNTIME closes it. 0 disables; above 255 is refused at boot. Coarse — measured, it closes at about twice the value with a floor near four seconds, so it is not a deadline | | http.requestTimeout | number | off | ms a handler may take before the app answers 503 itself. Absent means no bound. Setting it raises the runtime's per-request timer, so this answer wins the race instead of the socket resetting. It does not stop the handler | | http.tls | { cert, key } | off | Serve https from a PEM cert and key, given as paths (a missing file is refused at boot, naming it). Development only: a client that refuses http://, localhost included (the Bitwarden CLI), needs the app itself to speak TLS; in production a proxy does | | http.maxConcurrentRequests | number | off | requests being handled at once before the next is refused 503 + Retry-After: 1 with a retryable: true body, before any work. Counts requests, not sockets — Bun has no connection hook, so an idle keep-alive socket is invisible. A WebSocket upgrade is not counted; http.ws.maxConnections bounds those and the two are never summed. 0 or absent is unlimited | | http.ws.maxFrameBytes | number | maxBodySize | largest inbound frame this app accepts. Refused by name — 1009 frame_too_large — because Bun's own ceiling closes with a bare 1006 that reads as the network dropping. Follows maxBodySize by default: a socket must not be a wider door than a POST | | http.ws.maxPayloadLength | number | 1048576 | the runtime's ceiling — what this process will buffer at all | | http.ws.maxFramesPerSecond | number | 100 | per socket, token bucket, burst 2x. Over it a frame is dropped and the client is told once a second | | http.ws.maxInFlight | number | 32 | concurrent unresolved frames per socket. The rate bounds arrivals; this bounds outstanding work | | http.ws.maxConnectionsPerIp | number | 256 | refused at the upgrade with a 503, so the caller reads a status rather than a close code. 0 is unlimited | | http.ws.maxConnections | number | 0 | total sockets. Unlimited by default — only an operator knows what the box holds | | http.ws.reverifySeconds | number | 60 | seconds a socket that sends nothing keeps its principal before verifySession is asked again; every frame asks it anyway. A session gone closes the app's socket 4001, a changed principal closes 1001 and the reconnect re-resolves it. 0 asks only on a frame (FJS-D1201) | | cache.defaultTtl | string | '5 minutes' | default cache entry TTL |

Where the services are

Where createApp({ root: import.meta.url }) says (FJS-D1080). The root is the directory of the module that calls createApp, and every folder derives from it: services/ and routes/ beside it, caravan's jobs/ and the notifications plugin's notifications/ beside it too, junction.config.js in ../config above it. Nothing is probed from the entry file or the working directory — under a test runner or a drive both name the RUNNER — and an app with no root reads nothing from disk.

api/src/app.ts        createApp({ root: import.meta.url })
api/src/services/     *.service.ts
api/src/jobs/         *.job.ts
api/config/           junction.config.js

The boot banner carries the answer either way:

🚀 shop v1.0.0
    services  20
    autoload  api/src/services
🚀 shop v1.0.0
    services  3
    autoload  none — no api/src/services

The second banner is an app that loaded nothing and will 404 every route those services would have mounted. Nothing throws — a missing directory is a deliberate no-op — so the banner is where that shows.

Where the routes are

A route is a URL an outsider fixes — a vendor's webhook setting, a link in an inbox. Junction loads the routes/ folder beside the services folder (or beside the entry, when there are no services), and a file's path there is the URL it answers. Its GET, POST, PUT, PATCH and DELETE exports are the handlers, the same (ctx) => … that app.get takes:

// api/src/routes/hooks/calendar.ts  →  POST /hooks/calendar
export const POST = async (ctx) => { /* verify the signature, answer 200 */ }

// api/src/routes/files/[id].ts      →  GET /files/{id}, read as ctx.route.id
// api/src/routes/index.ts           →  GET /

apiPrefix applies as it does to app.get. A file or folder beginning _ is private to the files beside it. Boot refuses, naming the files, a file that exports no verb, one that threw, and two that answer one URL (routes/a.ts and routes/a/index.ts, or [id] and [slug] at one position). A route that must sit outside the prefix still goes through app.http.router. An app with no root loads neither.


Auth

import { createLitestoneAuth, auth as authPlugin } from '@frontierjs/auth'

const auth = createLitestoneAuth(db)
const app  = createApp({ config, auth, db })
app.configure(authPlugin({ auth }))  // mounts /auth/* routes

Junction defines the IAuth interface and ships no vendor adapter. An app that wants another library implements IAuth over it and mounts the library's handler on a raw route.

The transport reads Authorization: Bearer or X-API-Key, calls auth.verifySession() and stamps ctx.auth.user; the authenticate hook only refuses a call that has no ctx.auth.user.

Sessions from a cookie

Off by default. Turn it on and the transport reads the session token from a cookie as well — on HTTP requests and on the WebSocket upgrade:

app.http.setAuthCookie('session')      // or config: { auth: { cookie: 'session' } }

Using @frontierjs/auth you never write that: auth(auth, { cookieAuth: true }) declares it from its own register(), so cookie mode is one switch.

Three rules worth knowing:

  • Explicit beats ambient. Authorization: Bearer and X-API-Key both win over the cookie, so acting as someone else for one call still works from a browser holding a session cookie.
  • An empty cookie is not a token — that is what a logout leaves behind.
  • It is off by default for a security reason, not caution. A bearer token has to be attached by script, so a cross-origin page cannot forge one. A cookie is attached by the browser automatically, which is what makes CSRF possible at all — so an app takes that exposure deliberately, and turning it on installs csrf() at start (FJS-D610). SameSite=Lax (which @frontierjs/auth sets) withholds the cookie from cross-site writes, but a cross-site POST to /auth/login needs no cookie: it sets one. See csrf() below.

SessionContext shape — what ctx.auth.user looks like inside hooks and services:

interface SessionContext {
  userId:       string                           // always present
  userType:     string                           // the kind, never a role: 'user' | 'bot' | 'peer' | 'service'
  authMethod:   'session' | 'apiKey' | 'oauth'  // how they authenticated
                | 'created' | 'verified'
  email?:       string
  name?:        string
  accountId?:   string                           // multi-tenant account scope
  workspaceId?: string                           // multi-tenant workspace scope
  role?:        string
  scopes?:      string[]

  // Standing — read by sessionGateLevel() to grade a caller on Litestone's
  // 0–7 @@gate scale. `undefined` = the app doesn't model this stage (not an
  // objection); `null` = it does and this user hasn't reached it.
  verifiedAt?:     Date | string | null
  activatedAt?:    Date | string | null
  isAdmin?:        boolean    // → ADMINISTRATOR (5)
  isOwner?:        boolean    // → OWNER (6)
  isSystemAdmin?:  boolean    // → SYSADMIN (7)
}

IAuth optional flow methods — implement these on your auth adapter to support password reset and email verification. The plugin's route handlers call them if present.

// Password reset flow
auth.requestPasswordReset?(email)                        // sends reset email
auth.confirmPasswordReset?(token, newPassword)           // consumes token, sets password

// Email verification flow
auth.requestEmailVerification?(userId)                   // sends verification email
auth.verifyEmail?(token)                                 // consumes token: { signedIn: LoginResult } for a registration, else { verified }

// Magic link — the mailbox as a first factor
auth.requestMagicLink?(email)                            // mails a link and a six-digit code
auth.verifyMagicLink?({ token } | { email, code })       // a LoginResult, as login() answers

These are optional on IAuth — if your provider handles them differently (e.g. the library has its own email flows), implement them in your IAuth or leave them unset and handle the routes yourself.


Running as somebody

ctx.auth.user propagates, so a service call made inside a request already runs as that caller and nothing has to be said. Work that outlives the request — a queued job, a retry, a scheduled sweep — is the case that does not: the store is gone by the time it runs, and no principal is STRANGER(0), refused by the model's own @@gate.

createApp({
  system: { userId: 'system', userType: 'service', role: 'system', authMethod: 'created' },
})

system is the principal an app is when it acts on its own behalf. It is graded by the app's own getLevel like any other. Declaring none is a valid answer and yields null — an app whose background work touches nothing gated needs no system identity, and inventing one would hand every app a privileged principal it never asked for.

app.principal()                  // who is in scope, or null
await app.runAs(userId, async (user) => { … })   // open a scope for them
await app.runAs(null,   async ()     => { … })   // as the app itself

runAs re-resolves the id through IAuth.sessionFor(userId) rather than replaying a stored session. That is the whole reason an id is what travels: a caller demoted, suspended or stripped of a role between asking and running is graded at what they hold now, where a snapshot is a privilege that outlives its own revocation. Inside the scope, auth propagates as it does anywhere else, so the body makes ordinary service calls that name nobody.

An id that cannot be resolved — a deleted user, or a provider that implements no sessionFor — throws by name. Falling back to no principal reinstates the refusal this exists to remove; falling back to system would silently escalate. Neither is safe, so there is no fallback.

IAuth.sessionFor is optional on the interface and proves no credential — it is reached only from code that has already decided whose behalf it acts on, and must never be wired to anything a request can name.

@frontierjs/caravan is the first caller: it records app.principal()?.userId at dispatch and runs the handler inside app.runAs(actorId, …).

Schema validation

import { createSchema, v } from '@frontierjs/junction'

const CreateUserSchema = createSchema({
  name:     v.required.string({ minLength: 2, maxLength: 100, trim: true }),
  email:    v.required.email(),
  age:      v.number({ min: 0, max: 150 }),
  role:     v.string({ enum: ['user', 'admin'], default: 'user' }),
  tags:     v.array(v.string(), { minItems: 0, maxItems: 10 }),
  address:  v.object(AddressSchema),
})

// Use as a before hook
service.hooks({ before: { create: [CreateUserSchema.hook()] } })

// Or call directly
const data = CreateUserSchema.parse(ctx.data)   // throws BadRequest on failure
const { valid, errors } = CreateUserSchema.validate(raw)

// Schema combinators
const UpdateSchema  = CreateUserSchema.partial()          // all fields optional
const PublicSchema  = CreateUserSchema.omit('passwordHash')
const LoginSchema   = CreateUserSchema.pick('email', 'password')

Convenience types (v.* and v.required.*):

v.string()        v.required.string()
v.number()        v.required.number()
v.boolean()       v.required.boolean()
v.email()         v.required.email()
v.url()           v.required.url()
v.uuid()          v.required.uuid()
v.date()          v.required.date()
v.array(items)    v.required.array(items)
v.object(schema)  v.required.object(schema)
v.any()

Array constraints: minItems, maxItems — v.array(v.string(), { minItems: 1, maxItems: 5 }).

Passthrough unknown fields:

// Default: unknown fields are stripped (safe for APIs)
const Strict = createSchema({ name: v.required.string() })

// passthrough: true — extra fields flow through unchanged
const Loose = createSchema({ name: v.required.string() }, { passthrough: true })

Service caching

Add cache: true to any service to automatically cache find and get responses. Writes (create, patch, remove) bust the cache for that service automatically.

createService({
  name:  'products',
  cache: true,           // 30s TTL, auth-scoped automatically
})

// Custom TTL
createService({
  name:  'config',
  cache: { ttl: '5 minutes' },
})

// Custom key function — full control
createService({
  name:  'reports',
  cache: { keyBy: (ctx) => `report:${ctx.id}:${ctx.query.format}` },
})

Cache keys include the authenticated user's ID when present — GET /orders for user A never serves user B's cached result. Public routes share a single cache entry per query.

The underlying store is the app's, shared across its services. Own a SQLite-backed one before the first cached call for persistence across restarts:

import { createSqliteCache } from '@frontierjs/junction/cache'

app.own('serviceCache', createSqliteCache({ path: './cache.db', defaultTtl: '1 minute' }))

Database

Junction opens no database (FJS-D641). createApp({ db }) is the one way in: a Litestone client, or any table-shaped client, becomes app.db. Opening it, its pragmas and its migrations are the client's business.

const db  = await createClient({ schema: './db/schema.lite', db: './app.db' })
const app = createApp({ config, db })

Litestone ORM

Litestone is Junction's first-party SQLite ORM. When installed, createService replaces manual CRUD — all five methods are generated from the schema, validation is auto-generated from the JSON Schema output, and File columns wire up to object storage transparently.

import { createService, withLitestoneDb } from '@frontierjs/junction'
import { createClient, generateJsonSchema, GatePlugin, LEVELS } from '@frontierjs/litestone'

const SCHEMA = `
  model Post {
    id        Int  @id
    title     String     @length(1, 200) @trim
    body      String
    authorId  Int
    createdAt DateTime @default(now())
    updatedAt DateTime @default(now()) @updatedAt

    @@gate(0.4.4.6)   // R=public  C/U=USER  D=OWNER
  }
`

// One options object, never positional. `schema` is inline text — a .lite file
// goes in `path`.
const db = await createClient({
  schema: SCHEMA,
  db:     './app.db',
  plugins: [new GatePlugin({ getLevel(user) {
    if (!user) return LEVELS.STRANGER
    return (user as { role: string }).role === 'admin' ? LEVELS.ADMINISTRATOR : LEVELS.USER
  }})],
})

const jsonSchema = generateJsonSchema(db.$schema)

// Wire per-request auth scoping — runs as an around hook before every service call
app.hooks({ around: { all: [withLitestoneDb(db)] } })

// createService generates find/get/create/patch/remove automatically.
// schema: jsonSchema auto-generates create + patch validators from the Litestone schema.
app.services.register(createService({
  name:       'posts',
  model:      'post',      // optional — resolved per call from the service name
                            //   ('posts' → db.post) when omitted
  schema:     jsonSchema,   // optional — enables auto-validation
  softDelete: 'deletedAt',  // optional — soft-delete via a nullable DateTime field
  allowBulk:  false,        // optional — default false, blocks bulk patch/delete
  bulkMax:    1000,         // optional — rows one filtered patch/remove or array create may carry
  paginate:   { default: 20, max: 100 },  // optional — omitted, config.paginate, else this
  cache:      true,         // optional — cache find/get responses, bust on writes
  methods:    ['find','get'],  // optional — narrow what this service answers.
                               //   Omitted = everything. See below.
  hooks: {
    before: {
      create: [authenticate],
      patch:  [authenticate],
      remove: [authenticate],
    },
  },
}))

methods: — the surface, declared

A model service answers every CRUD verb through the base with validation, whether or not your file declares one. Writing only find() does not make a service read-only; it makes the writes invisible. An append-only audit trail built that way accepts a forged POST.

Declare the narrower set with one key. Two forms:

createService({ name: 'audit',   model: 'AuditEvent', methods: 'readOnly' })
createService({ name: 'tickets', methods: ['find', 'create', 'approve'] })
  • Omitted means everything — existing services are unaffected.
  • 'readOnly' is shorthand for ['find', 'get'].
  • CRUD and custom methods share the list. Being defined on the service is not being offered; a method the list omits is refused like any verb.
  • An unlisted method answers 405, on every transport and to an in-process app.service('audit').create() — the check is in callService, which every caller goes through, so a job or a hook cannot do what a request cannot.
  • A name the service does not have throws at construction, so ['find','gett'] fails immediately instead of silently blocking get.
  • /manifest, /metrics and the OpenAPI spec all filter by the policy, so what a service answers and what it advertises cannot drift.

The list also DECLARES the actions. With no methods:, an action is found by scanning the definition for a function whose key is not a known option — which works, is what most services do, and cannot see a name an option already owns. Declaring it is the way in:

createService({
  name:    'reports',
  model:   'Report',
  methods: ['find', 'get', 'cache'],       // 'cache' is an ACTION here
  async cache(ctx) { … },                  // undeclared, this is eaten as config
})

cache, schema, channel and the other option names were previously impossible as action names, silently. In TypeScript a typed option still needs a cast — cache is declared CacheDeclaration and cannot also be a function — so the runtime honors the declaration and the type does not know about it.

Because the policy is structural rather than authorization, it is checked ahead of the hook pipeline — an anonymous caller gets 405, not 401.

withLitestoneDb(db) attaches the base Litestone client to the call, read as $.db, as an around hook. Auth scoping ($setAuth) happens inside getTable() at call time — after the authenticate hook has run — so the right user is always applied to every query.

deriveModelName converts a service name to a Litestone model name: 'users' → 'user', 'blogPosts' → 'blogPost'. Pass model explicitly if your naming doesn't follow this pattern.

jsonSchemaToJunctionSchema is available directly if you need to build a schema manually:

import { jsonSchemaToJunctionSchema } from '@frontierjs/junction'

const schema = jsonSchemaToJunctionSchema('Post', jsonSchema, 'create')
// Returns a Junction Schema object — pass to createSchema()

One search across models — searchService()

A palette that finds an issue, a comment and a project in one ranked list is one call to litestone's db.$search (FJS-D970), and searchService is its route. The mount names the models the palette holds; a caller may narrow that list and never widen it.

import { searchService } from '@frontierjs/junction/search'

app.services.register(searchService({ models: ['issue', 'comment', 'project'] }))
GET /search?q=quasar                    every mounted model, ranked on one scale
GET /search?q=quasar&models=issue       a subset; a model outside the mount is a 400 naming it
GET /search?q=quasar&$limit=8           the limit is spent only on rows the caller may read

A hit is { model, id, title, sub, _rank, _snippet }, unchanged from $search. The grade is the caller's client, so a hit is never a row the caller may not read. An empty q answers [], the last word is a prefix, and a query key other than q and models is refused by name. Each mounted model declares @@fts and @@label; one that does not fails on the first call with litestone's own refusal. name: moves the route from /search.


Transactions

Multi-step operations that need atomicity — create a user and a workspace together, or not at all — require a transaction. Litestone exposes db.$transaction() for this.

createService({
  name: 'accounts',

  async provision(ctx) {
    const db = $.db

    return db.$transaction(async (tx) => {
      const user      = await tx.user.create({ data: ctx.data })
      const workspace = await tx.workspace.create({ data: { ownerId: user.id } })
      return { user, workspace }
    })
  },

  hooks: {
    before: { provision: [authenticate] },
  },
})

What this bypasses — be explicit:

  • Service hooks on users and workspaces do not run — no before/after hooks on the individual writes
  • The individual writes are announced by the Data boundary's own tap when the transaction commits, not by a service
  • Gates and row policies still apply to every write — tx is the caller-scoped client
  • Cache is not busted for affected services automatically

When this is acceptable:

  • The operation is internal and has no real-time subscribers on the individual sub-writes
  • The calling service's own hooks handle any necessary side effects (e.g. an after hook on provision that publishes a single composite event)
  • Consistency matters more than the hook pipeline for this operation
// Handle side effects in the provision service's own after hook
createService({
  name: 'accounts',
  async provision(ctx) { /* ... db.$transaction(...) ... */ },
  hooks: {
    after: {
      provision: [
        // Publish once after the whole transaction succeeds
        announce((_r, ctx) => app.channel('accounts')),
        // Bust cache for affected services explicitly
        async () => { bustServiceCache('users'); bustServiceCache('workspaces') },
      ]
    }
  }
})

When the unit of work is one service call, prefer transactional: true on the service: it wraps the whole pipeline, hooks included, in one transaction, and ctx.afterCommit() below runs effects only once it commits. db.$transaction() is the escape hatch for work that spans services.

ctx.afterCommit() — the effect that must not run early

after means after the method, not after the call. Hooks run in sequence, so an after hook that sends an email runs, a later after hook throws, and the client is told the call failed with the email already gone — and under transactional: the row is rolled back while the email stays sent.

hooks: {
  after: {
    create: [(ctx) => {
      // Queued here, run only if the whole call succeeds — and, under
      // `transactional:`, only after the transaction commits.
      ctx.afterCommit(() => app.conduit.send('order.placed', { to, order: ctx.result }))
    }]
  }
}

Queue from any phase; callbacks run once, in order, after the announcement. Three things to know:

  • A throw does not fail the call. The write is committed and the broadcast is out — a 500 would tell the client a write failed that did not. It is logged and emitted as junction.aftercommit.error, and the remaining callbacks run.
  • Not durability. A crash between the commit and the callback loses the effect; nothing is recorded anywhere that it was owed. ctx.enqueue() below is the verb that survives that.
  • A failed call runs nothing, even where the write itself committed (no transactional:). The effect follows the call's verdict, which is the same condition the real-time announcement uses.

ctx.enqueue() — the effect that must not be lost

afterCommit buys ordering. It buys nothing against a crash: the process dies between the commit and the callback, and the email is simply never sent — with no row anywhere saying it was owed.

ctx.enqueue(job, payload) writes an intent into your own database inside the call's own transaction, so it commits with the write or rolls back with it. A relay hands it to app.jobs afterwards and marks it delivered.

fli outbo