FrontierJS

Junction

API · Service@frontierjs/junction

Name a model and its CRUD, validation and authorization come with it.

$ npm i @frontierjs/junction

Services

Five methods plus named actions

A service is the unit, not a route. It exposes find, get, create, patch and remove, plus any custom action — callable over HTTP, over WebSocket, or in-process.

createService({ name: 'invoices', model: 'invoice',
  async markPaid(ctx) { … },
})

// same service, three ways in:
GET /api/invoices
ws  { service: 'invoices', method: 'find' }
app.service('invoices').call('markPaid', id)

Replaces

Express routersFastify pluginsNestJS controllers

Hooks

around · before · after · error

Four phases, per method or for all of them, at app or service level. around wraps everything including failures, which is where timing and tracing belong.

hooks: {
  around: { all:    [timing] },
  before: { create: [setOwner] },
  after:  { create: [broadcast] },
  error:  { all:    [report] },
}

Replaces

Express middlewareFastify hooksNestJS interceptors

Context

One shape: auth · client · route · locals · query

Every hook and every method receives the same object, with the caller, the parsed query, the result and the error in known places — not a request object you decorate by convention.

Replaces

req/res + res.locals conventions

Envelope

Lists keep { data, total }; singles unwrap

One module decides the response shape for the entire framework, so pagination metadata is always in the same place and a single record is never wrapped for no reason.

GET /api/invoices     → { object: "list", data: [...], total: 17 }
GET /api/invoices/8   → { id: 8, total: 500 }

Replaces

hand-rolled response wrappers

Transport

HTTP and WebSocket over one bridge

Both protocols enter through the same boundary and produce the same Context, so a service does not know or care which one a caller used.

Replaces

Express + Socket.IO wired separately

Directives

$limit $offset $orderBy $select, parsed at the edge

Result shaping is transport syntax, split from filters once at the boundary. Past that line nothing sees a $ — services receive clean filters and a directives object.

GET /api/invoices?status=open&$limit=20&$orderBy=-createdAt

ctx.query      → { status: "open" }
ctx.directives → { limit: 20, orderBy: "-createdAt" }

Replaces

query-string parsersmanual pagination plumbing

Security

CORS · CSRF · headers · rate limiting

The usual middleware stack, configured rather than assembled, and ordered correctly by default — preflights short-circuit before CSRF, which is easy to get wrong by hand.

Replaces

helmetcorscsurfexpress-rate-limit

Channels

One announcement, two fan-outs, gated on the way out

Declare channel: on a service and every mutation announces once, reaching both the event bus and WebSocket subscribers. Gates still apply, so nobody is pushed a row they could not fetch.

createService({ name: 'invoices', channel: 'invoices' })

// subscribers receive:
{ type: 'invoices created', data: { id: 9 } }

Replaces

Socket.IO rooms + manual emit calls

Errors

Named classes; data denials normalize into them

Throw BadRequest or NotFound anywhere and the transport serializes it correctly. A data-layer denial becomes a 403 with the same shape, so clients match one contract.

Replaces

http-errorsBoomad-hoc error middleware

Client

Typed resources and live subscriptions in the browser

The same service API from the other side of the wire, including subscriptions and session handling — not a fetch wrapper you maintain alongside the server.

Replaces

axios wrappersgenerated API clients

Testing

Real apps, real pipelines, no port binding

Boot an app in-process, send real requests through the full hook pipeline, and stub only auth. Tests exercise the code that runs in production.

const app = await createTestApp({ db, auth: createStubAuth() })
await request(app).post('/api/invoices').send({ total: 5 })

Replaces

supertest + mock layers