@nifrajs/cli 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/check.d.ts +100 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +690 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +208 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs-search.d.ts +30 -0
- package/dist/docs-search.d.ts.map +1 -0
- package/dist/docs-search.js +94 -0
- package/dist/docs-search.js.map +1 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +291 -0
- package/dist/doctor.js.map +1 -0
- package/dist/examples.d.ts +30 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/introspect.d.ts +64 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +243 -0
- package/dist/introspect.js.map +1 -0
- package/dist/load.d.ts +51 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +70 -0
- package/dist/load.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +13 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -0
- package/dist/mcp-docs-tools.js +63 -0
- package/dist/mcp-docs-tools.js.map +1 -0
- package/dist/mcp-http.d.ts +43 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +136 -0
- package/dist/mcp-http.js.map +1 -0
- package/dist/mcp-protocol.d.ts +97 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +248 -0
- package/dist/mcp-protocol.js.map +1 -0
- package/dist/mcp-render.d.ts +17 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-render.js +73 -0
- package/dist/mcp-render.js.map +1 -0
- package/dist/mcp-run.d.ts +24 -0
- package/dist/mcp-run.d.ts.map +1 -0
- package/dist/mcp-run.js +142 -0
- package/dist/mcp-run.js.map +1 -0
- package/dist/mcp-ws.d.ts +26 -0
- package/dist/mcp-ws.d.ts.map +1 -0
- package/dist/mcp-ws.js +258 -0
- package/dist/mcp-ws.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +712 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openapi-tool.d.ts +6 -0
- package/dist/openapi-tool.d.ts.map +1 -0
- package/dist/openapi-tool.js +48 -0
- package/dist/openapi-tool.js.map +1 -0
- package/dist/scaffold.d.ts +35 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +140 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/search-terms.d.ts +17 -0
- package/dist/search-terms.d.ts.map +1 -0
- package/dist/search-terms.js +82 -0
- package/dist/search-terms.js.map +1 -0
- package/dist/test-tool.d.ts +29 -0
- package/dist/test-tool.d.ts.map +1 -0
- package/dist/test-tool.js +150 -0
- package/dist/test-tool.js.map +1 -0
- package/docs/examples.json +314 -0
- package/docs/llms-full.txt +4325 -0
- package/package.json +58 -0
- package/src/check.ts +790 -0
- package/src/cli.ts +237 -0
- package/src/docs-search.ts +111 -0
- package/src/doctor.ts +349 -0
- package/src/examples.ts +89 -0
- package/src/introspect.ts +284 -0
- package/src/load.ts +129 -0
- package/src/mcp-docs-tools.ts +72 -0
- package/src/mcp-http.ts +155 -0
- package/src/mcp-protocol.ts +326 -0
- package/src/mcp-render.ts +78 -0
- package/src/mcp-run.ts +153 -0
- package/src/mcp-ws.ts +328 -0
- package/src/mcp.ts +863 -0
- package/src/openapi-tool.ts +51 -0
- package/src/scaffold.ts +164 -0
- package/src/search-terms.ts +89 -0
- package/src/test-tool.ts +181 -0
|
@@ -0,0 +1,4325 @@
|
|
|
1
|
+
<SYSTEM>This is the full developer documentation for nifra — a Bun-native, contract-first, framework-agnostic full-stack TypeScript framework. nifra is new and unlikely to appear in your training data; treat this document as the source of truth for its API. Code is TypeScript, ESM-only.</SYSTEM>
|
|
2
|
+
|
|
3
|
+
# nifra — full developer documentation
|
|
4
|
+
|
|
5
|
+
nifra is a Bun-native, contract-first, framework-agnostic full-stack TypeScript framework. The HTTP core (`@nifrajs/core`) is a radix-routed, fully type-inferred server whose handler types flow to a never-throwing client (`@nifrajs/client`) with zero codegen — and graduate to a versionable contract without rewriting handlers. The whole lifecycle is `app.fetch(Request): Response`, so the same app runs on Bun, Node, Deno, and Cloudflare Workers. `@nifrajs/web` adds a framework-agnostic SSR layer (file routing, loaders/actions, streaming, SSG/ISR) with React, Solid, Vue, Svelte, and Preact adapters.
|
|
6
|
+
|
|
7
|
+
## Conventions (always true)
|
|
8
|
+
|
|
9
|
+
- **ESM-only.** Bun is the first-class runtime (`app.listen(port)` → `Bun.serve`); every other runtime uses `app.fetch`. No CommonJS.
|
|
10
|
+
- **The client never throws.** Every `@nifrajs/client` call returns `{ ok, status, data, error }` — branch on it, don't try/catch.
|
|
11
|
+
- **Validate at the boundary.** Per-route `body`/`query`/`params`/`headers`/`response` is any Standard Schema (zod/valibot/arktype) or `@nifrajs/schema`'s `t`; invalid input → structured `400` before the handler runs.
|
|
12
|
+
- **Secure by default.** Body-size cap, `requestTimeoutMs` + `c.signal`, graceful shutdown, redacting logger, same-origin `redirect()`, constant-time secret comparison, fail-closed middleware.
|
|
13
|
+
- **Money** in integer minor units; **time** parsed to absolute UTC at the boundary.
|
|
14
|
+
- Throwing a `Response` anywhere in the lifecycle is control flow (returned as-is), not an error.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Guides
|
|
19
|
+
|
|
20
|
+
_Extracted from the docs at nifra.dev/docs._
|
|
21
|
+
|
|
22
|
+
## Nifra — Getting started
|
|
23
|
+
|
|
24
|
+
> Get started with Nifra: install, server, typed client, loaders, deploy.
|
|
25
|
+
|
|
26
|
+
Nifra is a contract-first TypeScript framework. Start with just a typed backend — like Hono or
|
|
27
|
+
Elysia — and the client infers its types with zero codegen. Add a frontend only when you need
|
|
28
|
+
one: the same route model then drives SSR across React, Solid, Vue, Preact, and Svelte, on
|
|
29
|
+
Bun, Node, Deno, and the edge.
|
|
30
|
+
|
|
31
|
+
### Install
|
|
32
|
+
|
|
33
|
+
`bun add @nifrajs/core`
|
|
34
|
+
|
|
35
|
+
### A server — no frontend required
|
|
36
|
+
|
|
37
|
+
Chainable and fully type-inferred. This is a complete app: `@nifrajs/core` alone is a
|
|
38
|
+
production backend (routing, validation, middleware, auth, WebSockets). Run it on Bun with
|
|
39
|
+
`.listen()`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { server } from "@nifrajs/core"
|
|
43
|
+
|
|
44
|
+
server()
|
|
45
|
+
.get("/", () => ({ hello: "world" }))
|
|
46
|
+
.get("/users/:id", (c) => ({ id: c.params.id }))
|
|
47
|
+
.listen(3000)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### An end-to-end-typed client
|
|
51
|
+
|
|
52
|
+
The server's types flow to the client — no schema duplication, no codegen — behind a
|
|
53
|
+
never-throwing `{ data, error }` result.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { client } from "@nifrajs/client"
|
|
57
|
+
import type { app } from "./server"
|
|
58
|
+
|
|
59
|
+
// The client infers the server's types — no codegen. Never throws: { data, error }.
|
|
60
|
+
const api = client<typeof app>("http://localhost:3000")
|
|
61
|
+
const { data, error } = await api.users({ id: "7" }).get()
|
|
62
|
+
// ^? { id: string } | undefined
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Loaders & the full stack
|
|
66
|
+
|
|
67
|
+
Route loaders call your backend in-process during SSR; `actions` handle
|
|
68
|
+
mutations. Add streaming, `defer()`, optimistic UI, and a keyed query cache as
|
|
69
|
+
you grow. The data model is framework-agnostic, so the renderer stays replaceable.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// A route's loader runs on the server (in-process during SSR, no network),
|
|
73
|
+
// fully typed against your contract.
|
|
74
|
+
export async function loader({ api }: LoaderArgs<typeof app>) {
|
|
75
|
+
const res = await api.users({ id: "7" }).get()
|
|
76
|
+
return { user: res.data }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function Page(props: { data: LoaderData<typeof loader> }) {
|
|
80
|
+
return <h1>{props.data.user?.id}</h1>
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Deploy anywhere
|
|
85
|
+
|
|
86
|
+
-
|
|
87
|
+
**Bun** — `app.listen()` (native).
|
|
88
|
+
|
|
89
|
+
-
|
|
90
|
+
**Node / Deno** — the `@nifrajs/node` / `@nifrajs/deno` adapters.
|
|
91
|
+
|
|
92
|
+
-
|
|
93
|
+
**Cloudflare Workers / Pages** — edge build via `buildServer` +
|
|
94
|
+
`toFetchHandler` (the exact way this self-hosted site is compiled and served).
|
|
95
|
+
|
|
96
|
+
See the [benchmarks](/benchmarks) for how it performs.
|
|
97
|
+
|
|
98
|
+
## Nifra vs. other frameworks
|
|
99
|
+
|
|
100
|
+
> How Nifra compares to the full-stack frameworks (Next.js, Nuxt, SvelteKit, Remix, TanStack Start) — and, as a standalone backend, to Hono and Elysia. Five UI frameworks, five runtimes, end-to-end types, and an AI-agent toolchain no competitor ships.
|
|
101
|
+
|
|
102
|
+
Honest comparison. Nifra is a **full-stack framework first** — five UI libraries and five runtimes
|
|
103
|
+
from one core, with loaders, actions, streaming, and a typed data layer — and a
|
|
104
|
+
**standalone typed backend** you can use on its own. Here is where each tool fits.
|
|
105
|
+
|
|
106
|
+
### Nifra's lane
|
|
107
|
+
|
|
108
|
+
Every other full-stack framework here is **one UI library on a mostly-one-runtime story**. Nifra
|
|
109
|
+
bets the opposite axis: one core, many front-ends, many runtimes, with a contract-first typed backend
|
|
110
|
+
in the same project.
|
|
111
|
+
|
|
112
|
+
Framework
|
|
113
|
+
UI libraries
|
|
114
|
+
Runtimes
|
|
115
|
+
|
|
116
|
+
Nifra
|
|
117
|
+
React · Preact · Vue · Solid · Svelte
|
|
118
|
+
Bun · Node · Deno · Workers · Edge
|
|
119
|
+
|
|
120
|
+
Next.js
|
|
121
|
+
React
|
|
122
|
+
Node · Vercel Edge
|
|
123
|
+
|
|
124
|
+
Nuxt 3
|
|
125
|
+
Vue
|
|
126
|
+
Nitro (many)
|
|
127
|
+
|
|
128
|
+
SvelteKit
|
|
129
|
+
Svelte
|
|
130
|
+
adapters
|
|
131
|
+
|
|
132
|
+
Remix / RR7
|
|
133
|
+
React
|
|
134
|
+
adapters
|
|
135
|
+
|
|
136
|
+
TanStack Start
|
|
137
|
+
React (Solid WIP)
|
|
138
|
+
Nitro
|
|
139
|
+
|
|
140
|
+
### Full-stack parity
|
|
141
|
+
|
|
142
|
+
Across the board, Nifra ships the modern full-stack feature set on all five UI frameworks:
|
|
143
|
+
|
|
144
|
+
- File routing (dynamic / catch-all / groups / optional) + nested layouts.
|
|
145
|
+
|
|
146
|
+
-
|
|
147
|
+
SSR · SSG · ISR, streaming SSR with out-of-order Suspense, islands, view transitions, and a
|
|
148
|
+
~0 KB-client vanilla adapter.
|
|
149
|
+
|
|
150
|
+
-
|
|
151
|
+
Loaders + actions + progressive-enhancement forms; `defer()` / `<Await>`
|
|
152
|
+
streaming data; query cache, optimistic UI, concurrent fetchers, revalidation.
|
|
153
|
+
|
|
154
|
+
- Head/meta, hover/focus prefetch, scroll restoration.
|
|
155
|
+
|
|
156
|
+
-
|
|
157
|
+
First-party auth, i18n, image, uploads — plus content collections + MDX, font optimization, and
|
|
158
|
+
draft / preview mode.
|
|
159
|
+
|
|
160
|
+
The data model (loaders, actions, `defer`, progressive enhancement, fetchers,
|
|
161
|
+
revalidation) is closest to **Remix / React Router** — delivered across five UI libraries instead
|
|
162
|
+
of one.
|
|
163
|
+
|
|
164
|
+
### The line Nifra draws: no React Server Components
|
|
165
|
+
|
|
166
|
+
Nifra is classic **SSR + hydration** with streaming and islands — not RSC. No
|
|
167
|
+
`"use server"` / `"use client"`, no server-only component tree. That is the one
|
|
168
|
+
thing **Next.js App Router** has that Nifra doesn't, and it's deliberate: RSC is React-specific and
|
|
169
|
+
would break a core that also serves Vue, Solid, and Svelte. (Nuxt, SvelteKit, and Remix don't ship RSC
|
|
170
|
+
either — this is _Nifra vs. Next App Router specifically_.) Typed loaders, `defer()`,
|
|
171
|
+
and route-level code-splitting cover the same problems by a different mechanism.
|
|
172
|
+
|
|
173
|
+
### Also a standalone backend (vs. Hono & Elysia)
|
|
174
|
+
|
|
175
|
+
Nifra's core is a Bun-native, Web-standard server, so it stands on its own as an API — and graduates
|
|
176
|
+
to full-stack later without a rewrite.
|
|
177
|
+
|
|
178
|
+
-
|
|
179
|
+
**Throughput — the realistic case.** Router micro-benchmarks flatter Hono (a single compiled
|
|
180
|
+
regex), but a router is **~1% of a real request** — the time goes to middleware, validation,
|
|
181
|
+
context, and serialization. In the bare Bun HTTP matrix, Nifra sits close to raw Bun and behind
|
|
182
|
+
Elysia on most GET rows; in the current realistic preview (security headers + CORS + bearer auth +
|
|
183
|
+
cookies + validated query/body + a ~3 KB JSON response, measured with `oha`), it
|
|
184
|
+
edges Elysia. Treat benchmark rows as same-run evidence, not a permanent law of nature.
|
|
185
|
+
|
|
186
|
+
-
|
|
187
|
+
**End-to-end types.** `client<typeof app>()` derives request inputs _and_
|
|
188
|
+
`res.data` from the route contracts — the compiler catches frontend/backend drift. Hono's
|
|
189
|
+
`hc` and Elysia's Eden are typed too, but backend-only — no full-stack page/loader story.
|
|
190
|
+
|
|
191
|
+
-
|
|
192
|
+
**Validation + OpenAPI.** Any Standard Schema plus `t` (TypeBox — free JSON Schema),
|
|
193
|
+
emitting a real 3.1 doc with field-level request/response schemas (+ Scalar UI).
|
|
194
|
+
|
|
195
|
+
-
|
|
196
|
+
**Batteries.** `@nifrajs/better-auth` + session guards, `@nifrajs/otel` (W3C
|
|
197
|
+
`traceparent`, OTel semantic conventions), and `create-nifra` scaffolding
|
|
198
|
+
(framework × deploy × CI × DB × auth, with an `AGENTS.md`).
|
|
199
|
+
|
|
200
|
+
**Run it yourself:** `bun run bench:realworld` and
|
|
201
|
+
`bun run bench:http:compare`.
|
|
202
|
+
|
|
203
|
+
### The AI-agent toolchain — Nifra-only
|
|
204
|
+
|
|
205
|
+
No competitor — full-stack or backend — ships this. Every Nifra app is built to be edited by AI agents
|
|
206
|
+
accurately:
|
|
207
|
+
|
|
208
|
+
-
|
|
209
|
+
`nifra mcp` — an MCP server exposing `nifra_context` (the project's typed
|
|
210
|
+
surface), `nifra_example` (snippets typechecked against the installed version — no
|
|
211
|
+
hallucinated APIs), `nifra_scaffold` (URL → correct `routes/` file),
|
|
212
|
+
`nifra_run` (verify via HTTP), and `nifra_check` (a drift gate that returns the
|
|
213
|
+
fix).
|
|
214
|
+
|
|
215
|
+
-
|
|
216
|
+
`llms.txt` + `llms-full.txt` served at the site root, an `AGENTS.md`
|
|
217
|
+
in every scaffold, and a docs corpus that can't drift from the code.
|
|
218
|
+
|
|
219
|
+
### Where Nifra fits
|
|
220
|
+
|
|
221
|
+
Reach for Nifra when you want:
|
|
222
|
+
|
|
223
|
+
-
|
|
224
|
+
**One codebase, every front-end + runtime** — ship the same app on React, Vue, Solid,
|
|
225
|
+
Svelte, or Preact, running on Bun, Node, Deno, and the edge, with no rewrite to switch either.
|
|
226
|
+
|
|
227
|
+
-
|
|
228
|
+
**A type-locked stack** — the typed client derives requests and `res.data` from
|
|
229
|
+
your route contracts, so the compiler catches any frontend/backend drift.
|
|
230
|
+
|
|
231
|
+
-
|
|
232
|
+
**The full modern toolkit on your UI of choice** — loaders, actions, streaming SSR with
|
|
233
|
+
out-of-order Suspense, SSG/ISR, islands, query cache, progressive-enhancement forms.
|
|
234
|
+
|
|
235
|
+
-
|
|
236
|
+
**A framework AI agents edit accurately** — the `nifra mcp` toolchain + verified,
|
|
237
|
+
version-checked examples, which no other framework ships.
|
|
238
|
+
|
|
239
|
+
-
|
|
240
|
+
**To start lean and grow** — begin as a typed API, graduate to full-stack on the same core,
|
|
241
|
+
no rewrite.
|
|
242
|
+
|
|
243
|
+
The one deliberate trade: Nifra is streaming SSR + islands, **not** React Server Components —
|
|
244
|
+
framework-agnostic by design. If your app is built specifically around RSC, that's the call
|
|
245
|
+
you're making.
|
|
246
|
+
|
|
247
|
+
## Nifra — Routing
|
|
248
|
+
|
|
249
|
+
> File-based routing in Nifra: conventions, params, nested layouts.
|
|
250
|
+
|
|
251
|
+
Routes are files under `routes/`. The file path is the URL — no route config to
|
|
252
|
+
maintain.
|
|
253
|
+
|
|
254
|
+
### Conventions
|
|
255
|
+
|
|
256
|
+
-
|
|
257
|
+
`index.tsx` → the parent path; `about.tsx` → `/about`.
|
|
258
|
+
|
|
259
|
+
-
|
|
260
|
+
`[id].tsx` → a dynamic segment `:id` (read via `c.params.id`
|
|
261
|
+
/ the loader).
|
|
262
|
+
|
|
263
|
+
-
|
|
264
|
+
`[...path].tsx` → a **catch-all** capturing the rest of the URL into one param (
|
|
265
|
+
`params.path` = `"a/b/c"`). Must be the last segment; matches one or more
|
|
266
|
+
segments (so `/files` won't match `/files/[...path]`).
|
|
267
|
+
|
|
268
|
+
-
|
|
269
|
+
`[[lang]].tsx` → an **optional segment**: it matches both with and without the
|
|
270
|
+
segment. `[[lang]]/about.tsx` serves `/about` (
|
|
271
|
+
`params.lang === undefined`) _and_ `/:lang/about` — handy for an
|
|
272
|
+
optional locale prefix. It expands to one route per combination, all sharing the page + layout
|
|
273
|
+
chain (so `n` optionals → `2ⁿ` patterns).
|
|
274
|
+
|
|
275
|
+
-
|
|
276
|
+
`(group)/` → a **route group**: the folder organizes routes (and can hold its own
|
|
277
|
+
`_layout.tsx`) without adding a URL segment — e.g.
|
|
278
|
+
`(marketing)/pricing.tsx` → `/pricing`.
|
|
279
|
+
|
|
280
|
+
-
|
|
281
|
+
`_layout.tsx` wraps its directory; nesting them builds a **layout chain**
|
|
282
|
+
(this docs sidebar is a nested layout).
|
|
283
|
+
|
|
284
|
+
-
|
|
285
|
+
`_404.tsx` renders unmatched paths.
|
|
286
|
+
|
|
287
|
+
-
|
|
288
|
+
`_error.tsx` is the segment's **error boundary**. On the server — if a route's
|
|
289
|
+
loader or shell render throws — the nearest `_error` (in the route's ancestor chain)
|
|
290
|
+
renders in its place, wrapped by the layouts at/above that segment, at status 500 (served
|
|
291
|
+
non-hydrated). On the **client** — a render error during navigation/interaction is caught by
|
|
292
|
+
the nearest boundary, which renders `_error` in place (all five adapters). It
|
|
293
|
+
receives the serialized error as `{ data: { name, message } }` (never the
|
|
294
|
+
stack); a thrown `Response` (e.g. a guard `redirect`) passes through.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
routes/
|
|
298
|
+
_layout.tsx wraps every page (chain: outer → inner)
|
|
299
|
+
_error.tsx error boundary (a loader throws → renders here, 500)
|
|
300
|
+
index.tsx → /
|
|
301
|
+
about.tsx → /about
|
|
302
|
+
users/
|
|
303
|
+
[id].tsx → /users/:id dynamic segment
|
|
304
|
+
files/
|
|
305
|
+
[...path].tsx → /files/*path catch-all (the rest of the path)
|
|
306
|
+
[[lang]]/ optional segment — matches WITH and WITHOUT it
|
|
307
|
+
docs.tsx → /docs AND /:lang/docs
|
|
308
|
+
(marketing)/ route group: organizes + can hold its own _layout,
|
|
309
|
+
_layout.tsx but contributes NO URL segment
|
|
310
|
+
pricing.tsx → /pricing
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### A route
|
|
314
|
+
|
|
315
|
+
Each route default-exports a component; an optional `meta` export drives
|
|
316
|
+
`<head>` (applied on SSR and on client navigation). Add a
|
|
317
|
+
`loader` for data — see [Loaders & actions](/docs/data).
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// routes/users/[id].tsx
|
|
321
|
+
export const meta = { title: "User" } // injected into <head> (SSR + client nav)
|
|
322
|
+
|
|
323
|
+
export default function User(props: { data: LoaderData<typeof loader> }) {
|
|
324
|
+
return <h1>User {props.data.id}</h1>
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Catch-all routes
|
|
329
|
+
|
|
330
|
+
A `[...name].tsx` segment matches the rest of the path and hands it to your loader as
|
|
331
|
+
a single string param — ideal for docs/CMS trees, file browsers, or a custom fallback. It must
|
|
332
|
+
be the final segment.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
// routes/files/[...path].tsx → matches /files/a, /files/a/b/c.txt, …
|
|
336
|
+
export async function loader({ params }) {
|
|
337
|
+
const path = params.path // "a/b/c.txt" — the matched tail, as one string
|
|
338
|
+
return { file: await read(path) }
|
|
339
|
+
}
|
|
340
|
+
// A catch-all needs ≥1 segment (/files alone won't match) and must be the last segment.
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Nifra — API & typed client
|
|
344
|
+
|
|
345
|
+
> Build a typed JSON API with server()/defineContract + validate inputs with any Standard Schema, then consume it from a zero-codegen, never-throwing typed client.
|
|
346
|
+
|
|
347
|
+
Nifra is **contract-first**: you describe an HTTP API once — inline or as a standalone
|
|
348
|
+
contract — and its types flow to the client with zero codegen. Inputs are validated at the
|
|
349
|
+
trust boundary by any [Standard Schema](https://standardschema.dev) (zod, valibot,
|
|
350
|
+
arktype, …); outputs are inferred end-to-end.
|
|
351
|
+
|
|
352
|
+
Everything on this page is just `@nifrajs/core` — no frontend, no build step. Use Nifra
|
|
353
|
+
as a standalone backend the way you'd use Hono or Elysia, deploy it to any runtime, and reach
|
|
354
|
+
for [the frontend adapters](/docs/frameworks) only if and when you go full-stack.
|
|
355
|
+
|
|
356
|
+
### An inline server
|
|
357
|
+
|
|
358
|
+
The chainable builder is the quickest start. Attach a `body` or `query`
|
|
359
|
+
schema to a route and it's parsed-and-validated before your handler runs — `c.body`
|
|
360
|
+
and `c.query` are the _validated_ types, and a bad request gets a structured
|
|
361
|
+
`422` automatically. Path params (`:id`) are typed from the pattern.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { server } from "@nifrajs/core"
|
|
365
|
+
import { z } from "zod" // any Standard Schema works: zod, valibot, arktype…
|
|
366
|
+
|
|
367
|
+
export const app = server()
|
|
368
|
+
.get("/users/:id", (c) => ({ id: c.params.id })) // c.params is typed from the path
|
|
369
|
+
.post("/users", { body: z.object({ name: z.string().min(1) }) }, // body validated at the boundary
|
|
370
|
+
(c) => ({ created: c.body.name })) // c.body is the validated type
|
|
371
|
+
.get("/search", { query: z.object({ page: z.string() }) }, // query validated too
|
|
372
|
+
(c) => ({ page: c.query.page }))
|
|
373
|
+
.listen(3000)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Status, headers & cookies (c.set)
|
|
377
|
+
|
|
378
|
+
Return a plain object and Nifra serializes it with a `200` (or `204` when
|
|
379
|
+
you return `undefined`). To shape the response _without_ giving up the typed
|
|
380
|
+
return, use `c.set`: assign `c.set.status`, mutate
|
|
381
|
+
`c.set.headers`, or call `c.set.cookie(name, value, opts?)` — cookies are
|
|
382
|
+
**HttpOnly + Secure + SameSite=Lax + Path=/** by default, and `c.set.deleteCookie(name)`
|
|
383
|
+
expires one. It's lazy: a handler that never touches `c.set` allocates nothing.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
export const app = server()
|
|
387
|
+
.post("/login", { body: z.object({ email: z.string() }) }, (c) => {
|
|
388
|
+
c.set.status = 201 // override the default 200 (204 when you return undefined)
|
|
389
|
+
c.set.headers["x-request-id"] = reqId // add/override a response header
|
|
390
|
+
c.set.cookie("session", token, { // HttpOnly + Secure + SameSite=Lax + Path=/ by default
|
|
391
|
+
maxAge: 60 * 60 * 24,
|
|
392
|
+
})
|
|
393
|
+
return { ok: true } // still a plain object — the typed client stays in sync
|
|
394
|
+
})
|
|
395
|
+
.post("/logout", (c) => {
|
|
396
|
+
c.set.deleteCookie("session") // expire it immediately
|
|
397
|
+
return { ok: true }
|
|
398
|
+
})
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Prefer `c.set` over returning a raw `Response`. A `Response`
|
|
402
|
+
return makes the typed client infer `data: never`, so you silently lose drift
|
|
403
|
+
detection for that route (`nifra check` flags it). `c.set` keeps your
|
|
404
|
+
plain-object return fully typed.
|
|
405
|
+
|
|
406
|
+
### Contract-first (defineContract + implement)
|
|
407
|
+
|
|
408
|
+
For larger apps — or when the contract is shared across services — declare it with
|
|
409
|
+
`defineContract` (methods, paths, schemas; no handlers), then
|
|
410
|
+
`implement` it. Handlers are checked against the contract, so a wrong path param,
|
|
411
|
+
body, or return type is a compile error. The result is the same `app` the inline
|
|
412
|
+
builder produces.
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import { defineContract, implement } from "@nifrajs/core"
|
|
416
|
+
import { z } from "zod"
|
|
417
|
+
|
|
418
|
+
// 1. Declare the contract — methods, paths, and input schemas, no handlers.
|
|
419
|
+
// Share this object between server and (optionally) other services.
|
|
420
|
+
export const contract = defineContract({
|
|
421
|
+
listUsers: { method: "GET", path: "/users" },
|
|
422
|
+
getUser: { method: "GET", path: "/users/:id" },
|
|
423
|
+
createUser:{ method: "POST", path: "/users", body: z.object({ name: z.string() }) },
|
|
424
|
+
search: { method: "GET", path: "/search", query: z.object({ page: z.string() }) },
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// 2. Implement it — handlers are checked against the contract (path params, body, query all typed).
|
|
428
|
+
export const app = implement(contract, {
|
|
429
|
+
listUsers: () => users.all(),
|
|
430
|
+
getUser: (c) => users.find(c.params.id),
|
|
431
|
+
createUser:(c) => users.create(c.body.name),
|
|
432
|
+
search: (c) => ({ page: c.query.page }),
|
|
433
|
+
})
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### The end-to-end-typed client
|
|
437
|
+
|
|
438
|
+
`@nifrajs/client` takes the server's type (`client<typeof app>`) and
|
|
439
|
+
exposes a fluent, fully-typed proxy — no generated SDK. Path params are call arguments; the body
|
|
440
|
+
and query are typed from the route's schema.
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { client } from "@nifrajs/client"
|
|
444
|
+
import type { app } from "./server"
|
|
445
|
+
|
|
446
|
+
// Infers the server's types directly — no codegen, no schema duplication.
|
|
447
|
+
const api = client<typeof app>("https://api.example.com")
|
|
448
|
+
|
|
449
|
+
const { data } = await api.users({ id: "1" }).get() // path param → /users/1
|
|
450
|
+
await api.users.post({ name: "Ada" }) // POST body
|
|
451
|
+
await api.search.get({ query: { page: "3" } }) // query string
|
|
452
|
+
await api.users({ id: "1" }).posts({ postId: "2" }).get() // nested params
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Results never throw
|
|
456
|
+
|
|
457
|
+
Every call resolves to a discriminated `Result`: branch on `ok` (or
|
|
458
|
+
destructure `{ data, error }`). Success carries the typed `data`;
|
|
459
|
+
failure carries a structured `ApiError` — a stable `error` code plus
|
|
460
|
+
validation `issues` — and the HTTP `status`. No try/catch, no surprise
|
|
461
|
+
exceptions on a 404 or 422.
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
// The client NEVER throws — every call returns a discriminated Result:
|
|
465
|
+
const res = await api.users({ id: "1" }).get()
|
|
466
|
+
if (res.ok) {
|
|
467
|
+
res.data // ^? { id: string } (typed success body)
|
|
468
|
+
} else {
|
|
469
|
+
res.status // the HTTP status
|
|
470
|
+
res.error.error // a stable error code, e.g. "not_found"
|
|
471
|
+
res.error.issues // validation issues (message + path), when the body/query was rejected
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Or destructure { data, error } directly:
|
|
475
|
+
const { data, error } = await api.users.post({ name: "" }) // 422 if the schema rejects it
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
The same client runs in the browser and on the server. During SSR, a route's
|
|
479
|
+
[loader](/docs/data) calls it **in-process** (no network hop) via
|
|
480
|
+
`ctx.api`. Next: [file routing](/docs/routing),
|
|
481
|
+
[loaders & actions](/docs/data), and [plugins](/docs/plugins).
|
|
482
|
+
|
|
483
|
+
## Nifra — Loaders & actions
|
|
484
|
+
|
|
485
|
+
> Typed loaders and actions in Nifra: data on the server, mutations, revalidation.
|
|
486
|
+
|
|
487
|
+
Loaders fetch data on the server; actions mutate it. Both are typed against your contract,
|
|
488
|
+
and the client never throws — it returns `{ data, error }`.
|
|
489
|
+
|
|
490
|
+
### Loaders
|
|
491
|
+
|
|
492
|
+
A route's `loader` runs on the server and calls your backend in-process during
|
|
493
|
+
SSR — no network hop. Its return type flows to the component as `LoaderData`.
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
// A loader runs on the server — in-process during SSR (no network round-trip),
|
|
497
|
+
// fully typed against your backend contract.
|
|
498
|
+
export async function loader({ api }: LoaderArgs<typeof app>) {
|
|
499
|
+
const res = await api.users({ id: "7" }).get()
|
|
500
|
+
return { user: res.data } // serialized to the client for hydration
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Actions & revalidation
|
|
505
|
+
|
|
506
|
+
An `action` handles the route's POST. After a client-side submit the page's
|
|
507
|
+
loader **revalidates** (no full reload); with JS disabled the native form POST re-renders
|
|
508
|
+
— progressive enhancement, same code.
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
// An action handles a mutation (a POST). Progressive-enhancement: works with JS
|
|
512
|
+
// off (native form POST) and as a client submit (no full reload) with JS on.
|
|
513
|
+
export async function action({ api, request }: ActionArgs<typeof app>) {
|
|
514
|
+
const form = await request.formData()
|
|
515
|
+
await api.users.post({ name: String(form.get("name")) })
|
|
516
|
+
return { ok: true } // the loader revalidates automatically
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export default function Page(props: { data: LoaderData<typeof loader> }) {
|
|
520
|
+
return (
|
|
521
|
+
<form method="post">
|
|
522
|
+
<input name="name" />
|
|
523
|
+
<button type="submit">Create</button>
|
|
524
|
+
</form>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Content collections
|
|
530
|
+
|
|
531
|
+
A **content collection** turns a folder of Markdown into a typed, schema-validated data
|
|
532
|
+
source — no hand-rolled `readdir` + frontmatter parsing.
|
|
533
|
+
`defineCollection` (from `@nifrajs/content/fs`) validates each file's
|
|
534
|
+
frontmatter against a `t` schema (a typo'd field fails the build, not production) and
|
|
535
|
+
renders the Markdown body to HTML; `all()` / `get(slug)` return
|
|
536
|
+
fully-typed entries you read in a loader. Framework-agnostic — the rendered `html`
|
|
537
|
+
drops into any adapter (React `dangerouslySetInnerHTML`, Vue `v-html`,
|
|
538
|
+
Svelte `{@html}`).
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
// content.config.ts — a typed, validated collection over a folder of Markdown.
|
|
542
|
+
import { defineCollection } from "@nifrajs/content/fs"
|
|
543
|
+
import { t } from "@nifrajs/schema"
|
|
544
|
+
|
|
545
|
+
export const blog = defineCollection({
|
|
546
|
+
dir: "content/blog",
|
|
547
|
+
schema: t.object({ title: t.string(), date: t.string(), draft: t.boolean() }),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// a loader — typed + validated entries, no manual fs/frontmatter parsing:
|
|
551
|
+
export async function loader() {
|
|
552
|
+
const posts = (await blog.all()).filter((p) => !p.frontmatter.draft)
|
|
553
|
+
return { posts: posts.sort((a, b) => b.frontmatter.date.localeCompare(a.frontmatter.date)) }
|
|
554
|
+
}
|
|
555
|
+
// posts[0].frontmatter is { title; date; draft } (typed); posts[0].html is the rendered Markdown
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
For optimistic UI, concurrent fetchers, and a keyed query cache, the same primitives compose
|
|
559
|
+
on both React and Solid.
|
|
560
|
+
|
|
561
|
+
## Nifra — Optimistic UI & fetchers
|
|
562
|
+
|
|
563
|
+
> Optimistic updates and concurrent fetchers in Nifra: instant feedback, row-level mutations.
|
|
564
|
+
|
|
565
|
+
Beyond the per-route action, Nifra gives you optimistic updates and independent, concurrent
|
|
566
|
+
fetchers for snappy, granular mutations — agnostic across all five frameworks, each in its
|
|
567
|
+
idiom.
|
|
568
|
+
|
|
569
|
+
### Optimistic updates
|
|
570
|
+
|
|
571
|
+
While a submit is in flight, read the value back from the submission's `formData`
|
|
572
|
+
(on a fetcher, `fetcher.submission`; on a route, the `submission` prop)
|
|
573
|
+
and the UI reflects it immediately. When the action resolves the real data takes over, and a
|
|
574
|
+
failed submit **reverts** automatically — no manual rollback bookkeeping.
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
import { useFetcher } from "@nifrajs/web-react/fetcher"
|
|
578
|
+
|
|
579
|
+
// Optimistic UI: while a submit is in flight, read the expected value from the in-flight
|
|
580
|
+
// submission's FormData; the real data takes over when the action resolves, and a failed
|
|
581
|
+
// submit reverts automatically — no manual rollback.
|
|
582
|
+
const fetcher = useFetcher("todo:" + id)
|
|
583
|
+
const pending = fetcher.submission?.formData.get("done") // the value you just submitted
|
|
584
|
+
const done = pending != null ? pending === "true" : todo.done // optimistic value wins while pending
|
|
585
|
+
|
|
586
|
+
function toggle() {
|
|
587
|
+
const fd = new FormData()
|
|
588
|
+
fd.set("id", String(id))
|
|
589
|
+
fd.set("done", String(!done))
|
|
590
|
+
fetcher.submit("/todos", fd) // submit(actionPath, body) — runs the route's action
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Concurrent fetchers
|
|
595
|
+
|
|
596
|
+
`useFetcher(key)` (React) / `createFetcher(key)` (Solid) is an
|
|
597
|
+
independent submission state machine — perfect for row-level actions or side-channel loads
|
|
598
|
+
that shouldn't block navigation. `useFetchers()` exposes the live collection for
|
|
599
|
+
global pending indicators. After a mutation, targeted revalidation refreshes just the
|
|
600
|
+
affected data (via the `X-Nifra-Revalidate` header).
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
import { useFetcher, useFetchers } from "@nifrajs/web-react/fetcher"
|
|
604
|
+
|
|
605
|
+
// Concurrent fetchers: each keyed fetcher is its own state machine, so many rows
|
|
606
|
+
// mutate in parallel without clobbering each other. `pending` is the in-flight flag.
|
|
607
|
+
function Row({ id }: { id: string }) {
|
|
608
|
+
const f = useFetcher("row:" + id)
|
|
609
|
+
const save = () => { const fd = new FormData(); fd.set("id", id); f.submit("/rows", fd) }
|
|
610
|
+
return <button disabled={f.pending} onClick={save}>Save</button>
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// useFetchers() exposes the live collection — e.g. a global "saving…" indicator.
|
|
614
|
+
const anyPending = useFetchers().some((f) => f.snapshot().pending)
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
## Nifra — Rendering: SSG & ISR
|
|
618
|
+
|
|
619
|
+
> Prerender static routes, enumerate dynamic ones, and cache rendered pages with stale-while-revalidate — on every runtime including the edge.
|
|
620
|
+
|
|
621
|
+
Nifra renders on one framework-agnostic seam, so the same app can be server-rendered per
|
|
622
|
+
request (the default), **prerendered** to static files at build (SSG), or cached and served
|
|
623
|
+
**stale-while-revalidate** (ISR) — and every strategy works on Bun, Node, Deno, **and**
|
|
624
|
+
the edge.
|
|
625
|
+
|
|
626
|
+
### SSG — prerender at build
|
|
627
|
+
|
|
628
|
+
Opt a static route into prerendering with `export const prerender = true`. Its
|
|
629
|
+
loader runs at _build_ time (build-safe data only — no per-request cookies or secrets),
|
|
630
|
+
and the route is baked to a static `index.html` plus a `_data.json` the
|
|
631
|
+
client fetches on soft-navigation.
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
// A static route: render it to a static index.html at build time.
|
|
635
|
+
export const prerender = true
|
|
636
|
+
|
|
637
|
+
export async function loader({ api }: LoaderArgs<typeof app>) {
|
|
638
|
+
return { posts: (await api.posts.get()).data } // runs at BUILD (no per-request secrets)
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
For dynamic routes (`/posts/:slug`), enumerate the param sets with
|
|
643
|
+
`getStaticPaths`. `fallback` decides what happens to a path you didn't
|
|
644
|
+
list: `"ssr"` renders it on-demand (the natural hybrid), `"404"` means
|
|
645
|
+
only the listed paths exist.
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
// A dynamic route (/posts/:slug): enumerate which pages to prerender.
|
|
649
|
+
export async function getStaticPaths(): Promise<StaticPaths> {
|
|
650
|
+
const slugs = await loadAllSlugs()
|
|
651
|
+
return {
|
|
652
|
+
paths: slugs.map((slug) => ({ params: { slug } })),
|
|
653
|
+
fallback: "ssr", // an unlisted slug renders on-demand (the worker); "404" = only these exist
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
At build, `prerenderRoutes` drives the app's own `fetch` to render each
|
|
659
|
+
page to bytes — agnostic, because it sits above the adapter seam. The output is turnkey for a
|
|
660
|
+
hybrid CDN deploy: static files served by the edge, everything else falling through to the SSR
|
|
661
|
+
worker.
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
// build.ts — buildClient, then prerender opted-in routes to static HTML.
|
|
665
|
+
import { buildClient, prerenderRoutes, cloudflarePagesRoutes } from "@nifrajs/web/build"
|
|
666
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
667
|
+
|
|
668
|
+
await buildClient({ routesDir: "./routes", outDir: "./dist", clientModule: "@nifrajs/web-react/client" })
|
|
669
|
+
const { app } = await import("./server")
|
|
670
|
+
const { prerendered } = await prerenderRoutes({
|
|
671
|
+
app,
|
|
672
|
+
routes: discoverRoutes("./routes").routes,
|
|
673
|
+
outDir: "./dist", // writes <path>/index.html + <path>/_data.json per prerendered route
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// Hybrid deploy (Cloudflare Pages): serve prerendered HTML + _data.json from the CDN; everything
|
|
677
|
+
// else falls through to the SSR worker.
|
|
678
|
+
const paths = prerendered.map((p) => p.path)
|
|
679
|
+
Bun.write("./dist/_routes.json", JSON.stringify(cloudflarePagesRoutes({ prerendered: paths })))
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### ISR — cache with stale-while-revalidate
|
|
683
|
+
|
|
684
|
+
When data changes but not on every request, ISR gives static-like speed with background
|
|
685
|
+
freshness. `withISR` wraps the app: a cacheable page (a `GET` document,
|
|
686
|
+
`200`, `text/html`) is served from the store when fresh, served
|
|
687
|
+
**stale while a fresh copy regenerates behind it**, or rendered + stored on a miss.
|
|
688
|
+
Regeneration is single-flight per key, so a hot stale page regenerates once. Every response
|
|
689
|
+
carries an `x-nifra-isr: hit | stale | miss` header.
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
// server.ts — wrap the app with Incremental Static Regeneration.
|
|
693
|
+
import { createWebApp, withISR, MemoryCacheStore } from "@nifrajs/web"
|
|
694
|
+
|
|
695
|
+
const app = createWebApp({ adapter, manifest, clientEntry, api })
|
|
696
|
+
const store = new MemoryCacheStore() // dev / single-instance only
|
|
697
|
+
|
|
698
|
+
// GET text/html responses are cached + served stale-while-revalidate. Default freshness 60s.
|
|
699
|
+
const isr = withISR(app, { store, revalidate: 60, now: () => Date.now() })
|
|
700
|
+
Bun.serve({ fetch: (req) => isr(req) })
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Set a route's freshness with `export const revalidate` (seconds) — nifra emits it as
|
|
704
|
+
the `x-nifra-isr-revalidate` header, which the wrapper reads to set that page's TTL.
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
// A per-route freshness window (seconds) — overrides the wrapper default.
|
|
708
|
+
export const revalidate = 300 // this page is fresh for 5 min, then regenerates on the next hit
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### A shared store for production
|
|
712
|
+
|
|
713
|
+
`MemoryCacheStore` is per-instance — fine for dev, but it refuses to run under
|
|
714
|
+
`NODE_ENV=production` unless you opt in, because the cache and on-demand purges
|
|
715
|
+
wouldn't propagate across instances. In production use a shared, durable store. On Cloudflare,
|
|
716
|
+
`KVCacheStore` wraps a Workers KV namespace; any backend that fits the small
|
|
717
|
+
`CacheStore` interface (Redis, the Cache API) works too. Every read is validated
|
|
718
|
+
before it's trusted, so a corrupt or version-skewed entry is treated as a miss, not served
|
|
719
|
+
broken.
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
// worker.ts — production uses a SHARED store so the cache + purges hold across instances.
|
|
723
|
+
import { withISR, KVCacheStore, revalidateEndpoint } from "@nifrajs/web"
|
|
724
|
+
|
|
725
|
+
export default {
|
|
726
|
+
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
|
|
727
|
+
const store = new KVCacheStore(env.ISR_CACHE, { expirationTtl: 86_400 }) // Workers KV
|
|
728
|
+
if (new URL(req.url).pathname === "/__nifra/revalidate") {
|
|
729
|
+
return revalidateEndpoint({ store, secret: env.REVALIDATE_SECRET })(req)
|
|
730
|
+
}
|
|
731
|
+
const isr = withISR(app, { store, revalidate: 60, now: () => Date.now() })
|
|
732
|
+
// waitUntil keeps the worker alive while a stale page regenerates behind the response.
|
|
733
|
+
return isr(req, { env, waitUntil: (p) => ctx.waitUntil(p) })
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### On-demand revalidation
|
|
739
|
+
|
|
740
|
+
Purge a path the moment its data changes (a CMS webhook, an admin action) with
|
|
741
|
+
`revalidateEndpoint` — a `POST` that drops the cached entry so the next
|
|
742
|
+
request re-renders. The token is compared in constant time; a wrong or missing token is
|
|
743
|
+
`401`, a missing or relative path is `400`.
|
|
744
|
+
|
|
745
|
+
```ts
|
|
746
|
+
# On-demand revalidation: purge a path so the next request re-renders it.
|
|
747
|
+
curl -X POST 'https://example.com/__nifra/revalidate?path=/posts/hello' \
|
|
748
|
+
-H 'x-nifra-revalidate-token: $REVALIDATE_SECRET'
|
|
749
|
+
# → { "revalidated": "/posts/hello" } (the token is checked in constant time)
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Draft / preview mode
|
|
753
|
+
|
|
754
|
+
Let an editor preview unpublished content without exposing it to the world.
|
|
755
|
+
`enableDraft(c, secret)` sets a **signed, HttpOnly** cookie (gate the route
|
|
756
|
+
yourself — behind a login or a token, like Next's `draftMode()`); loaders then read
|
|
757
|
+
`ctx.draft` to fetch drafts, and `withISR` **bypasses the cache** for
|
|
758
|
+
that request — so the editor always renders fresh and a draft is never written to the public
|
|
759
|
+
cache. Pass the same `draftSecret` to `createWebApp` and
|
|
760
|
+
`withISR`; a forged or tampered cookie fails the constant-time signature check.
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
// 1. A route you gate yourself flips draft mode on for the editor.
|
|
764
|
+
import { enableDraft, disableDraft } from "@nifrajs/web"
|
|
765
|
+
|
|
766
|
+
app.get("/api/preview", async (c) => {
|
|
767
|
+
if (c.query.token !== env.PREVIEW_TOKEN) return new Response("nope", { status: 401 })
|
|
768
|
+
await enableDraft(c, env.DRAFT_SECRET) // signed, HttpOnly cookie
|
|
769
|
+
return redirect(String(c.query.to ?? "/"))
|
|
770
|
+
})
|
|
771
|
+
app.get("/api/preview/exit", (c) => (disableDraft(c), redirect("/")))
|
|
772
|
+
|
|
773
|
+
// 2. Loaders branch on ctx.draft to load unpublished content.
|
|
774
|
+
export async function loader({ api, draft }: LoaderArgs<typeof app>) {
|
|
775
|
+
return { post: (await api.posts.get({ query: { slug, includeDrafts: draft } })).data }
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// 3. Wire the SAME secret so loaders see ctx.draft + editors bypass the ISR cache.
|
|
779
|
+
createWebApp({ adapter, manifest, clientEntry, api, draftSecret: env.DRAFT_SECRET })
|
|
780
|
+
withISR(app, { store, revalidate: 60, now: () => Date.now(), draftSecret: env.DRAFT_SECRET })
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
### Fonts
|
|
784
|
+
|
|
785
|
+
Self-host your fonts (hotlinking a CDN is a privacy leak and an extra connection).
|
|
786
|
+
`fontFace()` generates a **CLS-safe** `@font-face` — it defaults to
|
|
787
|
+
`font-display: swap` and supports the `size-adjust` /
|
|
788
|
+
`ascent-override` metric overrides that stop the fallback→web-font layout shift; put
|
|
789
|
+
it in a CSS file your app imports. `fontPreload()` returns a
|
|
790
|
+
`<link rel="preload" as="font">` for a layout's `meta.link`, so the
|
|
791
|
+
file downloads with the document instead of waiting on CSS parse.
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
// fonts.css — a CLS-safe @font-face for a self-hosted font (the pipeline bundles + hashes it).
|
|
795
|
+
import { fontFace } from "@nifrajs/web"
|
|
796
|
+
export default fontFace({
|
|
797
|
+
family: "Inter",
|
|
798
|
+
src: [{ url: "/fonts/inter-var.woff2" }], // self-host it — never hotlink a CDN
|
|
799
|
+
weight: "100 900", // variable font
|
|
800
|
+
sizeAdjust: "100.06%", ascentOverride: "90%", // optional: stop fallback->web-font layout shift
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
// a root layout — preload the file so it downloads WITH the document (not after CSS parse):
|
|
804
|
+
import { fontPreload } from "@nifrajs/web"
|
|
805
|
+
export const meta = { link: [fontPreload({ href: "/fonts/inter-var.woff2" })] }
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### Which one?
|
|
809
|
+
|
|
810
|
+
Content that's the same for everyone and changes rarely → **SSG**. Content that changes
|
|
811
|
+
occasionally and can tolerate seconds-to-minutes of staleness → **ISR**. Per-request or
|
|
812
|
+
per-user content → plain **SSR** (the default). You can mix all three in one app, route by
|
|
813
|
+
route.
|
|
814
|
+
|
|
815
|
+
## Nifra — Streaming
|
|
816
|
+
|
|
817
|
+
> Streaming SSR, Suspense, and defer() in Nifra — on every runtime including the edge.
|
|
818
|
+
|
|
819
|
+
Nifra streams HTML as it renders — the shell goes out first, slow data fills in. It's a Web
|
|
820
|
+
`ReadableStream`, so it works on Bun, Node, Deno, **and** the edge (workerd).
|
|
821
|
+
|
|
822
|
+
### Suspense & defer()
|
|
823
|
+
|
|
824
|
+
Wrap slow data in `defer()` in the loader and render it through
|
|
825
|
+
`<Await>` (a Suspense boundary). The client receives the shell + a streamed
|
|
826
|
+
resolution, then hydrates — no waterfall, no blank screen.
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
// Send the page shell immediately; stream the slow part in when it resolves.
|
|
830
|
+
export async function loader({ api }: LoaderArgs<typeof app>) {
|
|
831
|
+
return {
|
|
832
|
+
user: (await api.users({ id: "7" }).get()).data, // awaited — in the shell
|
|
833
|
+
feed: defer(api.feed.get()), // deferred — streamed later
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export default function Page(props: { data: LoaderData<typeof loader> }) {
|
|
838
|
+
return (
|
|
839
|
+
<>
|
|
840
|
+
<h1>{props.data.user?.id}</h1>
|
|
841
|
+
<Await resolve={props.data.feed} fallback={<p>Loading feed…</p>}>
|
|
842
|
+
{(feed) => <Feed items={feed} />}
|
|
843
|
+
</Await>
|
|
844
|
+
</>
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
The same `defer()` works in actions and across client-side soft navigations (an
|
|
850
|
+
NDJSON stream settles the deferred values), and it's framework-agnostic — React
|
|
851
|
+
`<Suspense>` and Solid's streaming both drive it from one core.
|
|
852
|
+
|
|
853
|
+
### Server-Sent Events
|
|
854
|
+
|
|
855
|
+
For server push — live feeds, progress, notifications — `sse(c, run)` returns a
|
|
856
|
+
`text/event-stream` response a handler returns directly. Push frames with
|
|
857
|
+
`stream.send({})`; the connection stays open until `run`
|
|
858
|
+
resolves, you call `stream.close()`, or the client disconnects (
|
|
859
|
+
`stream.signal`). It's a Web `ReadableStream` too, so it runs on Bun,
|
|
860
|
+
Node, Deno, and the edge — no `new Function`, no per-runtime API.
|
|
861
|
+
|
|
862
|
+
```ts
|
|
863
|
+
import { sse } from "@nifrajs/core"
|
|
864
|
+
|
|
865
|
+
// A live feed — push events until the client disconnects.
|
|
866
|
+
app.get("/notifications", (c) =>
|
|
867
|
+
sse(c, (stream) => {
|
|
868
|
+
const off = notifications.subscribe((n) =>
|
|
869
|
+
stream.send({ event: "notification", id: n.id, data: JSON.stringify(n) }),
|
|
870
|
+
)
|
|
871
|
+
// Keep the connection open until the client leaves, then tear down.
|
|
872
|
+
return new Promise<void>((resolve) =>
|
|
873
|
+
stream.signal.addEventListener("abort", () => { off(); resolve() }, { once: true }),
|
|
874
|
+
)
|
|
875
|
+
}, { keepAlive: 15_000 }),
|
|
876
|
+
)
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
`event:`, `id:`, and `retry:` are supported (and CR/LF is
|
|
880
|
+
stripped from `event`/`id` to prevent frame injection); multi-line
|
|
881
|
+
`data` is split into multiple `data:` lines per the spec; and
|
|
882
|
+
`keepAlive` emits comment pings so idle proxies don't drop the connection.
|
|
883
|
+
|
|
884
|
+
## Nifra — Query cache
|
|
885
|
+
|
|
886
|
+
> Nifra's keyed query cache: useQuery / createQuery, dedup, staleness, invalidation.
|
|
887
|
+
|
|
888
|
+
For client-side data that isn't a route loader — lists, widgets, anything refetchable —
|
|
889
|
+
Nifra ships a keyed query cache (TanStack-Query-style), agnostic across React and Solid.
|
|
890
|
+
|
|
891
|
+
### useQuery / createQuery
|
|
892
|
+
|
|
893
|
+
`useQuery(key, fn)` (React) and `createQuery(key, fn)` (Solid) subscribe
|
|
894
|
+
a component to a cached, keyed query. Concurrent reads of the same key **dedup** into one
|
|
895
|
+
in-flight fetch; results are cached with a `staleTime`, a background refetch, and
|
|
896
|
+
bounded GC.
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
import { useQuery, useQueryClient } from "@nifrajs/web-react/query"
|
|
900
|
+
|
|
901
|
+
function Profile({ id }: { id: string }) {
|
|
902
|
+
// Keyed + cached. Concurrent useQuery's with the same key dedup into one fetch;
|
|
903
|
+
// results are cached, with staleTime + background refetch.
|
|
904
|
+
const { data, isPending, refetch } = useQuery(["user", id], () =>
|
|
905
|
+
fetch(`/api/users/${id}`).then((r) => r.json()),
|
|
906
|
+
)
|
|
907
|
+
if (isPending) return <p>Loading…</p>
|
|
908
|
+
return <h1>{data.name}</h1>
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function CreateUser() {
|
|
912
|
+
const qc = useQueryClient()
|
|
913
|
+
// After a mutation, invalidate by key (or prefix) — matching queries refetch.
|
|
914
|
+
return <button onClick={() => qc.invalidateQueries(["user"])}>Refresh</button>
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### Invalidation
|
|
919
|
+
|
|
920
|
+
After a mutation, `useQueryClient().invalidateQueries(key)` marks matching entries
|
|
921
|
+
stale (by exact key or array **prefix**) and refetches the mounted ones — no manual cache
|
|
922
|
+
surgery.
|
|
923
|
+
|
|
924
|
+
It's the same agnostic core under both adapters: the cache, dedup, and invalidation logic
|
|
925
|
+
live in `@nifrajs/web`; the bindings are thin `useSyncExternalStore` /
|
|
926
|
+
signal wrappers.
|
|
927
|
+
|
|
928
|
+
## Nifra — Frameworks
|
|
929
|
+
|
|
930
|
+
> One agnostic core, five UI frameworks: React, Solid, Vue, Preact, and Svelte — same loaders, streaming, islands, and routing, unchanged.
|
|
931
|
+
|
|
932
|
+
Nifra renders **five UI frameworks on one agnostic core**. The render seam, file-based
|
|
933
|
+
routes, typed loaders/actions, streaming, islands, prefetch, and the client router are
|
|
934
|
+
shared across adapters, while each page still uses its framework's normal component style.
|
|
935
|
+
|
|
936
|
+
### Scaffold any of them
|
|
937
|
+
|
|
938
|
+
`create-nifra`'s `--framework` flag scaffolds the multi-target SSR site
|
|
939
|
+
with the adapter, routes, build wiring, and deps for your pick. It composes with
|
|
940
|
+
`--deploy`, so one command gives you a framework + a default deploy target.
|
|
941
|
+
|
|
942
|
+
```ts
|
|
943
|
+
# Scaffold a multi-target SSR site in any of the five (react is the default):
|
|
944
|
+
bun create nifra my-app --framework solid # or react · preact · vue · svelte
|
|
945
|
+
|
|
946
|
+
# Composes with the deploy preset — pick a framework AND a default deploy target:
|
|
947
|
+
bun create nifra my-app --framework svelte --deploy vercel
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
### The adapters
|
|
951
|
+
|
|
952
|
+
Framework
|
|
953
|
+
Package
|
|
954
|
+
UI idiom
|
|
955
|
+
Build plugin
|
|
956
|
+
Hydration bundle
|
|
957
|
+
|
|
958
|
+
**Bundle** = the same minimal counter app, minified (not gzipped), for each framework —
|
|
959
|
+
see `examples/web-*`. Indicative payload, not a benchmark.
|
|
960
|
+
|
|
961
|
+
### Authoring routes
|
|
962
|
+
|
|
963
|
+
A route's `default` export is the component; its `loader`/
|
|
964
|
+
`action`/`meta` are named exports. Most adapters write `.tsx`,
|
|
965
|
+
but the compiled frameworks use their native single-file format — **Svelte**
|
|
966
|
+
`.svelte` (loader/meta in `<script module>`) and **Vue**
|
|
967
|
+
`.vue` SFCs (loader/meta in the plain `<script>`; the component in
|
|
968
|
+
`<script setup>` + `<template>`) — each compiled by its
|
|
969
|
+
package's Bun plugin (`@nifrajs/web-vue/plugin`, `@nifrajs/web-svelte/plugin`).
|
|
970
|
+
|
|
971
|
+
```ts
|
|
972
|
+
<!-- routes/index.vue — a nifra route authored as a Vue Single-File Component -->
|
|
973
|
+
<script lang="ts">
|
|
974
|
+
// The plain <script> carries nifra's route convention (server-only named exports):
|
|
975
|
+
export const meta = { title: "Home" }
|
|
976
|
+
export async function loader({ api }) {
|
|
977
|
+
const res = await api.count.get()
|
|
978
|
+
return { count: res.data?.count ?? 0 }
|
|
979
|
+
}
|
|
980
|
+
</script>
|
|
981
|
+
|
|
982
|
+
<script setup lang="ts">
|
|
983
|
+
defineProps(["data"]) // nifra passes the loader data in as `data`
|
|
984
|
+
</script>
|
|
985
|
+
|
|
986
|
+
<template>
|
|
987
|
+
<h1>Count: {{ data.count }}</h1>
|
|
988
|
+
</template>
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Component `<style scoped>` (Vue) and `<style>` (Svelte —
|
|
992
|
+
scoped by default) are compiled and scoped into the app stylesheet — no runtime, no FOUC — plus
|
|
993
|
+
CSS Modules and global imports. See [Dev & HMR → Styling](/docs/dev).
|
|
994
|
+
`examples/routing-vue-sfc` is a full SSR + hydration + client-nav demo.
|
|
995
|
+
|
|
996
|
+
### Same features, every framework
|
|
997
|
+
|
|
998
|
+
Every adapter gets Nifra's full feature set — not a subset:
|
|
999
|
+
|
|
1000
|
+
- Streaming SSR + hydration (islands), with component-level Suspense.
|
|
1001
|
+
|
|
1002
|
+
-
|
|
1003
|
+
File-based routing, nested layouts, typed `loader`/`action`, and
|
|
1004
|
+
progressive-enhancement forms (work with JS off).
|
|
1005
|
+
|
|
1006
|
+
-
|
|
1007
|
+
`<Await>` for deferred data (`defer()`), a keyed query cache (
|
|
1008
|
+
`useQuery`), and concurrent `useFetcher` mutations.
|
|
1009
|
+
|
|
1010
|
+
- Optimistic UI, targeted revalidation, hover-prefetch, and scroll restoration.
|
|
1011
|
+
|
|
1012
|
+
-
|
|
1013
|
+
True HMR in dev for all five (`@nifrajs/web/vite`) — see
|
|
1014
|
+
[Dev & HMR](/docs/dev).
|
|
1015
|
+
|
|
1016
|
+
### One line changes
|
|
1017
|
+
|
|
1018
|
+
The adapter is the only framework-specific choice on the server. Swap it and the entire app —
|
|
1019
|
+
routes, data, streaming — runs on a different framework, unchanged.
|
|
1020
|
+
|
|
1021
|
+
```ts
|
|
1022
|
+
// Server — pick an adapter. Everything else is identical across all five frameworks:
|
|
1023
|
+
// the same routes, loaders, actions, streaming, <Await>, fetchers, query cache.
|
|
1024
|
+
import { createWebApp } from "@nifrajs/web"
|
|
1025
|
+
import { reactAdapter } from "@nifrajs/web-react" // ← or web-solid · web-vue · web-preact · web-svelte
|
|
1026
|
+
|
|
1027
|
+
const app = createWebApp({
|
|
1028
|
+
adapter: reactAdapter, // the one line that changes per framework
|
|
1029
|
+
manifest,
|
|
1030
|
+
clientEntry, // the built client bundle (from buildClient's manifest)
|
|
1031
|
+
api,
|
|
1032
|
+
})
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
### Idiomatic, not lowest-common-denominator
|
|
1036
|
+
|
|
1037
|
+
The bindings share names and behaviour across frameworks, but each is expressed the native
|
|
1038
|
+
way — React/Preact hooks, Vue composables, Solid signals, Svelte stores.
|
|
1039
|
+
|
|
1040
|
+
```ts
|
|
1041
|
+
// The data primitives — same names + behaviour everywhere, each in the framework's idiom.
|
|
1042
|
+
// React / Preact — hooks:
|
|
1043
|
+
const fetcher = useFetcher("save") // fetcher.submit(...), fetcher.pending
|
|
1044
|
+
const q = useQuery(["todos"], loadTodos) // q.data, q.isFetching, q.refetch()
|
|
1045
|
+
|
|
1046
|
+
// Vue — composables (refs): const f = useFetcher("save"); f.state.value.pending
|
|
1047
|
+
// Solid — signals: const q = useQuery(...); q().data
|
|
1048
|
+
// Svelte — stores (read with $): const f = useFetcher("save"); $f.pending
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
### Deferred data behavior
|
|
1052
|
+
|
|
1053
|
+
React, Preact, and Solid **stream** a `<Await>` boundary — the fallback
|
|
1054
|
+
flushes in the SSR HTML, then the resolved content streams in (it works with JS off). Vue and
|
|
1055
|
+
Svelte resolve deferred data on the **client**, so JS-off users see the fallback for that
|
|
1056
|
+
deferred boundary. Put critical page data in the route `loader`; use
|
|
1057
|
+
`defer()` for slower, non-critical data.
|
|
1058
|
+
|
|
1059
|
+
### Dev HMR support
|
|
1060
|
+
|
|
1061
|
+
Dev HMR works for all five adapters through `createViteDevServer` and the
|
|
1062
|
+
framework's Vite plugin. React, Preact, and Vue preserve edited component state. Solid
|
|
1063
|
+
and Svelte update live without a full page reload, and the edited component may remount. Full
|
|
1064
|
+
matrix in [Dev & HMR](/docs/dev).
|
|
1065
|
+
|
|
1066
|
+
## Nifra — Plugins & middleware
|
|
1067
|
+
|
|
1068
|
+
> Extend Nifra with definePlugin (typed context, idempotent) + hook-bundle middleware. Official middleware: requestId, logger, etag, bearer, apiKey, basicAuth, jwt/jwks, csrf, ipRestriction, bodyLimit, cors, securityHeaders, rateLimit, compression, cacheControl, cache, prettyJson, timing, methodOverride, trailingSlash, language, poweredBy, combine, openapi, healthcheck, idempotency.
|
|
1069
|
+
|
|
1070
|
+
Nifra's plugin surface lives in the agnostic core, so it's the same on every runtime and
|
|
1071
|
+
framework. A plugin is a function over the app; middleware is a bundle of lifecycle hooks.
|
|
1072
|
+
Both apply with `app.use()`.
|
|
1073
|
+
|
|
1074
|
+
### The plugin convention
|
|
1075
|
+
|
|
1076
|
+
A **plugin** is `(app) => app` — it calls `use`/`derive`/
|
|
1077
|
+
`decorate` or registers routes, and returns the app. Because `derive` and
|
|
1078
|
+
`decorate` are type-threaded, any context a plugin adds is **typed on every handler
|
|
1079
|
+
defined after** `app.use(plugin)` — no extra generics. Wrap a plugin with
|
|
1080
|
+
`definePlugin(name, …)` to make it **idempotent**: applied twice (e.g. because two
|
|
1081
|
+
plugins both depend on it), it wires its hooks once.
|
|
1082
|
+
|
|
1083
|
+
```ts
|
|
1084
|
+
import { definePlugin } from "@nifrajs/core"
|
|
1085
|
+
|
|
1086
|
+
// A plugin is just (app) => app — call use/derive/decorate or register routes.
|
|
1087
|
+
// definePlugin adds a name so applying it twice (even transitively) is a no-op.
|
|
1088
|
+
export const auth = definePlugin("auth", (app) =>
|
|
1089
|
+
app.derive((c) => ({ user: verify(c.req) })), // adds c.user…
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
app
|
|
1093
|
+
.use(auth) // …threaded to every handler after this:
|
|
1094
|
+
.get("/me", (c) => ({ id: c.user.id })) // c.user is fully typed
|
|
1095
|
+
|
|
1096
|
+
// Inline plugins thread context too — no definePlugin needed for one-offs:
|
|
1097
|
+
app.use((a) => a.decorate("db", db).derive((c) => ({ now: Date.now() })))
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
### Route/hook plugins: keep types with defineIdentityPlugin
|
|
1101
|
+
|
|
1102
|
+
A plugin that registers routes or hooks but adds **no context type** — e.g. mounting an auth
|
|
1103
|
+
handler — should be built with `defineIdentityPlugin`, not `definePlugin`.
|
|
1104
|
+
It threads the app's _exact_ type through `use`, so the route registry (and the
|
|
1105
|
+
typed client derived from it) survives the plugin. A plain
|
|
1106
|
+
`definePlugin((app) => app)` infers `app` as
|
|
1107
|
+
`Server`, which collapses `use()`'s result — and your whole
|
|
1108
|
+
typed client — to `any`. `@nifrajs/better-auth` is built this way, so
|
|
1109
|
+
`server().use(betterAuth(auth)).get(...)` keeps every route typed.
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
import { defineIdentityPlugin, server } from "@nifrajs/core"
|
|
1113
|
+
|
|
1114
|
+
// A plugin that registers routes/hooks but adds NO context type is a type-IDENTITY plugin.
|
|
1115
|
+
// defineIdentityPlugin keeps app.use()'s return type EXACTLY the caller's server, so routes added
|
|
1116
|
+
// after .use() keep their types — and the typed client derived from them stays intact.
|
|
1117
|
+
export const audit = defineIdentityPlugin("audit", (app) =>
|
|
1118
|
+
app.onResponse((res) => res),
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
// /a AND /b stay fully typed across the .use():
|
|
1122
|
+
const api = server().get("/a", () => ({ a: 1 })).use(audit).get("/b", () => ({ b: 2 }))
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
### Lifecycle hooks
|
|
1126
|
+
|
|
1127
|
+
Plugins can attach five lifecycle hooks: `onRequest` (pre-routing, can
|
|
1128
|
+
short-circuit), `beforeHandle`/`afterHandle` (around the handler),
|
|
1129
|
+
`onError`, and `onResponse` (transform every response — success,
|
|
1130
|
+
error, 404). Hardening middleware uses the same hook model:
|
|
1131
|
+
|
|
1132
|
+
```ts
|
|
1133
|
+
import { cors, rateLimit, securityHeaders } from "@nifrajs/middleware"
|
|
1134
|
+
|
|
1135
|
+
// Hardening middleware is a hook bundle (context-agnostic) — same app.use():
|
|
1136
|
+
app
|
|
1137
|
+
.use(securityHeaders())
|
|
1138
|
+
.use(cors({ origins: ["https://app.example"] }))
|
|
1139
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
### Official plugins
|
|
1143
|
+
|
|
1144
|
+
`@nifrajs/middleware` seeds a few `definePlugin` plugins to build on:
|
|
1145
|
+
|
|
1146
|
+
```ts
|
|
1147
|
+
import { requestId, logger, etag } from "@nifrajs/middleware"
|
|
1148
|
+
|
|
1149
|
+
app
|
|
1150
|
+
.use(requestId()) // reuse/generate x-request-id → c.requestId (typed) + response header
|
|
1151
|
+
.use(logger()) // one structured line/request: { method, path, status, ms }
|
|
1152
|
+
.use(etag()) // content-hash ETag on GET 200s → 304 on matching If-None-Match
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
-
|
|
1156
|
+
`requestId()` — reuses an inbound `x-request-id` or generates one,
|
|
1157
|
+
threads it as `c.requestId`, and echoes the header.
|
|
1158
|
+
|
|
1159
|
+
-
|
|
1160
|
+
`logger()` — one structured line per request (method, path, status, duration);
|
|
1161
|
+
covers 404s and errors; route it to your own sink via `log`.
|
|
1162
|
+
|
|
1163
|
+
-
|
|
1164
|
+
`etag()` — adds a content-hash `ETag` to `GET` `200`s
|
|
1165
|
+
and returns `304` on a matching `If-None-Match`.
|
|
1166
|
+
|
|
1167
|
+
### Authentication
|
|
1168
|
+
|
|
1169
|
+
`bearer` and `apiKey` guard the routes defined after them and expose a
|
|
1170
|
+
**fully typed** principal. Because the derive path can't carry a precise type through a named
|
|
1171
|
+
plugin, the principal is read from the **returned instance** — `auth.principal(req)`
|
|
1172
|
+
(nullable) or `auth.requirePrincipal(req)` (throws `401`) — mirroring
|
|
1173
|
+
`@nifrajs/auth` and `@nifrajs/better-auth`. It's verified once per request and
|
|
1174
|
+
cached. For full session-based auth (OAuth, magic links, 2FA), see
|
|
1175
|
+
[Auth & sessions](/docs/auth).
|
|
1176
|
+
|
|
1177
|
+
```ts
|
|
1178
|
+
import { bearer, apiKey } from "@nifrajs/middleware"
|
|
1179
|
+
|
|
1180
|
+
// Bearer tokens — verify returns your principal (its type is inferred), 401s missing/invalid:
|
|
1181
|
+
const auth = bearer({ verify: (token) => lookupUser(token) }) // AuthPlugin<User>
|
|
1182
|
+
app
|
|
1183
|
+
.use(auth) // guards routes defined after it
|
|
1184
|
+
.get("/me", (c) => auth.requirePrincipal(c.req)) // typed principal, or throws 401
|
|
1185
|
+
|
|
1186
|
+
// API keys via a header (default x-api-key) — a fixed set compared in CONSTANT TIME…
|
|
1187
|
+
app.use(apiKey({ keys: [process.env.API_KEY!] })) // matched key becomes the principal
|
|
1188
|
+
// …or custom (DB-backed) verification; 'optional' lets unauthenticated requests through:
|
|
1189
|
+
app.use(apiKey({ verify: (key) => db.apiKeys.find(key), optional: true }))
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
-
|
|
1193
|
+
`bearer({ verify })` — parses `Authorization: Bearer`, rejects with
|
|
1194
|
+
`401` + `WWW-Authenticate` unless `optional`.
|
|
1195
|
+
|
|
1196
|
+
-
|
|
1197
|
+
`apiKey({ keys })` — a fixed key set compared in **constant time** (SHA-256 +
|
|
1198
|
+
early-exit-free byte compare; the matched key is the principal). `apiKey({ verify })`
|
|
1199
|
+
does custom, typed verification.
|
|
1200
|
+
|
|
1201
|
+
### Performance
|
|
1202
|
+
|
|
1203
|
+
`compression()` gzips compressible responses (via the Web-standard
|
|
1204
|
+
`CompressionStream`, so it works on every runtime) when the client sends
|
|
1205
|
+
`Accept-Encoding: gzip`, peeking the body so tiny responses aren't enlarged.
|
|
1206
|
+
`cacheControl()` sets `Cache-Control` on matching responses without
|
|
1207
|
+
clobbering one a handler already set.
|
|
1208
|
+
|
|
1209
|
+
```ts
|
|
1210
|
+
import { compression, cacheControl } from "@nifrajs/middleware"
|
|
1211
|
+
|
|
1212
|
+
app
|
|
1213
|
+
.use(compression()) // gzip compressible responses (Accept-Encoding)
|
|
1214
|
+
.use(cacheControl("public, max-age=60")) // Cache-Control on GET/HEAD 2xx (won't clobber)
|
|
1215
|
+
// …or a per-path policy — return undefined to leave a response untouched:
|
|
1216
|
+
.use(cacheControl((req) =>
|
|
1217
|
+
new URL(req.url).pathname.startsWith("/assets/")
|
|
1218
|
+
? "public, max-age=31536000, immutable"
|
|
1219
|
+
: undefined))
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
### Operations & docs
|
|
1223
|
+
|
|
1224
|
+
`healthcheck()` adds liveness (`/health`) and readiness (`/ready`)
|
|
1225
|
+
endpoints — apply it **before** any auth guard so they stay public. `openapi()`
|
|
1226
|
+
serves an OpenAPI 3.1 document at `/openapi.json`, generated from your routes — add
|
|
1227
|
+
`ui: true` for a Scalar API-reference page at `/reference`. Paths,
|
|
1228
|
+
methods, and params are introspected; `servers`, `tags`,
|
|
1229
|
+
`security`, and `securitySchemes` are document options, and
|
|
1230
|
+
`operations` (keyed by `"GET /users/:id"`) supplies per-route bodies/security
|
|
1231
|
+
that Standard Schema can't expose. For full request/response _schemas_, generate from a
|
|
1232
|
+
`defineContract` with `@nifrajs/schema`'s `toOpenAPI` (it reads the
|
|
1233
|
+
`t` JSON Schema and emits `$ref` reuse). `buildOpenApiDocument` is
|
|
1234
|
+
exported for build-time generation too.
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
import { healthcheck, openapi } from "@nifrajs/middleware"
|
|
1238
|
+
|
|
1239
|
+
app
|
|
1240
|
+
// Liveness + readiness. /ready runs each check concurrently → 200 (all pass) or 503.
|
|
1241
|
+
.use(healthcheck({ checks: { db: () => db.ping(), cache: () => redis.ping() } }))
|
|
1242
|
+
// GET /openapi.json from your routes (paths, methods, params); ui adds a Scalar page at /reference.
|
|
1243
|
+
.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
### Reliability
|
|
1247
|
+
|
|
1248
|
+
`idempotency()` makes a retried unsafe request (same `Idempotency-Key` header)
|
|
1249
|
+
replay the first response instead of re-running the side effect — no double-charge on a dropped
|
|
1250
|
+
connection. It short-circuits before the handler; a concurrent retry gets a `409`. See
|
|
1251
|
+
[Security & hardening](/docs/security) for the store contract, the production
|
|
1252
|
+
guidance (shared store + DB constraint), and the `Set-Cookie` rule.
|
|
1253
|
+
|
|
1254
|
+
```ts
|
|
1255
|
+
import { idempotency, MemoryIdempotencyStore } from "@nifrajs/middleware"
|
|
1256
|
+
|
|
1257
|
+
// A retried POST with the same Idempotency-Key replays the first response instead of
|
|
1258
|
+
// re-running the side effect. Shared store in production (atomic claim); dev-only memory store.
|
|
1259
|
+
app.use(idempotency({ store: new MemoryIdempotencyStore() }))
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
### JWT & Basic auth
|
|
1263
|
+
|
|
1264
|
+
`jwt` verifies tokens with WebCrypto. The `algorithms` allowlist is
|
|
1265
|
+
**required**; `alg:none` and RSA/HMAC confusion are rejected, `exp` is
|
|
1266
|
+
enforced by default, and claims (`iss`/`aud`/`nbf`) are checked.
|
|
1267
|
+
Read the typed claims off the returned plugin — `auth.requireClaims(c.req)` (throws
|
|
1268
|
+
`401`) or `auth.claims(c.req)` (nullable). For rotating keys, pass
|
|
1269
|
+
`key: jwks({ url })` (HTTPS-only, cached, size/time-bounded). `basicAuth`
|
|
1270
|
+
compares static credentials in **constant time** (or takes a `verify` callback).
|
|
1271
|
+
|
|
1272
|
+
```ts
|
|
1273
|
+
import { jwt, jwks, basicAuth } from "@nifrajs/middleware"
|
|
1274
|
+
|
|
1275
|
+
// JWT (WebCrypto): an explicit algorithm allowlist is REQUIRED; alg:none and RSA/HMAC confusion are rejected.
|
|
1276
|
+
const auth = jwt({ key: process.env.JWT_SECRET!, algorithms: ["HS256"], issuer: "my-app" })
|
|
1277
|
+
app
|
|
1278
|
+
.use(auth) // 401s missing/invalid (optional:true lets them through)
|
|
1279
|
+
.get("/me", (c) => auth.requireClaims(c.req)) // typed claims, or throws 401; auth.claims(req) is nullable
|
|
1280
|
+
// Asymmetric (rotating keys): key: jwks({ url: "https://issuer/.well-known/jwks.json" }) — https-only, cached.
|
|
1281
|
+
|
|
1282
|
+
// HTTP Basic — static creds compared in CONSTANT TIME (SHA-256 + timing-safe), or a verify callback.
|
|
1283
|
+
app.use(basicAuth({ username: "admin", password: process.env.PASS!, realm: "staging" }))
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
### Response caching
|
|
1287
|
+
|
|
1288
|
+
`cache` is a full response cache with a pluggable store, `Vary`-aware keys,
|
|
1289
|
+
and a byte cap. It **bypasses `Set-Cookie`** and honors request/response
|
|
1290
|
+
`Cache-Control` (`no-store`/`private`) so it never serves one
|
|
1291
|
+
user's response to another. `MemoryResponseCache` is per-instance and refuses
|
|
1292
|
+
`NODE_ENV=production` unless opted in — use a shared store in prod. `prettyJson`
|
|
1293
|
+
pretty-prints JSON responses (capped, with an optional query toggle).
|
|
1294
|
+
|
|
1295
|
+
```ts
|
|
1296
|
+
import { cache, MemoryResponseCache, prettyJson } from "@nifrajs/middleware"
|
|
1297
|
+
|
|
1298
|
+
// Full response cache: pluggable store, Vary-aware keys, byte cap. Bypasses Set-Cookie and respects
|
|
1299
|
+
// Cache-Control (no-store/private). MemoryResponseCache is per-instance — refuses prod unless opted in.
|
|
1300
|
+
app.use(cache({ store: new MemoryResponseCache(), ttlMs: 30_000, vary: ["accept-language"] }))
|
|
1301
|
+
app.use(prettyJson()) // pretty-print JSON responses (size-capped; optional ?pretty query toggle)
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
### Request shaping & negotiation
|
|
1305
|
+
|
|
1306
|
+
These build on the `onRequest` hook's ability to return a replacement
|
|
1307
|
+
`Request` (a real pre-routing rewrite, so handlers/validation/response hooks all see
|
|
1308
|
+
the rewritten request). `methodOverride` tunnels `PUT`/`PATCH`/
|
|
1309
|
+
`DELETE` through a `POST` header (query tunneling is off by default);
|
|
1310
|
+
`trimTrailingSlash`/`appendTrailingSlash` canonicalize URLs (same-origin,
|
|
1311
|
+
no open redirect); `language` negotiates `Accept-Language` into
|
|
1312
|
+
`c.language`; `timing` emits `Server-Timing`; `poweredBy`
|
|
1313
|
+
is opt-in; `combine` bundles several middleware into one.
|
|
1314
|
+
|
|
1315
|
+
```ts
|
|
1316
|
+
import { methodOverride, trimTrailingSlash, language, timing, poweredBy, combine } from "@nifrajs/middleware"
|
|
1317
|
+
|
|
1318
|
+
app
|
|
1319
|
+
.use(methodOverride()) // POST + X-HTTP-Method-Override → PUT/PATCH/DELETE (a real pre-routing request rewrite)
|
|
1320
|
+
.use(trimTrailingSlash()) // canonicalize URLs: 308 redirect (or rewrite), same-origin only, conservative methods
|
|
1321
|
+
.use(language({ supported: ["en", "fr"], defaultLanguage: "en" })) // Accept-Language → c.language + Content-Language
|
|
1322
|
+
.use(timing()) // Server-Timing header + typed c.timing marks/measures
|
|
1323
|
+
// poweredBy() is opt-in (nifra emits no X-Powered-By by default). combine(a, b, c) bundles several into one plugin.
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
### Security middleware
|
|
1327
|
+
|
|
1328
|
+
The security set — `csrf` (signed double-submit + Origin/Referer), `jwt`,
|
|
1329
|
+
`ipRestriction` (IPv4/IPv6 + CIDR, fails closed), and `bodyLimit`
|
|
1330
|
+
(Content-Length cap before routing) — is documented with hardening guidance on
|
|
1331
|
+
[Security & hardening](/docs/security). All comparisons are constant-time and all
|
|
1332
|
+
defaults fail closed.
|
|
1333
|
+
|
|
1334
|
+
### Error reporting
|
|
1335
|
+
|
|
1336
|
+
For SSR apps, `createWebApp`'s `onLoaderError` lets a reporting plugin
|
|
1337
|
+
observe every loader/action failure — including ones a nearest `_error` boundary
|
|
1338
|
+
would otherwise hide.
|
|
1339
|
+
|
|
1340
|
+
```ts
|
|
1341
|
+
// @nifrajs/web: observe loader/action failures for error reporting (Sentry-style).
|
|
1342
|
+
createWebApp({
|
|
1343
|
+
adapter, manifest, clientEntry,
|
|
1344
|
+
onLoaderError: (error, { route, request }) => report(error, { route }),
|
|
1345
|
+
})
|
|
1346
|
+
// Fires before the nearest _error boundary renders — so errors the boundary
|
|
1347
|
+
// would hide still reach your reporter. (Control-flow redirects aren't reported.)
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
## Nifra — security & hardening
|
|
1351
|
+
|
|
1352
|
+
> Bounded request bodies, magic-byte file-upload validation, constant-time webhook verification, and idempotency-key replay — the hardening primitives every production app needs, built in.
|
|
1353
|
+
|
|
1354
|
+
The pieces every production endpoint needs — a body-size cap for raw routes, real file-type
|
|
1355
|
+
validation, constant-time webhook verification, and idempotent retries — ship as first-party
|
|
1356
|
+
primitives. All are **edge-safe** (WebCrypto, no `node:crypto`) and run unchanged
|
|
1357
|
+
on Bun, Node, Deno, and Workers.
|
|
1358
|
+
|
|
1359
|
+
### Bounded request bodies
|
|
1360
|
+
|
|
1361
|
+
Nifra caps the body of any **schema-validated** route at `maxBodyBytes` — an over-cap
|
|
1362
|
+
`Content-Length` is rejected before buffering, and a chunked body is aborted mid-stream.
|
|
1363
|
+
But a route that reads the body _directly_ (raw bodies, file uploads, your own validation)
|
|
1364
|
+
bypasses that read path. `c.boundedBody(maxBytes?)` and
|
|
1365
|
+
`c.boundedJson<T>(maxBytes?)` extend the **same** cap to those routes.
|
|
1366
|
+
|
|
1367
|
+
```ts
|
|
1368
|
+
import { server, t } from "@nifrajs/core"
|
|
1369
|
+
|
|
1370
|
+
const app = server()
|
|
1371
|
+
|
|
1372
|
+
// A schema route is ALREADY bounded — the validated read enforces `maxBodyBytes`.
|
|
1373
|
+
// But a raw-body / file / BYO-validation route reads the body directly, which
|
|
1374
|
+
// `maxBodyBytes` does not cover. `c.boundedBody` caps that read:
|
|
1375
|
+
app.post("/import", async (c) => {
|
|
1376
|
+
const bytes = await c.boundedBody(5 * 1024 * 1024) // cap THIS route at 5 MiB
|
|
1377
|
+
// Over-cap throws a flat 413; a malformed Content-Length a 400 — as control-flow
|
|
1378
|
+
// Responses (caught by the lifecycle like `throw redirect()`), so a handler can't
|
|
1379
|
+
// accidentally ignore the cap. The over-cap length is rejected BEFORE buffering;
|
|
1380
|
+
// a chunked / length-less body is aborted mid-stream once it crosses the cap.
|
|
1381
|
+
return c.json({ received: bytes.byteLength })
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
app.post("/rpc", async (c) => {
|
|
1385
|
+
const body = await c.boundedJson<{ method: string }>() // default: the server's maxBodyBytes
|
|
1386
|
+
// …bad JSON → 400. Then validate `body` with your schema before trusting it.
|
|
1387
|
+
})
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
Over-cap throws a flat `413`, a malformed `Content-Length` a `400`,
|
|
1391
|
+
bad JSON a `400` — thrown as control-flow `Response`s the lifecycle catches,
|
|
1392
|
+
so the cap can't be silently skipped. Pass a larger `maxBytes` for an upload route, a
|
|
1393
|
+
smaller one to tighten an endpoint.
|
|
1394
|
+
|
|
1395
|
+
### File uploads — `@nifrajs/uploads`
|
|
1396
|
+
|
|
1397
|
+
A dependency-free package for the upload-hardening basics. `validateUpload` enforces a
|
|
1398
|
+
size cap and sniffs the **real** type from magic bytes — never the client-set
|
|
1399
|
+
`Content-Type`, which is trivially forged — against an optional allow-list. An oversized
|
|
1400
|
+
`Blob` is rejected by its `.size` before it's ever buffered.
|
|
1401
|
+
|
|
1402
|
+
```ts
|
|
1403
|
+
import { validateUpload, signDownloadUrl } from "@nifrajs/uploads"
|
|
1404
|
+
|
|
1405
|
+
app.post("/avatar", async (c) => {
|
|
1406
|
+
const form = await c.req.formData()
|
|
1407
|
+
const file = form.get("file")
|
|
1408
|
+
if (!(file instanceof Blob)) return c.json({ ok: false, error: "no_file" }, 400)
|
|
1409
|
+
|
|
1410
|
+
// Size cap + REAL type by magic bytes — a .exe renamed .png (or a spoofed
|
|
1411
|
+
// Content-Type) is caught, because the bytes win. An oversized Blob is rejected
|
|
1412
|
+
// by its .size BEFORE it's buffered into memory.
|
|
1413
|
+
const result = await validateUpload(file, {
|
|
1414
|
+
maxBytes: 2_000_000,
|
|
1415
|
+
accept: ["image/png", "image/jpeg"], // exact, or "image/*"
|
|
1416
|
+
})
|
|
1417
|
+
if (!result.ok) return c.json({ ok: false, error: result.reason }, 400)
|
|
1418
|
+
// reason: "too_large" | "empty" | "unrecognized" | "type_not_allowed"
|
|
1419
|
+
|
|
1420
|
+
await save(result.bytes, `${id}.${result.ext}`) // result.mime / .ext are trustworthy
|
|
1421
|
+
|
|
1422
|
+
// Hand back a short-TTL, tamper-evident URL (HMAC over path + expiry):
|
|
1423
|
+
const url = await signDownloadUrl(`/files/${id}`, env.FILE_SECRET, { expiresInSeconds: 300 })
|
|
1424
|
+
return c.json({ ok: true, url })
|
|
1425
|
+
})
|
|
1426
|
+
```
|
|
1427
|
+
|
|
1428
|
+
Pair it with `c.boundedBody` to also bound the _read_: cap the read, then validate
|
|
1429
|
+
the buffered bytes. `detectFileType(bytes)` is exposed standalone too (returns
|
|
1430
|
+
`{ mime, ext }` or `null`), covering common image / A-V / archive types.
|
|
1431
|
+
|
|
1432
|
+
`signDownloadUrl` / `verifyDownloadUrl` mint short-TTL, tamper-evident
|
|
1433
|
+
download links (HMAC-SHA256 over the path + expiry, constant-time verify). And
|
|
1434
|
+
`stripImageMetadata` drops EXIF/GPS by re-encoding the image — through any
|
|
1435
|
+
[@nifrajs/image](/docs/images) backend, with no dependency on it:
|
|
1436
|
+
|
|
1437
|
+
```ts
|
|
1438
|
+
import { stripImageMetadata } from "@nifrajs/uploads"
|
|
1439
|
+
import { bunImageBackend } from "@nifrajs/image/backends"
|
|
1440
|
+
|
|
1441
|
+
// Drop EXIF/GPS by re-encoding through any @nifrajs/image backend. @nifrajs/uploads keeps
|
|
1442
|
+
// ZERO dependency on @nifrajs/image — the backend is passed in (structural type), so this
|
|
1443
|
+
// also works with sharpImageBackend(sharp) on Node or wasmImageBackend(...) on the edge.
|
|
1444
|
+
const clean = await stripImageMetadata(result.bytes, bunImageBackend())
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
### Webhooks — `verifyWebhook`
|
|
1448
|
+
|
|
1449
|
+
The cardinal webhook rule: **verify before you parse**. A handler that
|
|
1450
|
+
`JSON.parse`s the body before checking the signature is acting on an unauthenticated
|
|
1451
|
+
payload. `verifyWebhook` reads the raw body bounded, verifies the HMAC, and hands back
|
|
1452
|
+
the verified text for you to parse with your own schema.
|
|
1453
|
+
|
|
1454
|
+
```ts
|
|
1455
|
+
import { verifyWebhook } from "@nifrajs/core"
|
|
1456
|
+
|
|
1457
|
+
app.post("/webhooks/stripe", async (c) => {
|
|
1458
|
+
// Reads the raw body BOUNDED (DoS guard), verifies the HMAC CONSTANT-TIME, and only
|
|
1459
|
+
// then returns the payload. Never JSON.parse a webhook before the signature checks out.
|
|
1460
|
+
const r = await verifyWebhook(c.req, env.STRIPE_WEBHOOK_SECRET, { provider: "stripe" })
|
|
1461
|
+
if (!r.ok) return c.json({ ok: false, error: r.reason }, 400)
|
|
1462
|
+
// reason: "missing_signature" | "invalid_signature" | "timestamp_out_of_tolerance"
|
|
1463
|
+
// | "malformed_signature" | "payload_too_large" | "invalid_content_length"
|
|
1464
|
+
|
|
1465
|
+
const event = StripeEvent.parse(JSON.parse(r.payload)) // validate at the trust boundary
|
|
1466
|
+
// …handle event… (pair with idempotency below so a redelivery doesn't double-process)
|
|
1467
|
+
return c.json({ ok: true })
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
// GitHub (sha256=…hex), or any provider via the generic preset:
|
|
1471
|
+
await verifyWebhook(c.req, env.GH_SECRET, { provider: "github" })
|
|
1472
|
+
await verifyWebhook(c.req, [next, current], { // an array accepts either during a rotation
|
|
1473
|
+
header: "x-signature", encoding: "base64", prefix: "v1=",
|
|
1474
|
+
})
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
Verification is **constant-time** — the provider's signature goes straight into
|
|
1478
|
+
`crypto.subtle.verify`, so a wrong signature can't be discovered byte-by-byte through
|
|
1479
|
+
timing. Presets cover **Stripe** (parses `t=…,v1=…` and enforces a 5-minute replay
|
|
1480
|
+
window on the signed timestamp) and **GitHub** (`sha256=…`); the `generic`
|
|
1481
|
+
preset takes an explicit header, encoding, and prefix for anything else. Pass an array of secrets to
|
|
1482
|
+
accept either during a key rotation.
|
|
1483
|
+
|
|
1484
|
+
### Idempotency — `idempotency()` middleware
|
|
1485
|
+
|
|
1486
|
+
A dropped connection or an impatient double-tap shouldn't double-charge a card. With an
|
|
1487
|
+
`Idempotency-Key` header, a retried unsafe request replays the first response instead of
|
|
1488
|
+
re-running the side effect. It short-circuits in `onRequest`, _before_ the handler.
|
|
1489
|
+
|
|
1490
|
+
```ts
|
|
1491
|
+
import { idempotency, MemoryIdempotencyStore } from "@nifrajs/middleware"
|
|
1492
|
+
|
|
1493
|
+
// Dev / single-instance. In production use a SHARED store (Redis, etc.) with an atomic
|
|
1494
|
+
// claim — MemoryIdempotencyStore throws under NODE_ENV=production unless you opt in.
|
|
1495
|
+
app.use(idempotency({ store: new MemoryIdempotencyStore() }))
|
|
1496
|
+
|
|
1497
|
+
app.post("/charge", async (c) => {
|
|
1498
|
+
await chargeCard(/* … */) // the side effect
|
|
1499
|
+
return c.json({ ok: true, id })
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
// A client retrying POST /charge with the same `Idempotency-Key` header gets the FIRST
|
|
1503
|
+
// response replayed (`Idempotent-Replayed: true`) — the charge runs once. A concurrent
|
|
1504
|
+
// retry, while the first is still in flight, gets 409 { error: "idempotency_in_progress" }.
|
|
1505
|
+
// Transient 5xx are NOT cached (a failed call stays retryable).
|
|
1506
|
+
```
|
|
1507
|
+
|
|
1508
|
+
-
|
|
1509
|
+
**Production needs a shared store.** `MemoryIdempotencyStore` is per-instance and
|
|
1510
|
+
refuses to start under `NODE_ENV=production` unless you pass
|
|
1511
|
+
`{ allowInProduction: true }`. Implement `IdempotencyStore` over Redis
|
|
1512
|
+
(etc.) with an **atomic** claim (`SET key NX PX`) so two retries can't both proceed.
|
|
1513
|
+
|
|
1514
|
+
-
|
|
1515
|
+
**Pair it with a DB uniqueness constraint.** The middleware stops the _retry_; the
|
|
1516
|
+
constraint is the source of truth for genuinely-concurrent _distinct_ requests. Belt and
|
|
1517
|
+
braces — the constraint is the belt.
|
|
1518
|
+
|
|
1519
|
+
-
|
|
1520
|
+
**`Set-Cookie` is never cached or replayed.** A session cookie is caller-specific;
|
|
1521
|
+
replaying it to a second caller (key collision or abuse) would leak/fixate a session. The first
|
|
1522
|
+
caller still gets their cookie — replays just don't carry it.
|
|
1523
|
+
|
|
1524
|
+
-
|
|
1525
|
+
Caching buffers the response body, so apply it to JSON/API routes, not streaming SSR responses.
|
|
1526
|
+
Transient `5xx` aren't cached, so a failed call stays retryable.
|
|
1527
|
+
|
|
1528
|
+
### Edge gating — `jwt`, `csrf`, `ipRestriction`, `bodyLimit`
|
|
1529
|
+
|
|
1530
|
+
`@nifrajs/middleware` ships the request-gating set, applied with `app.use()`.
|
|
1531
|
+
Every one is constant-time where it compares secrets and **fails closed** by default.
|
|
1532
|
+
|
|
1533
|
+
```ts
|
|
1534
|
+
import { jwt, csrf, ipRestriction, bodyLimit } from "@nifrajs/middleware"
|
|
1535
|
+
|
|
1536
|
+
app
|
|
1537
|
+
// JWT: the algorithm allowlist is REQUIRED; alg:none and RSA/HMAC confusion are rejected; exp enforced.
|
|
1538
|
+
.use(jwt({ key: env.JWT_SECRET, algorithms: ["HS256"], issuer: "my-app" }))
|
|
1539
|
+
// Signed double-submit CSRF (HMAC) + Origin/Referer check on unsafe methods. Secret must be >= 32 bytes.
|
|
1540
|
+
.use(csrf({ secret: env.CSRF_SECRET }))
|
|
1541
|
+
// Allow/deny by IPv4/IPv6 + CIDR. FAILS CLOSED with no trusted client IP; X-Forwarded-For is ignored
|
|
1542
|
+
// unless trustedProxies > 0 (set it to the number of proxies you actually run in front of the app).
|
|
1543
|
+
.use(ipRestriction({ allow: ["10.0.0.0/8", "::1"], trustedProxies: 1 }))
|
|
1544
|
+
// Reject oversized bodies at the EDGE by Content-Length, before routing — fails closed (411) on a
|
|
1545
|
+
// length-less body. (The schema / c.boundedBody cap is the read-time guard; this is the cheap pre-filter.)
|
|
1546
|
+
.use(bodyLimit({ maxBytes: 1_000_000 }))
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
-
|
|
1550
|
+
`jwt` — WebCrypto verification with a **required** `algorithms` allowlist;
|
|
1551
|
+
`alg:none` and RSA/HMAC confusion are rejected, `exp`/`nbf`/
|
|
1552
|
+
`iss`/`aud` are checked. Rotating keys via `jwks({ url })`
|
|
1553
|
+
(HTTPS-only, cached). Read claims with `auth.requireClaims(c.req)`.
|
|
1554
|
+
|
|
1555
|
+
-
|
|
1556
|
+
`csrf` — signed double-submit token (HMAC, secret ≥ 32 bytes) plus an Origin/Referer
|
|
1557
|
+
check on unsafe methods; both the token match and signature are verified constant-time.
|
|
1558
|
+
|
|
1559
|
+
-
|
|
1560
|
+
`ipRestriction` — IPv4/IPv6 exact + CIDR allow/deny. It **fails closed** when no
|
|
1561
|
+
trustworthy client IP can be derived, and never trusts `X-Forwarded-For` unless you set
|
|
1562
|
+
`trustedProxies` to the number of proxies in front of the app.
|
|
1563
|
+
|
|
1564
|
+
-
|
|
1565
|
+
`bodyLimit` — a cheap `Content-Length` pre-filter that rejects oversized
|
|
1566
|
+
bodies before routing (fails closed with `411` on a length-less body). The read-time
|
|
1567
|
+
guard above (`c.boundedBody` / schema cap) remains the source of truth.
|
|
1568
|
+
|
|
1569
|
+
### Already built in
|
|
1570
|
+
|
|
1571
|
+
These add to Nifra's standing defaults: strict-by-default schema validation (unknown fields
|
|
1572
|
+
rejected), SSR serialization that escapes every inline-script value, signed-cookie sessions + CSRF
|
|
1573
|
+
+ route guards ([@nifrajs/auth](/docs/auth)), bearer/apiKey auth + a shared-store rate
|
|
1574
|
+
limiter ([@nifrajs/middleware](/docs/plugins)), and a hardened image-resize endpoint
|
|
1575
|
+
([@nifrajs/image/server](/docs/images)).
|
|
1576
|
+
|
|
1577
|
+
### Redacting logs
|
|
1578
|
+
|
|
1579
|
+
The built-in `jsonLogger` redacts values under sensitive **keys**
|
|
1580
|
+
(`password`, `authorization`, `token`, …) by default. For
|
|
1581
|
+
secrets that land in a **value** or the message itself (e.g. an `err.message` that
|
|
1582
|
+
embeds a token), pass opt-in `valuePatterns` — `commonSecretPatterns` covers
|
|
1583
|
+
bearer tokens, JWTs, emails, and a few well-known key formats, or supply your own:
|
|
1584
|
+
|
|
1585
|
+
```ts
|
|
1586
|
+
import { server, jsonLogger, commonSecretPatterns } from "@nifrajs/core"
|
|
1587
|
+
|
|
1588
|
+
// Key-name redaction is always on; valuePatterns adds opt-in value + message scanning.
|
|
1589
|
+
const app = server({
|
|
1590
|
+
logger: jsonLogger(undefined, { valuePatterns: commonSecretPatterns }),
|
|
1591
|
+
})
|
|
1592
|
+
|
|
1593
|
+
// logger.error("auth failed for user@example.com with Bearer abc.def")
|
|
1594
|
+
// → { ...,"message":"auth failed for [REDACTED] with [REDACTED]" }
|
|
1595
|
+
// Add your own: { valuePatterns: [...commonSecretPatterns, /\bord_[a-z0-9]+/g] }
|
|
1596
|
+
```
|
|
1597
|
+
|
|
1598
|
+
## Nifra — Auth & sessions
|
|
1599
|
+
|
|
1600
|
+
> Turnkey auth with @nifrajs/better-auth (OAuth, magic links, 2FA), or signed-cookie + server-store sessions, route guards, and CSRF with @nifrajs/auth.
|
|
1601
|
+
|
|
1602
|
+
Two paths. **[@nifrajs/better-auth](#better-auth)** is turnkey — mount
|
|
1603
|
+
[Better Auth](https://better-auth.com) (OAuth, magic links, 2FA, …) into your app in
|
|
1604
|
+
one line. **[@nifrajs/auth](#sessions)** is the framework half — **signed-cookie or
|
|
1605
|
+
server-store sessions**, **route guards**, and **CSRF** — when you want to own identity
|
|
1606
|
+
yourself. Nifra owns the _session_; you bring (or mount) the _who_.
|
|
1607
|
+
|
|
1608
|
+
### Full auth with Better Auth
|
|
1609
|
+
|
|
1610
|
+
`@nifrajs/better-auth` bridges [Better Auth](https://better-auth.com) into
|
|
1611
|
+
nifra: `betterAuth(auth)` mounts its handler at `/api/auth/*` (GET + POST),
|
|
1612
|
+
so every endpoint — sign-in/up/out, OAuth callbacks, session, 2FA, magic links — is served by
|
|
1613
|
+
your nifra server. Read the session with `getSession(auth, request)` (typed
|
|
1614
|
+
`{ user, session } | null`) or guard a route with
|
|
1615
|
+
`requireSession(auth, request, options?)` (returns it, or throws a 401/redirect
|
|
1616
|
+
`Response`). It's declared **structurally** — no hard dependency on Better Auth, so
|
|
1617
|
+
your tests need no database — and your Better Auth types flow through by inference.
|
|
1618
|
+
|
|
1619
|
+
```ts
|
|
1620
|
+
// auth.ts — your configured Better Auth instance (database, providers, …):
|
|
1621
|
+
import { betterAuth as createBetterAuth } from "better-auth"
|
|
1622
|
+
export const auth = createBetterAuth({ database: db, emailAndPassword: { enabled: true } })
|
|
1623
|
+
|
|
1624
|
+
// server.ts — ONE use() mounts every Better Auth endpoint at /api/auth/*:
|
|
1625
|
+
import { betterAuth, getSession, requireSession } from "@nifrajs/better-auth"
|
|
1626
|
+
const app = server()
|
|
1627
|
+
.use(betterAuth(auth)) // sign-in/up/out, OAuth, 2FA, session…
|
|
1628
|
+
.get("/me", async (c) => (await requireSession(auth, c.req)).user) // typed; 401 when signed out
|
|
1629
|
+
|
|
1630
|
+
// In a loader/action, read the session from the raw Request:
|
|
1631
|
+
export async function loader({ request }) {
|
|
1632
|
+
const session = await getSession(auth, request) // { user, session } | null — typed
|
|
1633
|
+
const { user } = await requireSession(auth, request, { redirectTo: "/login" }) // or guard it
|
|
1634
|
+
return { user }
|
|
1635
|
+
}
|
|
1636
|
+
```
|
|
1637
|
+
|
|
1638
|
+
Prefer to own identity (custom password/OAuth, Lucia, …)? Use the session primitives below.
|
|
1639
|
+
|
|
1640
|
+
### Set up a session manager
|
|
1641
|
+
|
|
1642
|
+
`createSessions` signs the cookie (HMAC, verified constant-time) and always marks it
|
|
1643
|
+
`HttpOnly`. In **store mode** the cookie is just an opaque id and the data lives in
|
|
1644
|
+
a `SessionStore`; in **cookie mode** (no store) the data is signed into the cookie.
|
|
1645
|
+
Stores mirror the ISR discipline: `MemorySessionStore` is prod-guarded;
|
|
1646
|
+
`KVSessionStore` is the durable, shared production store.
|
|
1647
|
+
|
|
1648
|
+
```ts
|
|
1649
|
+
// auth.ts — one session manager. LAZY so a route module can import it without shipping it to the
|
|
1650
|
+
// browser (see the warning below). Store mode keeps data server-side; cookie mode (no store) is stateless.
|
|
1651
|
+
import { createSessions, MemorySessionStore, KVSessionStore } from "@nifrajs/auth"
|
|
1652
|
+
|
|
1653
|
+
let manager
|
|
1654
|
+
export const getSessions = () => (manager ??= createSessions({
|
|
1655
|
+
secret: process.env.SESSION_SECRET, // ≥ 16 chars; rotating it invalidates all sessions
|
|
1656
|
+
store: new MemorySessionStore(), // prod: new KVSessionStore(env.SESSIONS)
|
|
1657
|
+
// cookie: { secure: false }, // local http dev only
|
|
1658
|
+
}))
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
### Log in & out
|
|
1662
|
+
|
|
1663
|
+
A loader can _read_ the session but can't write cookies — so login/logout live in plain
|
|
1664
|
+
Nifra routes that have the full `Context`. `regenerate()` rotates the
|
|
1665
|
+
session id on login to defend against fixation; the `Set-Cookie` rides the redirect.
|
|
1666
|
+
|
|
1667
|
+
```ts
|
|
1668
|
+
// server.ts — login/logout are plain nifra routes (full Context → they can WRITE the cookie).
|
|
1669
|
+
const sessions = getSessions()
|
|
1670
|
+
app.use(csrf()) // Origin check on unsafe methods
|
|
1671
|
+
|
|
1672
|
+
app.post("/api/login", async (c) => {
|
|
1673
|
+
const { username } = Object.fromEntries(await c.req.formData())
|
|
1674
|
+
// ... verify the credential (Better Auth / Lucia / your own) ...
|
|
1675
|
+
const session = await sessions.get(c)
|
|
1676
|
+
session.set("userId", String(username))
|
|
1677
|
+
sessions.regenerate(session) // rotate the id on login (fixation defense)
|
|
1678
|
+
await sessions.commit(c, session)
|
|
1679
|
+
return redirect("/") // the Set-Cookie rides the redirect
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
app.post("/api/logout", async (c) => {
|
|
1683
|
+
await sessions.destroy(c, await sessions.get(c))
|
|
1684
|
+
return redirect("/login")
|
|
1685
|
+
})
|
|
1686
|
+
|
|
1687
|
+
createWebApp({ /* … */ api: sessions }) // inject the manager into loaders as ctx.api
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
### Guard a route
|
|
1691
|
+
|
|
1692
|
+
`requireSession` / `requireUser` throw a `Response` (a 302 to
|
|
1693
|
+
`redirectTo`, or a 401) when the session is missing — nifra returns a thrown Response
|
|
1694
|
+
as-is, so the guard short-circuits the loader.
|
|
1695
|
+
|
|
1696
|
+
```ts
|
|
1697
|
+
// A protected route's loader — reads the session and redirects when absent.
|
|
1698
|
+
import { requireUser } from "@nifrajs/auth" // browser-safe; OK to import in a route module
|
|
1699
|
+
|
|
1700
|
+
export async function loader({ request, api }) {
|
|
1701
|
+
const sessions = api // the manager injected via createWebApp's `api`
|
|
1702
|
+
const session = await sessions.read(request)
|
|
1703
|
+
// requireUser throws a 302 to /login when there's no session; nifra returns the thrown Response.
|
|
1704
|
+
const userId = requireUser(session, "userId", { redirectTo: "/login" })
|
|
1705
|
+
return { userId }
|
|
1706
|
+
}
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
### ⚠️ Never import server-only code into a route module
|
|
1710
|
+
|
|
1711
|
+
A route's `loader` runs only on the server, but its module is **also bundled for the
|
|
1712
|
+
browser** (for the component) — and the loader is **not** stripped from that bundle. So a
|
|
1713
|
+
top-level `import` of the session manager (or a DB client, or anything touching
|
|
1714
|
+
`process.env`) would ship server code to the client and crash hydration. Reach server
|
|
1715
|
+
resources through `ctx.api` / `ctx.env` instead (inject them via
|
|
1716
|
+
`createWebApp`) — exactly how the manager is passed as `api` above.
|
|
1717
|
+
`requireUser` is fine to import: it only builds a `Response`, no secrets.
|
|
1718
|
+
|
|
1719
|
+
### CSRF
|
|
1720
|
+
|
|
1721
|
+
`app.use(csrf({ origins }))` rejects any unsafe-method request whose
|
|
1722
|
+
`Origin`/`Referer` doesn't match an allowed origin — the recommended
|
|
1723
|
+
defense for cookie-auth. Pair it with the rate-limit middleware on your login route.
|
|
1724
|
+
|
|
1725
|
+
### Cookie Security Defaults
|
|
1726
|
+
|
|
1727
|
+
Nifra's `c.set.cookie()` applies secure defaults to every cookie:
|
|
1728
|
+
|
|
1729
|
+
-
|
|
1730
|
+
**HttpOnly** — not accessible to JavaScript (prevents XSS theft)
|
|
1731
|
+
|
|
1732
|
+
-
|
|
1733
|
+
**Secure** — sent only over HTTPS (set `{ secure: false }` for local http dev)
|
|
1734
|
+
|
|
1735
|
+
-
|
|
1736
|
+
**SameSite=Lax** — mitigates CSRF without blocking top-level navigation
|
|
1737
|
+
|
|
1738
|
+
-
|
|
1739
|
+
**Path=/** — sent on all requests (override with `{ path: '/admin' }` if needed)
|
|
1740
|
+
|
|
1741
|
+
These are applied to **all** cookies (sessions, CSRF tokens, preferences) unless explicitly
|
|
1742
|
+
overridden. When developing locally, you **must** set `{ secure: false }` or
|
|
1743
|
+
the cookie will be rejected by the browser on non-HTTPS connections.
|
|
1744
|
+
|
|
1745
|
+
### Rate Limiting on Login
|
|
1746
|
+
|
|
1747
|
+
The rate-limit middleware (`@nifrajs/middleware`) protects against brute-force by enforcing
|
|
1748
|
+
IP-based buckets. **Critical:** if your app is behind a reverse proxy (CDN, load balancer), you
|
|
1749
|
+
**must** configure `trustedProxies` so the middleware reads the real client IP from
|
|
1750
|
+
`X-Forwarded-For` instead of the proxy's IP (which would cause all users to share a rate
|
|
1751
|
+
limit):
|
|
1752
|
+
|
|
1753
|
+
"login:" + c.req.header("x-forwarded-for") ?? c.req.header("cf-connecting-ip") ?? c.ip,
|
|
1754
|
+
limit: 5, // 5 attempts
|
|
1755
|
+
window: 15 * 60 * 1000, // per 15 minutes
|
|
1756
|
+
onExceeded: (c) => new Response("Too many login attempts", { status: 429 }),
|
|
1757
|
+
.post("/login", …)
|
|
1758
|
+
lang="ts"
|
|
1759
|
+
/>
|
|
1760
|
+
|
|
1761
|
+
Without `trustedProxies` configured correctly, the middleware will fall back to the
|
|
1762
|
+
proxy's IP address, and your entire user base will share a single rate-limit bucket — defeating
|
|
1763
|
+
the protection. Check your proxy's documentation for how it sets
|
|
1764
|
+
`X-Forwarded-For` (Cloudflare uses `cf-connecting-ip`, AWS ALB/NLB use
|
|
1765
|
+
`x-forwarded-for`).
|
|
1766
|
+
|
|
1767
|
+
## Nifra — images
|
|
1768
|
+
|
|
1769
|
+
> A CLS-safe responsive <Image> with pluggable loaders — a CDN, or Nifra's self-hosted resize endpoint (Bun.Image, sharp, or WASM on the edge) with optional signed URLs.
|
|
1770
|
+
|
|
1771
|
+
`@nifrajs/image` gives you a CLS-safe, responsive `<Image>` with
|
|
1772
|
+
lazy-by-default loading and a pluggable loader. The **core bundles no image codec** — point the
|
|
1773
|
+
loader at a CDN and the runtime stays tiny. Or self-host: the optional
|
|
1774
|
+
`@nifrajs/image/server` resizes your own images with `Bun.Image`.
|
|
1775
|
+
|
|
1776
|
+
### The <Image> component
|
|
1777
|
+
|
|
1778
|
+
`width` and `height` are **required** and validated
|
|
1779
|
+
`> 0` — they reserve layout space so the page never shifts when the image loads
|
|
1780
|
+
(the CLS contract). It's `loading="lazy"` + `decoding="async"` by default;
|
|
1781
|
+
mark the LCP image with `priority` to get `eager` +
|
|
1782
|
+
`fetchpriority="high"`. Extra DOM props (`className`, `style`,
|
|
1783
|
+
`id`, `data-*`) pass straight through to the `<img>`.
|
|
1784
|
+
|
|
1785
|
+
```ts
|
|
1786
|
+
import { Image } from "@nifrajs/web-react/image"
|
|
1787
|
+
import { cloudflareLoader } from "@nifrajs/image"
|
|
1788
|
+
|
|
1789
|
+
const cdn = cloudflareLoader() // → /cdn-cgi/image/format=auto,width=W/...
|
|
1790
|
+
|
|
1791
|
+
export default function Page() {
|
|
1792
|
+
return <>
|
|
1793
|
+
{/* The LCP image: priority → loading="eager" + fetchpriority="high"
|
|
1794
|
+
(React 19 also emits a <link rel="preload" as="image"> for it). */}
|
|
1795
|
+
<Image src="/hero.jpg" width={1200} height={630} alt="Hero banner"
|
|
1796
|
+
priority loader={cdn} sizes="(max-width: 1200px) 100vw, 1200px" />
|
|
1797
|
+
|
|
1798
|
+
{/* Below the fold: lazy + async-decode by default. width/height
|
|
1799
|
+
reserve the box, so nothing shifts when it loads (no CLS). */}
|
|
1800
|
+
<Image src="/thumb.jpg" width={400} height={300} alt="Thumbnail"
|
|
1801
|
+
loader={cdn} quality={75} />
|
|
1802
|
+
</>
|
|
1803
|
+
}
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
The responsive `srcSet` is built from `widths` (default
|
|
1807
|
+
`[width, width*2]` for 1×/2× retina), de-duped and sorted; if every width yields
|
|
1808
|
+
the same URL (e.g. the identity loader), `srcSet` is omitted. The browser then picks
|
|
1809
|
+
the right candidate for the device's pixel ratio and the `sizes` you declare.
|
|
1810
|
+
|
|
1811
|
+
### Loaders
|
|
1812
|
+
|
|
1813
|
+
A loader is a pure `({ src, width, quality? }) => string` URL builder. Ship with
|
|
1814
|
+
` cloudflareLoader()` (Cloudflare Images) or the no-op `identityLoader`,
|
|
1815
|
+
or write your own for any CDN — Imgix, Cloudinary, a signed-URL service, anything.
|
|
1816
|
+
|
|
1817
|
+
```ts
|
|
1818
|
+
import type { ImageLoader } from "@nifrajs/image"
|
|
1819
|
+
import { cloudflareLoader, identityLoader } from "@nifrajs/image"
|
|
1820
|
+
|
|
1821
|
+
// Built-in: Cloudflare Images. `base` prepends an origin to bare paths.
|
|
1822
|
+
const cf = cloudflareLoader({ base: "https://assets.example.com" })
|
|
1823
|
+
|
|
1824
|
+
// The default when you pass no loader: no transform (still CLS-safe + lazy,
|
|
1825
|
+
// just no responsive variants).
|
|
1826
|
+
identityLoader({ src: "/a.png", width: 800 }) // → "/a.png"
|
|
1827
|
+
|
|
1828
|
+
// Any CDN is a pure (src, width, quality?) → URL builder:
|
|
1829
|
+
const imgix: ImageLoader = ({ src, width, quality }) =>
|
|
1830
|
+
`https://my.imgix.net${src}?w=${width}&auto=format${quality ? `&q=${quality}` : ""}`
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
### Self-hosting — nifra's own resize endpoint
|
|
1834
|
+
|
|
1835
|
+
No CDN? `@nifrajs/image/server`'s `createImageHandler` is a self-hosted resize
|
|
1836
|
+
endpoint backed by `Bun.Image` (libjpeg-turbo / libspng / libwebp, decoded off-thread).
|
|
1837
|
+
Pair it with `selfHostedLoader` and nifra resizes your own images — no third party in the
|
|
1838
|
+
path.
|
|
1839
|
+
|
|
1840
|
+
```ts
|
|
1841
|
+
// 1. The loader (browser-safe) points <Image> at your endpoint:
|
|
1842
|
+
import { selfHostedLoader } from "@nifrajs/image"
|
|
1843
|
+
const resize = selfHostedLoader({ endpoint: "/_image" })
|
|
1844
|
+
// <Image src="/photo.png" width={800} height={450} alt="…" loader={resize} />
|
|
1845
|
+
// → /_image?src=%2Fphoto.png&w=800 (+ a 1600w retina candidate)
|
|
1846
|
+
|
|
1847
|
+
// 2. The endpoint (server-only) does the actual resize with Bun.Image:
|
|
1848
|
+
import { createImageHandler } from "@nifrajs/image/server"
|
|
1849
|
+
|
|
1850
|
+
const image = createImageHandler({
|
|
1851
|
+
root: "./public", // local sources resolve under here (traversal+symlink guarded)
|
|
1852
|
+
allowedOrigins: ["https://cdn.example"], // remote sources: allowlist only (omit ⇒ none)
|
|
1853
|
+
// maxWidth, maxSourceBytes, maxSourcePixels, concurrency, cacheMaxAge — all tunable
|
|
1854
|
+
})
|
|
1855
|
+
|
|
1856
|
+
// mount it in your router:
|
|
1857
|
+
app.get("/_image", (c) => image(c.req))
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
The handler is **hardened by default**, because `src`/`w`/`q`
|
|
1861
|
+
are untrusted input:
|
|
1862
|
+
|
|
1863
|
+
-
|
|
1864
|
+
**SSRF, fail-closed.** Local sources are confined to `root` with path-traversal
|
|
1865
|
+
_and_ symlink containment checks; remote sources are refused unless their exact origin is in
|
|
1866
|
+
`allowedOrigins` (omit it ⇒ no remote fetch at all). Only `http(s)` URLs are
|
|
1867
|
+
considered, redirects are refused, and fetches are byte-capped + timed out.
|
|
1868
|
+
|
|
1869
|
+
-
|
|
1870
|
+
**DoS guards.** Strict integer parsing (no `Number()` coercion), width clamped to
|
|
1871
|
+
`maxWidth`, a source byte cap, a decompression-bomb pixel cap (via a cheap header-only
|
|
1872
|
+
probe), and a concurrency semaphore bounding the CPU-heavy codec work.
|
|
1873
|
+
|
|
1874
|
+
-
|
|
1875
|
+
**Correct + cacheable.** Never upscales past the intrinsic width; negotiates WebP via
|
|
1876
|
+
`Accept` (with `Vary: Accept`); serves
|
|
1877
|
+
`Cache-Control: …, immutable` + a strong `ETag` computed _before_ any
|
|
1878
|
+
decode, so a conditional `If-None-Match` short-circuits the whole pipeline.
|
|
1879
|
+
|
|
1880
|
+
The handler reads the filesystem, so its _local-source_ path targets **Node/Bun servers**
|
|
1881
|
+
— but the codec itself is a pluggable `ImageBackend`, so it runs anywhere (see below).
|
|
1882
|
+
|
|
1883
|
+
### Backends — Bun, sharp, or WASM (edge)
|
|
1884
|
+
|
|
1885
|
+
The codec is a seam: the handler owns all the security above; a backend just
|
|
1886
|
+
decodes/resizes/encodes. Three official backends, all from `@nifrajs/image/backends`:
|
|
1887
|
+
|
|
1888
|
+
-
|
|
1889
|
+
`bunImageBackend()` — the default; `Bun.Image` on Bun servers.
|
|
1890
|
+
|
|
1891
|
+
-
|
|
1892
|
+
`sharpImageBackend(sharp)` — libvips for Node. You pass your `sharp` import
|
|
1893
|
+
(nifra keeps zero dependency on it, and you pin the version).
|
|
1894
|
+
|
|
1895
|
+
-
|
|
1896
|
+
`wasmImageBackend(codecs)` — pure-WASM decode/resize/encode you wire up (jSquash is
|
|
1897
|
+
the common choice). The **only backend that runs on the edge** (Workers / Vercel-Edge /
|
|
1898
|
+
Deno-Deploy), where there's no native codec. `@nifrajs/image/backends` has no
|
|
1899
|
+
`node:` imports, so it bundles for the edge cleanly; the bomb-safe header probe is
|
|
1900
|
+
built in (it never decodes just to read dimensions).
|
|
1901
|
+
|
|
1902
|
+
```ts
|
|
1903
|
+
import { createImageHandler } from "@nifrajs/image/server"
|
|
1904
|
+
import { sharpImageBackend, wasmImageBackend } from "@nifrajs/image/backends"
|
|
1905
|
+
|
|
1906
|
+
// Node — libvips via sharp. Pass your own import (nifra never depends on it):
|
|
1907
|
+
import sharp from "sharp"
|
|
1908
|
+
createImageHandler({ backend: sharpImageBackend(sharp), root: "./public" })
|
|
1909
|
+
|
|
1910
|
+
// Edge (Workers / Vercel-Edge / Deno-Deploy) — pure-WASM codecs, e.g. jSquash:
|
|
1911
|
+
import decodeJpeg from "@jsquash/jpeg/decode"
|
|
1912
|
+
import resize from "@jsquash/resize"
|
|
1913
|
+
import encodeWebp from "@jsquash/webp/encode" // + @jsquash/png, @jsquash/jpeg encoders
|
|
1914
|
+
createImageHandler({
|
|
1915
|
+
allowedOrigins: ["https://cdn.example"],
|
|
1916
|
+
backend: wasmImageBackend({
|
|
1917
|
+
decode: decodeJpeg, // → { data, width, height } (RGBA)
|
|
1918
|
+
resize: (img, width, height) => resize(img, { width, height }),
|
|
1919
|
+
encode: async (img, format) => new Uint8Array(await encodeWebp(img)), // switch by format
|
|
1920
|
+
}),
|
|
1921
|
+
})
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
Prefer not to run a codec on the edge at all? `cloudflareLoader` still resizes at the
|
|
1925
|
+
CDN — same `<Image>`, swap the loader.
|
|
1926
|
+
|
|
1927
|
+
### Signed URLs
|
|
1928
|
+
|
|
1929
|
+
Because `src`/`w`/`q` are attacker-controllable, a public resize
|
|
1930
|
+
endpoint can be **resize-bombed** — enumerating widths/qualities to flood CPU + cache. Signing
|
|
1931
|
+
shuts that down: set a `secret` on the loader and the handler, and the endpoint
|
|
1932
|
+
**rejects any URL it didn't mint** (forged, tampered, or expired) with `403`, before
|
|
1933
|
+
any fetch or decode. The signature is a portable **synchronous** HMAC-SHA256, so the (sync)
|
|
1934
|
+
loader can sign inline — even on the edge.
|
|
1935
|
+
|
|
1936
|
+
```ts
|
|
1937
|
+
// Lock the endpoint to URLs YOU minted — kills resize-bombing (w/q enumeration).
|
|
1938
|
+
// SAME secret on loader + handler; it's server-only (inject from env, like a session secret).
|
|
1939
|
+
import { selfHostedLoader, signImageUrl } from "@nifrajs/image"
|
|
1940
|
+
import { createImageHandler } from "@nifrajs/image/server"
|
|
1941
|
+
|
|
1942
|
+
const resize = selfHostedLoader({ endpoint: "/_image", secret: env.IMAGE_SECRET })
|
|
1943
|
+
// → /_image?src=…&w=800&s=<hmac> (stable: an SSR-signed URL hydrates + caches identically)
|
|
1944
|
+
|
|
1945
|
+
const image = createImageHandler({ root: "./public", signing: { secret: env.IMAGE_SECRET } })
|
|
1946
|
+
// any request without a valid &s= (forged / tampered / expired) → 403, before any fetch or decode
|
|
1947
|
+
|
|
1948
|
+
// Time-limited links to private images (server-side only):
|
|
1949
|
+
const url = signImageUrl("/_image", { src: "/private/a.jpg", width: 800 }, {
|
|
1950
|
+
secret: env.IMAGE_SECRET,
|
|
1951
|
+
expiresIn: 300, // seconds → adds &exp=
|
|
1952
|
+
})
|
|
1953
|
+
```
|
|
1954
|
+
|
|
1955
|
+
Signatures over `(src, w, q)` are **stable** (no expiry), so an SSR-rendered
|
|
1956
|
+
`<Image>` srcset hydrates and caches identically. The secret makes a loader
|
|
1957
|
+
config **server-only** — inject it from `env` and never import it into a route/client
|
|
1958
|
+
module (same discipline as a [session secret](/docs/auth)). For time-limited links to
|
|
1959
|
+
private images, `signImageUrl(…, { expiresIn })` adds an `&exp=` the
|
|
1960
|
+
handler enforces.
|
|
1961
|
+
|
|
1962
|
+
### Reading intrinsic dimensions
|
|
1963
|
+
|
|
1964
|
+
Don't want to hardcode `width`/`height`? Read them from the file
|
|
1965
|
+
**header** in pure JS — no decode, no native dependency — and bake them into a build-time
|
|
1966
|
+
manifest. Supports PNG, JPEG (scans `SOFn`, skipping earlier segments), GIF, and WebP
|
|
1967
|
+
(VP8 / VP8L / VP8X).
|
|
1968
|
+
|
|
1969
|
+
```ts
|
|
1970
|
+
import { imageDimensions, readImageDimensions } from "@nifrajs/image"
|
|
1971
|
+
|
|
1972
|
+
// Pure-JS header read — PNG / JPEG / GIF / WebP. No decode, no codec, no deps.
|
|
1973
|
+
const info = await readImageDimensions(Bun.file("public/hero.jpg"))
|
|
1974
|
+
// → { width: 1200, height: 630, format: "jpeg" } (null if unrecognized)
|
|
1975
|
+
|
|
1976
|
+
// Build-time tooling: pre-read sizes into a manifest so <Image> is CLS-safe
|
|
1977
|
+
// without hardcoding width/height at every call site.
|
|
1978
|
+
imageDimensions(new Uint8Array(headerBytes))
|
|
1979
|
+
```
|
|
1980
|
+
|
|
1981
|
+
### Notes
|
|
1982
|
+
|
|
1983
|
+
-
|
|
1984
|
+
The loader is the seam: on the edge, resizing belongs at the CDN (which caches variants and
|
|
1985
|
+
negotiates `format=auto`); on a Node/Bun server you can self-host it with
|
|
1986
|
+
`@nifrajs/image/server` instead. Same `<Image>`, swap the loader.
|
|
1987
|
+
|
|
1988
|
+
-
|
|
1989
|
+
Always set `sizes` for images that aren't a fixed pixel width, so the browser
|
|
1990
|
+
selects the smallest sufficient `srcSet` candidate.
|
|
1991
|
+
|
|
1992
|
+
-
|
|
1993
|
+
`<Image>` ships for **all five adapters** (React, Preact, Vue, Solid,
|
|
1994
|
+
Svelte) — import from `@nifrajs/web-<framework>/image`. Each builds the same
|
|
1995
|
+
`<img>` from the agnostic `resolveImage` (non-React adapters map to
|
|
1996
|
+
lowercase HTML attrs via `toHtmlAttrs`).
|
|
1997
|
+
|
|
1998
|
+
## Nifra — i18n
|
|
1999
|
+
|
|
2000
|
+
> Locale negotiation + a tiny ICU message formatter on the platform Intl, framework-agnostic.
|
|
2001
|
+
|
|
2002
|
+
`@nifrajs/i18n` is framework-agnostic and dependency-free — locale negotiation plus a
|
|
2003
|
+
tiny ICU message formatter built on the platform `Intl`. It runs on every runtime;
|
|
2004
|
+
you bring JSON catalogs.
|
|
2005
|
+
|
|
2006
|
+
### Negotiate the locale
|
|
2007
|
+
|
|
2008
|
+
`negotiateLocale` picks the best supported locale from a cookie (an explicit choice),
|
|
2009
|
+
then `Accept-Language` (quality-ranked, with `fr-CA`→`fr`
|
|
2010
|
+
base-subtag fallback), else your default. Resolve it in a loader and return just that locale's
|
|
2011
|
+
messages.
|
|
2012
|
+
|
|
2013
|
+
```ts
|
|
2014
|
+
// In a loader: resolve the locale + return only that catalog's messages.
|
|
2015
|
+
import { negotiateLocale } from "@nifrajs/i18n"
|
|
2016
|
+
import { catalogs, locales } from "../catalogs"
|
|
2017
|
+
|
|
2018
|
+
export async function loader({ request }) {
|
|
2019
|
+
const q = new URL(request.url).searchParams.get("lang")
|
|
2020
|
+
const locale = q && locales.includes(q) ? q : negotiateLocale(request, { locales, defaultLocale: "en", cookie: "lang" })
|
|
2021
|
+
return { locale, messages: catalogs[locale] } // cookie → Accept-Language → default
|
|
2022
|
+
}
|
|
2023
|
+
```
|
|
2024
|
+
|
|
2025
|
+
### Format messages
|
|
2026
|
+
|
|
2027
|
+
`createFormatter(locale, messages)` → `{ t, n, d }`. `t`
|
|
2028
|
+
handles interpolation (`{name}`), `plural` (with `=N` exact
|
|
2029
|
+
cases and `#` → the number) and `select`, nested — via a hand-written
|
|
2030
|
+
parser + `Intl.PluralRules`. `n`/`d` are memoized
|
|
2031
|
+
` Intl.NumberFormat`/`DateTimeFormat`. A missing key returns the key.
|
|
2032
|
+
|
|
2033
|
+
```ts
|
|
2034
|
+
// catalogs: plain JSON per locale (ICU strings). Bring your own.
|
|
2035
|
+
export const catalogs = {
|
|
2036
|
+
en: { greeting: "Hello, {name}!", cart: "{count, plural, =0 {empty} one {# item} other {# items}}" },
|
|
2037
|
+
fr: { greeting: "Bonjour, {name} !", cart: "{count, plural, =0 {vide} one {# article} other {# articles}}" },
|
|
2038
|
+
}
|
|
2039
|
+
```
|
|
2040
|
+
|
|
2041
|
+
In React, provide it once and read it with `useT()`:
|
|
2042
|
+
|
|
2043
|
+
```ts
|
|
2044
|
+
// The page provides the formatter; components read it with useT().
|
|
2045
|
+
import { I18nProvider, useT } from "@nifrajs/web-react/i18n"
|
|
2046
|
+
|
|
2047
|
+
export default function Page({ data }) {
|
|
2048
|
+
return <I18nProvider locale={data.locale} messages={data.messages}><Body/></I18nProvider>
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function Body() {
|
|
2052
|
+
const { t, n, d } = useT()
|
|
2053
|
+
return <>
|
|
2054
|
+
<p>{t("greeting", { name: "Ada" })}</p>
|
|
2055
|
+
{/* ICU plural with # substitution */}
|
|
2056
|
+
<p>{t("cart", { count: 3 })}</p> {/* "3 items in your cart" */}
|
|
2057
|
+
<p>{t("price", { amount: n(1299.99, { style: "currency", currency: "EUR" }) })}</p>
|
|
2058
|
+
<p>{d(Date.now(), { dateStyle: "long" })}</p>
|
|
2059
|
+
</>
|
|
2060
|
+
}
|
|
2061
|
+
```
|
|
2062
|
+
|
|
2063
|
+
Both `locale` and `messages` are serializable, so SSR renders the
|
|
2064
|
+
negotiated catalog and the client rebuilds the same formatter on hydrate — no mismatch.
|
|
2065
|
+
Switching language re-navigates (a cookie or `?lang=`); the loader returns the new
|
|
2066
|
+
catalog and the page re-renders.
|
|
2067
|
+
|
|
2068
|
+
### Notes
|
|
2069
|
+
|
|
2070
|
+
- For many locales, load catalogs **lazily** per request — don't bundle every catalog.
|
|
2071
|
+
|
|
2072
|
+
- The supported ICU subset is interpolation + `plural`/`select`; use
|
|
2073
|
+
` n()`/`d()` for inline numbers/dates (no `{n, number}`
|
|
2074
|
+
skeletons). `Intl.MessageFormat` isn't widely available yet, so this is the
|
|
2075
|
+
portable core.
|
|
2076
|
+
|
|
2077
|
+
- `<I18nProvider>` + `useT()` ship for **all five adapters**
|
|
2078
|
+
(React, Preact, Vue, Solid, Svelte) — import from `@nifrajs/web-<framework>/i18n`;
|
|
2079
|
+
each is a thin binding over the agnostic `createFormatter`.
|
|
2080
|
+
|
|
2081
|
+
## Nifra — Edge & bindings
|
|
2082
|
+
|
|
2083
|
+
> Typed platform bindings in Nifra: c.env, Platform<Env>, c.waitUntil, KV/D1 on the edge.
|
|
2084
|
+
|
|
2085
|
+
On Cloudflare and other edge runtimes, your platform bindings (KV, D1, Durable Objects,
|
|
2086
|
+
secrets) reach handlers through a fully-typed `c.env`.
|
|
2087
|
+
|
|
2088
|
+
### Typed c.env
|
|
2089
|
+
|
|
2090
|
+
`server<Env>()` threads your `Env` type through the whole app:
|
|
2091
|
+
`c.env` is `Env` in every handler **and** in
|
|
2092
|
+
`derive`/`decorate` middleware — read `c.env.KV` directly, no
|
|
2093
|
+
cast. `app.fetch(req, { env })` / `toFetchHandler` type-check the
|
|
2094
|
+
bindings too. In `@nifrajs/web`, a route's loader/action gets the same typed
|
|
2095
|
+
`env`.
|
|
2096
|
+
|
|
2097
|
+
```ts
|
|
2098
|
+
// server<Env>() types the platform env end-to-end — c.env is your Env in every
|
|
2099
|
+
// handler AND in derive/decorate middleware, with no cast.
|
|
2100
|
+
interface Env { KV: KVNamespace; DB: D1Database }
|
|
2101
|
+
|
|
2102
|
+
const app = server<Env>()
|
|
2103
|
+
.get("/u/:id", async (c) => {
|
|
2104
|
+
const cached = await c.env.KV.get(c.params.id) // c.env is typed Env
|
|
2105
|
+
return cached ?? "miss"
|
|
2106
|
+
})
|
|
2107
|
+
|
|
2108
|
+
export default { fetch: (req: Request, env: Env, ctx: ExecutionContext) =>
|
|
2109
|
+
app.fetch(req, { env, waitUntil: ctx.waitUntil.bind(ctx) }) }
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
### Background work
|
|
2113
|
+
|
|
2114
|
+
`c.waitUntil(promise)` keeps work alive after the response is sent — cache writes,
|
|
2115
|
+
analytics, fan-out — without blocking the user. (Typing is not validation: platform bindings
|
|
2116
|
+
are trusted inputs; validate anything untrusted at the boundary.)
|
|
2117
|
+
|
|
2118
|
+
```ts
|
|
2119
|
+
// c.waitUntil keeps background work alive past the response (analytics, cache writes).
|
|
2120
|
+
app.post("/event", (c) => {
|
|
2121
|
+
c.waitUntil(c.env.KV.put("last", Date.now().toString()))
|
|
2122
|
+
return { ok: true } // returns immediately; the put finishes in the background
|
|
2123
|
+
})
|
|
2124
|
+
```
|
|
2125
|
+
|
|
2126
|
+
### Validation on the edge
|
|
2127
|
+
|
|
2128
|
+
Edge runtimes block dynamic code generation (`new Function`), which trips many
|
|
2129
|
+
schema libraries. Nifra's `t` handles it transparently: it compiles a fast
|
|
2130
|
+
validator on Bun and Node, and falls back to an eval-free checker on Cloudflare Workers,
|
|
2131
|
+
Vercel Edge, and Deno Deploy — **the same routes validate everywhere**, with no
|
|
2132
|
+
edge-specific schema module. Because core validates any
|
|
2133
|
+
[
|
|
2134
|
+
Standard Schema
|
|
2135
|
+
](https://standardschema.dev)
|
|
2136
|
+
, you can also bring Zod, Valibot, or ArkType (all eval-free) — only `t` also
|
|
2137
|
+
emits OpenAPI from the same definition.
|
|
2138
|
+
|
|
2139
|
+
### Scheduled (cron)
|
|
2140
|
+
|
|
2141
|
+
Pass `scheduled` to `toFetchHandler` to also export a Workers cron
|
|
2142
|
+
handler for a `[triggers]` schedule. It gets the platform controller plus the same
|
|
2143
|
+
typed `env` and `waitUntil` as your request handlers.
|
|
2144
|
+
|
|
2145
|
+
```ts
|
|
2146
|
+
import { toFetchHandler } from "@nifrajs/core"
|
|
2147
|
+
import { app } from "./app"
|
|
2148
|
+
|
|
2149
|
+
// Export fetch + a cron handler. Wire the schedule in wrangler.toml: [triggers] crons = ["0 * * * *"]
|
|
2150
|
+
export default toFetchHandler(app, {
|
|
2151
|
+
scheduled: (controller, { env, waitUntil }) =>
|
|
2152
|
+
waitUntil(env.KV.put("last-run", String(controller.scheduledTime))),
|
|
2153
|
+
})
|
|
2154
|
+
```
|
|
2155
|
+
|
|
2156
|
+
## Nifra — Deployment
|
|
2157
|
+
|
|
2158
|
+
> One Nifra app, one command per target — Bun, Node (Docker), Deno Deploy, Cloudflare Pages, Vercel Edge.
|
|
2159
|
+
|
|
2160
|
+
`app.fetch(request)` is a pure Web-standard handler, so the same app runs
|
|
2161
|
+
anywhere. Pick a runtime; the code doesn't change.
|
|
2162
|
+
|
|
2163
|
+
### One command per target
|
|
2164
|
+
|
|
2165
|
+
`create-nifra`'s `site` template ships every target's build + server entry
|
|
2166
|
+
and config. Pass `--deploy <target>` to make one the default — it points
|
|
2167
|
+
`build`/`deploy` at that target and fills in your project name. The
|
|
2168
|
+
per-target `build:*`/`deploy:*` scripts stay, so you can switch any time.
|
|
2169
|
+
|
|
2170
|
+
```ts
|
|
2171
|
+
# Scaffold the multi-target site with a chosen default deploy target:
|
|
2172
|
+
bun create nifra my-app --deploy vercel # or: bun | node | deno | cf-pages
|
|
2173
|
+
# add --framework to pick the UI too (react default):
|
|
2174
|
+
bun create nifra my-app --framework svelte --deploy vercel
|
|
2175
|
+
|
|
2176
|
+
cd my-app && bun install
|
|
2177
|
+
bun run dev # local preview
|
|
2178
|
+
bun run build # builds for the chosen target
|
|
2179
|
+
bun run deploy # runs that target's deploy CLI (you stay logged-in to the vendor)
|
|
2180
|
+
|
|
2181
|
+
# Or add --ci github to also emit a deploy-on-push GitHub Actions workflow:
|
|
2182
|
+
bun create nifra my-app --deploy cf-pages --ci github
|
|
2183
|
+
# → .github/workflows/deploy.yml (builds on every push/PR, deploys on push to main)
|
|
2184
|
+
```
|
|
2185
|
+
|
|
2186
|
+
target
|
|
2187
|
+
build →
|
|
2188
|
+
deploy
|
|
2189
|
+
config scaffolded
|
|
2190
|
+
|
|
2191
|
+
Bun (flagship)
|
|
2192
|
+
|
|
2193
|
+
`dist-bun/`
|
|
2194
|
+
|
|
2195
|
+
`bun run start` (any host)
|
|
2196
|
+
|
|
2197
|
+
—
|
|
2198
|
+
|
|
2199
|
+
Node
|
|
2200
|
+
|
|
2201
|
+
`dist-node/`
|
|
2202
|
+
|
|
2203
|
+
`docker build … && docker run`
|
|
2204
|
+
|
|
2205
|
+
`Dockerfile` + `.dockerignore`
|
|
2206
|
+
|
|
2207
|
+
Deno Deploy
|
|
2208
|
+
|
|
2209
|
+
`dist-deno/`
|
|
2210
|
+
|
|
2211
|
+
`deployctl deploy`
|
|
2212
|
+
|
|
2213
|
+
`deno.json`
|
|
2214
|
+
|
|
2215
|
+
Cloudflare Pages
|
|
2216
|
+
|
|
2217
|
+
`dist/`
|
|
2218
|
+
|
|
2219
|
+
`wrangler pages deploy`
|
|
2220
|
+
|
|
2221
|
+
`wrangler.toml`
|
|
2222
|
+
|
|
2223
|
+
Vercel Edge
|
|
2224
|
+
|
|
2225
|
+
`.vercel/output/`
|
|
2226
|
+
|
|
2227
|
+
`vercel deploy --prebuilt`
|
|
2228
|
+
|
|
2229
|
+
Build Output API v3
|
|
2230
|
+
|
|
2231
|
+
Nifra never runs the deploy or enters your cloud credentials — it scaffolds the config + a
|
|
2232
|
+
`deploy` script that shells out to the vendor CLI you've already authed.
|
|
2233
|
+
|
|
2234
|
+
### CI: deploy on push
|
|
2235
|
+
|
|
2236
|
+
Add `--ci github` to emit a `.github/workflows/deploy.yml` tuned to the
|
|
2237
|
+
chosen target — it builds on every push/PR and deploys on a push to `main`.
|
|
2238
|
+
**cf-pages** uses `cloudflare/wrangler-action`, **vercel** the prebuilt
|
|
2239
|
+
`vercel deploy`, **deno** `deployctl` (OIDC). The workflow's header
|
|
2240
|
+
comment lists the exact repo secrets to set (e.g. `CLOUDFLARE_API_TOKEN`,
|
|
2241
|
+
`VERCEL_TOKEN`). Self-hosted **bun**/**node** have no universal push-to-deploy,
|
|
2242
|
+
so their workflow builds + uploads the bundle as an artifact and leaves a clearly-marked,
|
|
2243
|
+
host-specific deploy step for you to fill in (Fly, a registry push, SSH, …).
|
|
2244
|
+
|
|
2245
|
+
### Bun (its home)
|
|
2246
|
+
|
|
2247
|
+
`app.listen(3000)` — the native Bun server. Sits at the raw `Bun.serve`
|
|
2248
|
+
ceiling (see [benchmarks](/benchmarks)).
|
|
2249
|
+
|
|
2250
|
+
**Every core:** Bun is single-threaded per process — for multi-core boxes,
|
|
2251
|
+
spawn one process per core, each binding the same port with
|
|
2252
|
+
`app.listen(PORT, { reusePort: true })`; the kernel load-balances connections
|
|
2253
|
+
across them (Linux balances ~evenly). A supervisor that spawns + restarts workers is ~20
|
|
2254
|
+
lines — see `examples/cluster.ts`. Anything shared across workers (rate limits,
|
|
2255
|
+
sessions, pub/sub) needs a shared store, as in any multi-instance deploy.
|
|
2256
|
+
|
|
2257
|
+
### Node & Deno
|
|
2258
|
+
|
|
2259
|
+
-
|
|
2260
|
+
`@nifrajs/node` — `serve(app, { port })` bridges to `node:http`.
|
|
2261
|
+
|
|
2262
|
+
-
|
|
2263
|
+
`@nifrajs/deno` — serves `app.fetch` on `Deno.serve`.
|
|
2264
|
+
|
|
2265
|
+
Self-hosting (no CDN in front)? Hand `@nifrajs/node`'s `serve` a
|
|
2266
|
+
`static` mount and it serves the client bundle from disk — traversal-guarded,
|
|
2267
|
+
content-typed, with an immutable cache — before the app runs, leaving the SSR fast path
|
|
2268
|
+
untouched: `serve(app, {'{ port, static: { dir: new URL("./assets/", import.meta.url) } }'})`.
|
|
2269
|
+
On Cloudflare/Vercel the platform serves assets, so you don't need it there.
|
|
2270
|
+
|
|
2271
|
+
### Cloudflare Workers / Pages (the edge)
|
|
2272
|
+
|
|
2273
|
+
`buildServer` bundles with edge conditions (`workerd`,
|
|
2274
|
+
`edge-light`) into a `_worker.js`; `toFetchHandler(app)` is
|
|
2275
|
+
the handler. For Pages, a `_routes.json` serves the client bundle statically.
|
|
2276
|
+
SSR verified on real workerd — **this very site runs there**.
|
|
2277
|
+
|
|
2278
|
+
```ts
|
|
2279
|
+
// _worker.ts — the edge SSR entry
|
|
2280
|
+
import { toFetchHandler } from "@nifrajs/core"
|
|
2281
|
+
import { createWebApp } from "@nifrajs/web"
|
|
2282
|
+
import { reactAdapter } from "@nifrajs/web-react"
|
|
2283
|
+
import { clientEntry, manifest } from "./server-manifest" // generated by buildServer
|
|
2284
|
+
|
|
2285
|
+
const app = createWebApp({ adapter: reactAdapter, manifest, clientEntry })
|
|
2286
|
+
export default toFetchHandler(app) // a Workers/Pages fetch handler
|
|
2287
|
+
|
|
2288
|
+
// build.ts — buildClient (→ /assets) + buildServer (edge conditions → _worker.js)
|
|
2289
|
+
import { buildClient, buildServer } from "@nifrajs/web/build"
|
|
2290
|
+
```
|
|
2291
|
+
|
|
2292
|
+
Deploy: `wrangler pages deploy dist` (Pages) or `wrangler deploy`
|
|
2293
|
+
(Workers). Deno Deploy and Vercel Edge reuse the same build with their conditions.
|
|
2294
|
+
|
|
2295
|
+
## Nifra — CLI
|
|
2296
|
+
|
|
2297
|
+
> The zero-config Nifra CLI (`nifra dev`, `nifra build`, `nifra start`): true HMR, hashed client builds, and SSR serve.
|
|
2298
|
+
|
|
2299
|
+
`Nifra` is zero-config: it reads `routes/`, `framework.ts`, and
|
|
2300
|
+
(optionally) `backend.ts` from your project and wires the right
|
|
2301
|
+
`@nifrajs/web` entrypoint — no `dev.ts`/`build.ts`/
|
|
2302
|
+
`server.ts` to hand-write. (`create-nifra` scaffolds the conventions.)
|
|
2303
|
+
|
|
2304
|
+
```ts
|
|
2305
|
+
nifra dev # true-HMR dev server (Vite middleware + nifra SSR) — http://localhost:3000
|
|
2306
|
+
nifra build # bundle the client (content-hashed, code-split) + write dist/manifest.json (incl. CSS)
|
|
2307
|
+
nifra start # serve the built client + SSR on Bun (assets, <link> stylesheets, matched-route preload)
|
|
2308
|
+
|
|
2309
|
+
# flags: --port <n> (dev/start) · --out <dir> (build/start) · --poll (dev; containers/sandboxes)
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
### The conventions
|
|
2313
|
+
|
|
2314
|
+
Three files at the project root. `nifra dev` runs the Vite-backed
|
|
2315
|
+
[HMR dev server](/docs/dev); `nifra build` runs the Bun-native production
|
|
2316
|
+
build (content-hashed, code-split, CSS bundled); `nifra start` serves the built
|
|
2317
|
+
client and SSRs on Bun — assets with immutable caching + correct MIME, the matched route's
|
|
2318
|
+
chunks preloaded, and the CSS bundle linked in every `<head>`.
|
|
2319
|
+
|
|
2320
|
+
```ts
|
|
2321
|
+
my-app/
|
|
2322
|
+
routes/ # file-based routes (index.tsx, _layout.tsx, [id].tsx, …)
|
|
2323
|
+
framework.ts # adapter + clientModule + plugins (above)
|
|
2324
|
+
backend.ts # export const backend = server()... (optional — the typed contract)
|
|
2325
|
+
```
|
|
2326
|
+
|
|
2327
|
+
### framework.ts — naming the framework once
|
|
2328
|
+
|
|
2329
|
+
The single framework-specific file. Only `adapter` + `clientModule` are
|
|
2330
|
+
required; the plugin/condition fields are extras the compiler frameworks need (React/Preact's
|
|
2331
|
+
JSX is Bun-native, so they need none beyond the Vite plugin for dev HMR).
|
|
2332
|
+
|
|
2333
|
+
```ts
|
|
2334
|
+
// framework.ts — the one place an app names its framework. The CLI reads these:
|
|
2335
|
+
import react from "@vitejs/plugin-react"
|
|
2336
|
+
import { reactAdapter } from "@nifrajs/web-react"
|
|
2337
|
+
|
|
2338
|
+
export const adapter = reactAdapter
|
|
2339
|
+
export const clientModule = "@nifrajs/web-react/client"
|
|
2340
|
+
export const vitePlugins = [react()] // dev HMR (Fast Refresh)
|
|
2341
|
+
// Vue/Svelte/Solid also export:
|
|
2342
|
+
// clientPlugins = [vueBunPlugin("dom")] // compile routes for the client build
|
|
2343
|
+
// serverPlugins = [vueBunPlugin("ssr")] // compile routes for SSR (nifra start registers them)
|
|
2344
|
+
// conditions = ["solid"] // Solid: resolve solid-js to its source
|
|
2345
|
+
// define = { __VUE_OPTIONS_API__: "true", ... } // Vue feature flags
|
|
2346
|
+
```
|
|
2347
|
+
|
|
2348
|
+
### Scope
|
|
2349
|
+
|
|
2350
|
+
`nifra build` + `nifra start` target a Bun (or Node) long-running server —
|
|
2351
|
+
the common self-hosted case. For the edge (Cloudflare Workers / Vercel Edge / Deno Deploy),
|
|
2352
|
+
use the per-target build entries from `create-nifra`'s `site` template (see
|
|
2353
|
+
[Deployment](/docs/deployment)) — `buildServer` bundles a disk-less
|
|
2354
|
+
worker those targets need. A `nifra build --target` for edge is a future addition.
|
|
2355
|
+
|
|
2356
|
+
## Nifra — Dev & HMR
|
|
2357
|
+
|
|
2358
|
+
> Use `nifra dev` for a Vite-backed state-preserving HMR loop, or `@nifrajs/web/dev` for a Bun-native live-reload loop. Production builds use Bun.
|
|
2359
|
+
|
|
2360
|
+
Nifra gives you two local development loops. Use `nifra dev` for
|
|
2361
|
+
state-preserving UI edits, or use `@nifrajs/web/dev` when you want a Bun-only
|
|
2362
|
+
live-reload server. Both serve your real SSR app locally.
|
|
2363
|
+
|
|
2364
|
+
### Two loops, same app
|
|
2365
|
+
|
|
2366
|
+
import
|
|
2367
|
+
watcher → update
|
|
2368
|
+
dependencies
|
|
2369
|
+
use when
|
|
2370
|
+
|
|
2371
|
+
`@nifrajs/web/dev`
|
|
2372
|
+
|
|
2373
|
+
rebuild + full-page live-reload
|
|
2374
|
+
none (Bun only)
|
|
2375
|
+
default; zero extra deps
|
|
2376
|
+
|
|
2377
|
+
`@nifrajs/web/vite`
|
|
2378
|
+
|
|
2379
|
+
true HMR (Fast Refresh / framework HMR)
|
|
2380
|
+
|
|
2381
|
+
`vite` + your framework's plugin
|
|
2382
|
+
|
|
2383
|
+
state-preserving UI iteration
|
|
2384
|
+
|
|
2385
|
+
### State-preserving HMR
|
|
2386
|
+
|
|
2387
|
+
Use `createViteDevServer` when you want component edits to update the browser
|
|
2388
|
+
without a full page reload. Pass the official plugin for your UI framework, keep the same
|
|
2389
|
+
Nifra routes and loaders, and run the dev server during local development.
|
|
2390
|
+
|
|
2391
|
+
```ts
|
|
2392
|
+
// dev.ts — state-preserving HMR for supported UI adapters
|
|
2393
|
+
import react from "@vitejs/plugin-react" // your framework's official Vite plugin
|
|
2394
|
+
import { createWebApp } from "@nifrajs/web"
|
|
2395
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
2396
|
+
import { createViteDevServer } from "@nifrajs/web/vite"
|
|
2397
|
+
import { reactAdapter } from "@nifrajs/web-react"
|
|
2398
|
+
import { backend } from "./backend"
|
|
2399
|
+
|
|
2400
|
+
const routesDir = `${import.meta.dir}/routes`
|
|
2401
|
+
const server = await createViteDevServer({
|
|
2402
|
+
root: import.meta.dir,
|
|
2403
|
+
routesDir,
|
|
2404
|
+
clientModule: "@nifrajs/web-react/client",
|
|
2405
|
+
plugins: [react()], // Vue: @vitejs/plugin-vue, Svelte: …, etc.
|
|
2406
|
+
port: Number(Bun.env.PORT ?? 3000),
|
|
2407
|
+
createApp: (clientEntry, importQuery) =>
|
|
2408
|
+
createWebApp({
|
|
2409
|
+
adapter: reactAdapter,
|
|
2410
|
+
manifest: discoverRoutes(routesDir, { importQuery }),
|
|
2411
|
+
clientEntry,
|
|
2412
|
+
api: inProcessClient(backend),
|
|
2413
|
+
}),
|
|
2414
|
+
})
|
|
2415
|
+
```
|
|
2416
|
+
|
|
2417
|
+
Start it with `bun run dev`. The server reads your route source directly, so you
|
|
2418
|
+
can edit routes, components, loaders, actions, and styles in one local loop.
|
|
2419
|
+
|
|
2420
|
+
### Framework coverage
|
|
2421
|
+
|
|
2422
|
+
All five adapters have a dev setup. Pass the framework's official Vite plugin, and for Vue,
|
|
2423
|
+
Svelte, and Solid, preload the matching Nifra compiler plugin for server rendering.
|
|
2424
|
+
|
|
2425
|
+
framework
|
|
2426
|
+
client (Vite plugin)
|
|
2427
|
+
SSR compile
|
|
2428
|
+
local state on edit
|
|
2429
|
+
|
|
2430
|
+
React
|
|
2431
|
+
|
|
2432
|
+
`@vitejs/plugin-react`
|
|
2433
|
+
|
|
2434
|
+
Bun-native
|
|
2435
|
+
preserved (Fast Refresh)
|
|
2436
|
+
|
|
2437
|
+
Preact
|
|
2438
|
+
|
|
2439
|
+
`@preact/preset-vite`
|
|
2440
|
+
|
|
2441
|
+
Bun-native
|
|
2442
|
+
preserved (prefresh)
|
|
2443
|
+
|
|
2444
|
+
Vue
|
|
2445
|
+
|
|
2446
|
+
`@vitejs/plugin-vue`
|
|
2447
|
+
|
|
2448
|
+
`vueBunPlugin` preload
|
|
2449
|
+
|
|
2450
|
+
preserved (rerender)
|
|
2451
|
+
|
|
2452
|
+
Solid
|
|
2453
|
+
|
|
2454
|
+
`vite-plugin-solid` (`{ ssr: true }`)
|
|
2455
|
+
|
|
2456
|
+
`solidBunPlugin` preload
|
|
2457
|
+
|
|
2458
|
+
resets (solid-refresh)
|
|
2459
|
+
|
|
2460
|
+
Svelte
|
|
2461
|
+
|
|
2462
|
+
`@sveltejs/vite-plugin-svelte`
|
|
2463
|
+
|
|
2464
|
+
`svelteBunPlugin` preload
|
|
2465
|
+
|
|
2466
|
+
resets (svelte HMR)
|
|
2467
|
+
|
|
2468
|
+
For **React, Preact, and Vue**, an edit hot-swaps with component state preserved.
|
|
2469
|
+
For **Solid and Svelte**, the module hot-swaps live (no full reload — scroll,
|
|
2470
|
+
route, and other components are kept), but the edited component re-runs, so its own local state
|
|
2471
|
+
resets. For Solid, use `solid({ ssr: true })` and the `"solid"`
|
|
2472
|
+
resolve condition. Working examples for all five live in `examples/hmr-*`.
|
|
2473
|
+
|
|
2474
|
+
### The Fast Refresh boundary rule
|
|
2475
|
+
|
|
2476
|
+
React Fast Refresh (and the other frameworks' equivalents) only hot-swap a module when
|
|
2477
|
+
_every_ export is a component. Nifra route files co-locate `loader`,
|
|
2478
|
+
`action`, and `meta` next to the component — so a route file isn't a
|
|
2479
|
+
refresh boundary, and saving it does a clean full reload. Keep the view in a child component
|
|
2480
|
+
and edits hot-swap with state intact.
|
|
2481
|
+
|
|
2482
|
+
```ts
|
|
2483
|
+
// routes/index.tsx — NOT a Fast Refresh boundary (exports loader/meta), so a save
|
|
2484
|
+
// here does a clean full reload. Keep the view in a child component:
|
|
2485
|
+
export const meta = { title: "Home" }
|
|
2486
|
+
export async function loader({ api }) { /* … */ }
|
|
2487
|
+
export default function Home(props) {
|
|
2488
|
+
return <Counter message={props.data.message} /> // ← edit Counter.tsx for state-preserving HMR
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// components/Counter.tsx — component-only module → a Fast Refresh boundary. Editing this file's
|
|
2492
|
+
// JSX hot-swaps it with useState/useReducer state PRESERVED (no reload).
|
|
2493
|
+
import { useState } from "react"
|
|
2494
|
+
export function Counter(props: { message: string }) {
|
|
2495
|
+
const [count, setCount] = useState(0)
|
|
2496
|
+
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>
|
|
2497
|
+
}
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
### Containers & sandboxes
|
|
2501
|
+
|
|
2502
|
+
In Docker, networked volumes, and some sandboxes, pass
|
|
2503
|
+
`poll: true` (or set `CHOKIDAR_USEPOLLING=1`) to use a polling watcher
|
|
2504
|
+
instead.
|
|
2505
|
+
|
|
2506
|
+
### The zero-dep alternative
|
|
2507
|
+
|
|
2508
|
+
`@nifrajs/web/dev` is a self-contained server: it builds the client on boot,
|
|
2509
|
+
serves SSR, watches the routes, and does a full-page reload on change.
|
|
2510
|
+
|
|
2511
|
+
```ts
|
|
2512
|
+
// dev.ts — Bun-native live reload
|
|
2513
|
+
import { createDevServer } from "@nifrajs/web/dev"
|
|
2514
|
+
// builds the client on boot, serves SSR, watches routes, full-page live-reload on change.
|
|
2515
|
+
const server = await createDevServer({ routesDir, outDir, clientModule, createApp })
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
### Styling (CSS)
|
|
2519
|
+
|
|
2520
|
+
Import a stylesheet anywhere — `import "./app.css"` in a route, layout, or component.
|
|
2521
|
+
In **dev**, Vite injects and hot-reloads it (no page reload). In
|
|
2522
|
+
**production**, `buildClient` bundles + minifies + content-hashes the
|
|
2523
|
+
CSS and records it as `manifest.css`; pass that to `createWebApp`'s
|
|
2524
|
+
`styles` and Nifra links it in every page's `<head>` as a
|
|
2525
|
+
render-blocking `<link rel="stylesheet">` (no FOUC). Serve `.css`
|
|
2526
|
+
assets as `text/css`.
|
|
2527
|
+
|
|
2528
|
+
```ts
|
|
2529
|
+
// Import CSS anywhere in a route or component — a global stylesheet (in _layout) or local:
|
|
2530
|
+
// routes/_layout.tsx
|
|
2531
|
+
import "./app.css"
|
|
2532
|
+
|
|
2533
|
+
// Dev: Vite injects + HMRs the CSS (no reload). Production: buildClient bundles + content-hashes it
|
|
2534
|
+
// into manifest.css (aggregate) + manifest.routeStyles (per route); wire both into your server:
|
|
2535
|
+
// server.ts
|
|
2536
|
+
const assets = JSON.parse(await Bun.file("dist/manifest.json").text())
|
|
2537
|
+
export const app = createWebApp({
|
|
2538
|
+
adapter, manifest, clientEntry: assets.entry,
|
|
2539
|
+
styles: assets.css, // aggregate — the safe fallback
|
|
2540
|
+
routeStyles: assets.routeStyles, // per route — each page links only its chain's CSS
|
|
2541
|
+
})
|
|
2542
|
+
// → <link rel="stylesheet"> for just the matched route's CSS in <head>. Serve .css as text/css.
|
|
2543
|
+
```
|
|
2544
|
+
|
|
2545
|
+
This is the _global imports_ tier: one bundled stylesheet linked on every page (the
|
|
2546
|
+
common case — a global stylesheet or Tailwind output).
|
|
2547
|
+
|
|
2548
|
+
#### Scoped styles — CSS Modules & SFC <style>
|
|
2549
|
+
|
|
2550
|
+
For component-local styles you have two collision-free options, both bundled into that same
|
|
2551
|
+
stylesheet:
|
|
2552
|
+
|
|
2553
|
+
-
|
|
2554
|
+
**CSS Modules** (`*.module.css`) — works in any framework.
|
|
2555
|
+
`buildClient` (Bun) and the dev server (Vite) both hash the class names and hand
|
|
2556
|
+
you a `Record<string, string>` map. Add an ambient declaration once so
|
|
2557
|
+
TypeScript types the import.
|
|
2558
|
+
|
|
2559
|
+
-
|
|
2560
|
+
**SFC `<style scoped>`** (Vue) and
|
|
2561
|
+
**`<style>`** (Svelte — scoped by default) — the framework's
|
|
2562
|
+
compiler plugin rewrites the selectors to a unique scope (`[data-v-…]` /
|
|
2563
|
+
`.svelte-…`) and bakes the matching marker into the SSR markup, so the server HTML
|
|
2564
|
+
already matches the bundled CSS. No runtime, no FOUC.
|
|
2565
|
+
|
|
2566
|
+
```ts
|
|
2567
|
+
// CSS Modules — *.module.css gives a hashed, collision-free class map:
|
|
2568
|
+
// Counter.module.css → .box { padding: 1rem }
|
|
2569
|
+
import styles from "./Counter.module.css"
|
|
2570
|
+
// then: <div className={styles.box}>…</div> → class="box_a1b2c3" at runtime
|
|
2571
|
+
|
|
2572
|
+
// TS needs ambient types for CSS imports — declare them once (e.g. src/css.d.ts):
|
|
2573
|
+
declare module "*.module.css" { const c: Readonly<Record<string, string>>; export default c }
|
|
2574
|
+
declare module "*.css" {}
|
|
2575
|
+
|
|
2576
|
+
// Vue / Svelte SFCs — <style scoped> just works. The framework compiler scopes the selectors
|
|
2577
|
+
// (#page[data-v-…] for Vue, .page.svelte-… for Svelte) and folds them into the same app stylesheet.
|
|
2578
|
+
```
|
|
2579
|
+
|
|
2580
|
+
#### Per-route CSS splitting
|
|
2581
|
+
|
|
2582
|
+
`buildClient` splits CSS per route: each page links only its layout chain and its
|
|
2583
|
+
own stylesheet. Pass `manifest.routeStyles` to `createWebApp` alongside
|
|
2584
|
+
`styles`, and Nifra links the matched route's CSS during SSR. In
|
|
2585
|
+
**dev**, Vite injects per-module CSS.
|
|
2586
|
+
|
|
2587
|
+
## Nifra — Databases
|
|
2588
|
+
|
|
2589
|
+
> Nifra is database-agnostic: use SQLite, Postgres, or any ORM (Drizzle, Prisma, Kysely) from your handlers and loaders, on Bun, Node, Deno, and the edge.
|
|
2590
|
+
|
|
2591
|
+
Nifra's core bundles **no database layer**. Like Hono or Elysia, it owns the HTTP boundary —
|
|
2592
|
+
routing, validation, the typed client — and your handlers, loaders, and actions are just
|
|
2593
|
+
functions. Import any database client or ORM and call it there. SQLite, Postgres, MySQL,
|
|
2594
|
+
MongoDB, Drizzle, Prisma, Kysely — all work, because none of them are Nifra's concern.
|
|
2595
|
+
|
|
2596
|
+
### Scaffold it (recommended)
|
|
2597
|
+
|
|
2598
|
+
You don't have to wire Drizzle by hand. `bun create Nifra` takes a
|
|
2599
|
+
`--db` flag and generates a correct, production-grade data layer — schema, typed
|
|
2600
|
+
client, `drizzle.config.ts`, the `db:generate`/`db:migrate`
|
|
2601
|
+
scripts, and `.env.example` — plus a Database section in the app's
|
|
2602
|
+
`AGENTS.md`, so a human _or a coding agent_ starts from the right setup instead
|
|
2603
|
+
of inventing one.
|
|
2604
|
+
|
|
2605
|
+
```ts
|
|
2606
|
+
# Scaffold an app with the Drizzle layer already wired (schema, typed client, migrations, scripts, .env):
|
|
2607
|
+
bun create nifra notes-api --db drizzle-libsql # SQLite everywhere incl. the edge (or Turso)
|
|
2608
|
+
bun create nifra notes-api --db drizzle-postgres # Postgres (postgres.js) on Bun/Node/Deno
|
|
2609
|
+
bun create nifra notes-api --db drizzle-sqlite # Bun's built-in bun:sqlite, local file
|
|
2610
|
+
```
|
|
2611
|
+
|
|
2612
|
+
`drizzle-libsql` is the default recommendation — it's the one SQLite client that runs
|
|
2613
|
+
on every runtime, including the edge (a local file in dev, [Turso](https://turso.tech)
|
|
2614
|
+
in prod).
|
|
2615
|
+
|
|
2616
|
+
### Inject the client once, read it as c.db
|
|
2617
|
+
|
|
2618
|
+
The whole integration is one seam: open the DB client once, `decorate` it onto the
|
|
2619
|
+
server, and every handler reads it as a typed `c.db`. This is how the scaffold wires
|
|
2620
|
+
it — and it keeps the client out of each handler's imports.
|
|
2621
|
+
|
|
2622
|
+
```ts
|
|
2623
|
+
import { server } from "@nifrajs/core"
|
|
2624
|
+
import { desc } from "drizzle-orm"
|
|
2625
|
+
import { db, notes } from "./db" // your Drizzle client + schema (what the scaffold generates)
|
|
2626
|
+
|
|
2627
|
+
// decorate() hangs the client on the context ONCE — every handler then reads it as `c.db`, fully typed.
|
|
2628
|
+
export const app = server()
|
|
2629
|
+
.decorate("db", db)
|
|
2630
|
+
.get("/notes", async (c) => c.db.select().from(notes).orderBy(desc(notes.createdAt)))
|
|
2631
|
+
|
|
2632
|
+
export type App = typeof app
|
|
2633
|
+
```
|
|
2634
|
+
|
|
2635
|
+
### SQLite
|
|
2636
|
+
|
|
2637
|
+
`bun:sqlite` is built into Bun (zero install); use `better-sqlite3` on
|
|
2638
|
+
Node, or `libSQL`/Turso anywhere. Open the connection once and query from your
|
|
2639
|
+
routes:
|
|
2640
|
+
|
|
2641
|
+
```ts
|
|
2642
|
+
import { server } from "@nifrajs/core"
|
|
2643
|
+
import { Database } from "bun:sqlite" // Node: better-sqlite3 · edge: Turso/libSQL
|
|
2644
|
+
|
|
2645
|
+
// Open the connection ONCE at module scope (the driver pools internally).
|
|
2646
|
+
const db = new Database("app.db")
|
|
2647
|
+
const byId = db.query<{ id: number; name: string }>("SELECT * FROM users WHERE id = ?")
|
|
2648
|
+
|
|
2649
|
+
export const app = server().get("/users/:id", (c) =>
|
|
2650
|
+
// Parameterized (?) — never interpolate user input into SQL.
|
|
2651
|
+
byId.get(Number(c.params.id)) ?? new Response("Not found", { status: 404 }),
|
|
2652
|
+
)
|
|
2653
|
+
```
|
|
2654
|
+
|
|
2655
|
+
### Postgres with Drizzle (Recommended)
|
|
2656
|
+
|
|
2657
|
+
Drizzle gives you a **fully typed schema**, auto-generated SQL migrations, and a composable query
|
|
2658
|
+
builder. Define your schema once, run `bunx drizzle-kit generate` to create
|
|
2659
|
+
migrations, and Nifra's loaders use fully-typed queries:
|
|
2660
|
+
|
|
2661
|
+
```ts
|
|
2662
|
+
// schema.ts — define your schema (source of truth)
|
|
2663
|
+
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"
|
|
2664
|
+
export const users = pgTable("users", {
|
|
2665
|
+
id: serial("id").primaryKey(),
|
|
2666
|
+
name: text("name").notNull(),
|
|
2667
|
+
email: text("email").notNull(),
|
|
2668
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
2669
|
+
})
|
|
2670
|
+
|
|
2671
|
+
// drizzle.config.ts — drizzle-kit config
|
|
2672
|
+
import { defineConfig } from "drizzle-kit"
|
|
2673
|
+
export default defineConfig({
|
|
2674
|
+
schema: "./schema.ts",
|
|
2675
|
+
out: "./migrations",
|
|
2676
|
+
dialect: "postgresql",
|
|
2677
|
+
})
|
|
2678
|
+
|
|
2679
|
+
// db.ts — initialize and migrate
|
|
2680
|
+
import { drizzle } from "drizzle-orm/postgres-js"
|
|
2681
|
+
import postgres from "postgres"
|
|
2682
|
+
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
|
2683
|
+
const sql = postgres(process.env.DATABASE_URL!)
|
|
2684
|
+
export const db = drizzle(sql, { schema: import("./schema") })
|
|
2685
|
+
await migrate(db, { migrationsFolder: "./migrations" }) // on app start
|
|
2686
|
+
|
|
2687
|
+
// routes/users.ts — use your typed schema in a loader
|
|
2688
|
+
import { type LoaderArgs } from "@nifrajs/web"
|
|
2689
|
+
import { eq } from "drizzle-orm"
|
|
2690
|
+
import { db } from "../db"
|
|
2691
|
+
import { users } from "../db/schema"
|
|
2692
|
+
|
|
2693
|
+
export async function loader({ params }: LoaderArgs) {
|
|
2694
|
+
const [user] = await db.select().from(users).where(eq(users.id, Number(params.id)))
|
|
2695
|
+
return { user }
|
|
2696
|
+
}
|
|
2697
|
+
export default function UserPage({ data }: any) { return <h1>{data.user?.name}</h1> }
|
|
2698
|
+
```
|
|
2699
|
+
|
|
2700
|
+
**Workflow:**
|
|
2701
|
+
|
|
2702
|
+
-
|
|
2703
|
+
Edit `schema.ts` — change a column name, add a table, whatever
|
|
2704
|
+
|
|
2705
|
+
-
|
|
2706
|
+
Run `bunx drizzle-kit generate` — Drizzle writes a new SQL migration to
|
|
2707
|
+
`migrations/`
|
|
2708
|
+
|
|
2709
|
+
-
|
|
2710
|
+
Call `migrate(db, { migrationsFolder })` on app start — migrations run
|
|
2711
|
+
automatically, once each
|
|
2712
|
+
|
|
2713
|
+
-
|
|
2714
|
+
Your loaders use the typed schema; no manual table names or inference
|
|
2715
|
+
|
|
2716
|
+
### Request-scoped multi-tenancy (Postgres RLS + Drizzle)
|
|
2717
|
+
|
|
2718
|
+
For multi-tenant data, enforce isolation at the **database**, not in every `WHERE`
|
|
2719
|
+
clause (one forgotten filter is a cross-tenant leak). Postgres Row-Level Security does it — but
|
|
2720
|
+
Drizzle reuses pooled connections and won't carry a per-request `SET`, so the trick is
|
|
2721
|
+
to run each tenant query inside a transaction that first sets a **transaction-local** GUC the
|
|
2722
|
+
policy reads. This `scoped()` + `tenantDb()` helper (≈30 lines) wires it up;
|
|
2723
|
+
`tenantDb()` _throws_ when called unscoped, so a missing scope fails loudly instead
|
|
2724
|
+
of silently reading every tenant's rows.
|
|
2725
|
+
|
|
2726
|
+
```ts
|
|
2727
|
+
// rls.ts — request-scoped Postgres RLS for Drizzle. Drizzle reuses pooled connections and won't carry
|
|
2728
|
+
// a per-request setting, so each tenant query runs in a tx that first sets a GUC the RLS policy reads.
|
|
2729
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2730
|
+
import { sql } from "drizzle-orm"
|
|
2731
|
+
import { db, notes } from "./db"
|
|
2732
|
+
|
|
2733
|
+
const als = new AsyncLocalStorage<typeof db>()
|
|
2734
|
+
|
|
2735
|
+
// Bind every query inside fn() to userId. set_config(..., true) is LOCAL to the tx; ${userId} is a bound
|
|
2736
|
+
// parameter (no SQL injection). One round-trip sets the GUC, then your queries run isolated.
|
|
2737
|
+
export function scoped<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
|
2738
|
+
return db.transaction(async (tx) => {
|
|
2739
|
+
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`)
|
|
2740
|
+
return als.run(tx, fn)
|
|
2741
|
+
})
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// Use this (not the raw db) inside scoped(). Throws if called unscoped — a missing scope is a loud error,
|
|
2745
|
+
// never a silent cross-tenant read.
|
|
2746
|
+
export function tenantDb(): typeof db {
|
|
2747
|
+
const tx = als.getStore()
|
|
2748
|
+
if (!tx) throw new Error("tenantDb() called outside scoped() — every tenant query must be scoped")
|
|
2749
|
+
return tx as typeof db
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// route: bind to the session user — the DB then isolates every query in scope, even a buggy one.
|
|
2753
|
+
app.get("/notes", (c) => scoped(c.session.userId, () => tenantDb().select().from(notes)))
|
|
2754
|
+
```
|
|
2755
|
+
|
|
2756
|
+
Enable RLS and the policy once, in a migration:
|
|
2757
|
+
|
|
2758
|
+
```ts
|
|
2759
|
+
-- migration: enable RLS + a policy that reads the tx-local GUC set above.
|
|
2760
|
+
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
|
|
2761
|
+
CREATE POLICY tenant_isolation ON notes
|
|
2762
|
+
USING (user_id = current_setting('app.user_id', true)::uuid);
|
|
2763
|
+
```
|
|
2764
|
+
|
|
2765
|
+
The policy is the source of truth; `scoped()` just supplies the identity. Pair it with a
|
|
2766
|
+
UUIDv7 `user_id` and an index on it. Connect `c.session.userId` from
|
|
2767
|
+
[@nifrajs/better-auth](/docs/auth).
|
|
2768
|
+
|
|
2769
|
+
### Postgres (Raw Driver)
|
|
2770
|
+
|
|
2771
|
+
If you prefer parameterized queries over an ORM, use `postgres` or `pg`
|
|
2772
|
+
directly:
|
|
2773
|
+
|
|
2774
|
+
```ts
|
|
2775
|
+
import { server } from "@nifrajs/core"
|
|
2776
|
+
import { drizzle } from "drizzle-orm/postgres-js" // or node-postgres / neon-http
|
|
2777
|
+
import postgres from "postgres"
|
|
2778
|
+
import { eq } from "drizzle-orm"
|
|
2779
|
+
import { users } from "./schema"
|
|
2780
|
+
|
|
2781
|
+
const db = drizzle(postgres(process.env.DATABASE_URL!))
|
|
2782
|
+
|
|
2783
|
+
export const app = server().get("/users/:id", async (c) => {
|
|
2784
|
+
const [user] = await db.select().from(users).where(eq(users.id, Number(c.params.id)))
|
|
2785
|
+
return user ?? new Response("Not found", { status: 404 })
|
|
2786
|
+
})
|
|
2787
|
+
```
|
|
2788
|
+
|
|
2789
|
+
### In a loader
|
|
2790
|
+
|
|
2791
|
+
Full-stack? The same query goes in a route loader, and its result is typed straight into your
|
|
2792
|
+
page during SSR. The loader is server-only, so the database client is tree-shaken out of the
|
|
2793
|
+
browser bundle.
|
|
2794
|
+
|
|
2795
|
+
```ts
|
|
2796
|
+
// In a full-stack app the same query runs in a route loader — typed end-to-end to the page.
|
|
2797
|
+
export async function loader({ params }: LoaderArgs<typeof app>) {
|
|
2798
|
+
const post = await db.query("SELECT * FROM posts WHERE slug = ?").get(params.slug)
|
|
2799
|
+
if (!post) throw new Response("Not found", { status: 404 })
|
|
2800
|
+
return { post }
|
|
2801
|
+
}
|
|
2802
|
+
```
|
|
2803
|
+
|
|
2804
|
+
### Servers vs the edge
|
|
2805
|
+
|
|
2806
|
+
This is a _runtime_ constraint, not a Nifra one — every edge framework shares it. On a
|
|
2807
|
+
long-running server you use native TCP drivers; on the edge (no raw sockets) you use HTTP /
|
|
2808
|
+
serverless drivers. The route code is identical either way.
|
|
2809
|
+
|
|
2810
|
+
Runtime
|
|
2811
|
+
SQLite
|
|
2812
|
+
Postgres / other
|
|
2813
|
+
|
|
2814
|
+
Bun
|
|
2815
|
+
|
|
2816
|
+
`bun:sqlite` (built-in)
|
|
2817
|
+
|
|
2818
|
+
`postgres`, `pg`, Drizzle, Prisma
|
|
2819
|
+
|
|
2820
|
+
Node
|
|
2821
|
+
|
|
2822
|
+
`better-sqlite3`
|
|
2823
|
+
|
|
2824
|
+
`postgres`, `pg`, Drizzle, Prisma
|
|
2825
|
+
|
|
2826
|
+
Deno
|
|
2827
|
+
libSQL
|
|
2828
|
+
|
|
2829
|
+
`postgres`, Drizzle
|
|
2830
|
+
|
|
2831
|
+
Cloudflare / edge
|
|
2832
|
+
Cloudflare D1, Turso/libSQL
|
|
2833
|
+
Neon serverless, Postgres over Hyperdrive, Prisma Accelerate
|
|
2834
|
+
|
|
2835
|
+
On Cloudflare, bindings like D1 are **typed** through `c.env` — see
|
|
2836
|
+
[Edge & bindings](/docs/edge). For Workers MySQL/Postgres, Hyperdrive pools the
|
|
2837
|
+
connection so a native driver works over the binding.
|
|
2838
|
+
|
|
2839
|
+
**Security:** always parameterize (`?` / driver placeholders or an ORM) — never
|
|
2840
|
+
string-build SQL from request input. Nifra validates the request body/params at the boundary
|
|
2841
|
+
(with `t` or any Standard Schema), so malformed input is rejected before your query
|
|
2842
|
+
runs. See [Security](/docs/security).
|
|
2843
|
+
|
|
2844
|
+
Complete runnable references — a typed CRUD API you can diff your own against:
|
|
2845
|
+
`examples/db-postgres` (Drizzle + Postgres, embedded PGlite — zero setup) and
|
|
2846
|
+
`examples/db-sqlite` (the same API on raw `bun:sqlite`, parameterized
|
|
2847
|
+
queries, boundary validation).
|
|
2848
|
+
|
|
2849
|
+
## Nifra — Fonts
|
|
2850
|
+
|
|
2851
|
+
> Self-host web fonts with zero layout shift: a build-time Google Fonts downloader (the next/font equivalent) plus CLS-safe @font-face + preload primitives.
|
|
2852
|
+
|
|
2853
|
+
Self-host your fonts with **zero layout shift** — Nifra's equivalent of
|
|
2854
|
+
`next/font`. Either let the build downloader fetch a Google font for you, or
|
|
2855
|
+
hand-write a `@font-face` from a file you already have. Both paths produce the same
|
|
2856
|
+
thing: a self-hosted, content-hashed font with `font-display: swap`, a preload, and
|
|
2857
|
+
metric overrides — no runtime CDN hotlink, and Google never learns what your users read.
|
|
2858
|
+
|
|
2859
|
+
### Automated: download a Google font at build time
|
|
2860
|
+
|
|
2861
|
+
`loadGoogleFont` runs in your **build** (it touches the network and writes files,
|
|
2862
|
+
so it never sits on the request path). It downloads the font, content-hashes each
|
|
2863
|
+
`.woff2` into `outDir`, and returns a self-hosted `@font-face`
|
|
2864
|
+
stylesheet plus the matching preloads:
|
|
2865
|
+
|
|
2866
|
+
```ts
|
|
2867
|
+
// fonts.build.ts — run once at build time (a prebuild step). It hits the network.
|
|
2868
|
+
import { loadGoogleFont } from "@nifrajs/web/fonts"
|
|
2869
|
+
|
|
2870
|
+
const inter = await loadGoogleFont(
|
|
2871
|
+
{ family: "Inter", weights: [400, 700], subsets: ["latin"], sizeAdjust: "107%" },
|
|
2872
|
+
{ outDir: "public/fonts", publicPath: "/fonts" },
|
|
2873
|
+
)
|
|
2874
|
+
|
|
2875
|
+
await Bun.write("app/fonts.css", inter.css) // the @font-face stylesheet
|
|
2876
|
+
await Bun.write("app/fonts.preloads.json", JSON.stringify(inter.preloads))
|
|
2877
|
+
```
|
|
2878
|
+
|
|
2879
|
+
Then wire the two outputs into your app:
|
|
2880
|
+
|
|
2881
|
+
```ts
|
|
2882
|
+
// app entry — bundled + content-hashed by nifra's CSS pipeline
|
|
2883
|
+
import "./fonts.css"
|
|
2884
|
+
|
|
2885
|
+
// root layout — preload the primary file (one less render-blocking round trip)
|
|
2886
|
+
import preloads from "./fonts.preloads.json" with { type: "json" }
|
|
2887
|
+
export const meta = { link: preloads }
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
Re-run the script when you change weights or subsets; commit the hashed
|
|
2891
|
+
`public/fonts/*.woff2` (or regenerate them in CI). A complete runnable example lives
|
|
2892
|
+
in `examples/fonts-google`.
|
|
2893
|
+
|
|
2894
|
+
-
|
|
2895
|
+
**weights** — numbers (`400`), a variable range (`"100 900"`), or the
|
|
2896
|
+
keywords `normal`/`bold`
|
|
2897
|
+
|
|
2898
|
+
-
|
|
2899
|
+
**styles** — `["normal", "italic"]`; defaults to `["normal"]`
|
|
2900
|
+
|
|
2901
|
+
-
|
|
2902
|
+
**subsets** — keep only the ones you serve (`["latin"]`); Google returns every
|
|
2903
|
+
subset as its own `@font-face` with a `unicode-range`
|
|
2904
|
+
|
|
2905
|
+
-
|
|
2906
|
+
**text** — glyph subsetting for a logo/heading: request only the characters you render, and
|
|
2907
|
+
you get one tiny file
|
|
2908
|
+
|
|
2909
|
+
### Manual: self-host a file you already have
|
|
2910
|
+
|
|
2911
|
+
Already have the `.woff2`? `fontFace` builds a CLS-safe rule directly. Put
|
|
2912
|
+
the result in a stylesheet your app imports — Nifra's CSS pipeline bundles and content-hashes
|
|
2913
|
+
it.
|
|
2914
|
+
|
|
2915
|
+
```ts
|
|
2916
|
+
// fonts.css — you dropped inter.woff2 into public/fonts/ yourself
|
|
2917
|
+
import { fontFace } from "@nifrajs/web"
|
|
2918
|
+
|
|
2919
|
+
export default fontFace({
|
|
2920
|
+
family: "Inter",
|
|
2921
|
+
src: [{ url: "/fonts/inter.woff2" }], // format() inferred from the extension
|
|
2922
|
+
weight: "100 900", // a variable-font range
|
|
2923
|
+
display: "swap", // the default — paints fallback text instantly
|
|
2924
|
+
sizeAdjust: "107%", // metric override → no fallback→web-font shift
|
|
2925
|
+
})
|
|
2926
|
+
```
|
|
2927
|
+
|
|
2928
|
+
### Preload the font file
|
|
2929
|
+
|
|
2930
|
+
`fontPreload` emits a `<link rel="preload" as="font">` for a root
|
|
2931
|
+
layout's `meta.link` — the browser would otherwise discover the font only after
|
|
2932
|
+
parsing the CSS, a wasted round trip. It defaults to `crossorigin="anonymous"`
|
|
2933
|
+
because fonts are always fetched in CORS mode (a mismatched preload is downloaded twice).
|
|
2934
|
+
|
|
2935
|
+
```ts
|
|
2936
|
+
// a root layout's meta — becomes <link rel="preload" as="font" crossorigin> in <head>
|
|
2937
|
+
import { fontPreload } from "@nifrajs/web"
|
|
2938
|
+
|
|
2939
|
+
export const meta = { link: [fontPreload({ href: "/fonts/inter.woff2" })] }
|
|
2940
|
+
```
|
|
2941
|
+
|
|
2942
|
+
Preload the _primary_ face only (e.g. latin 400). Preloading every weight and subset forces
|
|
2943
|
+
downloads the page may never use.
|
|
2944
|
+
|
|
2945
|
+
**Security:** `loadGoogleFont` validates the family, weights, and subsets,
|
|
2946
|
+
**allowlists the font-file host to `fonts.gstatic.com` over https** (a tampered
|
|
2947
|
+
stylesheet can't redirect the download elsewhere — an SSRF gate), and caps each download's size.
|
|
2948
|
+
Filenames are derived only from validated tokens plus a content hash, so there's no path
|
|
2949
|
+
traversal.
|
|
2950
|
+
|
|
2951
|
+
## Nifra — Migrating from Express, Hono, Fastify & Elysia
|
|
2952
|
+
|
|
2953
|
+
> Move a backend to Nifra: routing, middleware, validation, and body parsing map directly from Express, Hono, Fastify, and Elysia — and you gain a typed client, multi-runtime deploy, and optional SSR.
|
|
2954
|
+
|
|
2955
|
+
Nifra's `server()` is a chainable, Web-standard router — the move from Express, Hono,
|
|
2956
|
+
Fastify, or Elysia is mostly mechanical renames. What you gain: an end-to-end-typed client with
|
|
2957
|
+
zero codegen, schema validation built into the route, the same app on Bun / Node / Deno / the
|
|
2958
|
+
edge, and an SSR frontend whenever you want one.
|
|
2959
|
+
|
|
2960
|
+
### How the concepts map
|
|
2961
|
+
|
|
2962
|
+
Express / Hono / Fastify
|
|
2963
|
+
Nifra
|
|
2964
|
+
|
|
2965
|
+
`app.get(path, handler)`
|
|
2966
|
+
|
|
2967
|
+
`server().get(path, handler)` — chainable, fully type-inferred
|
|
2968
|
+
|
|
2969
|
+
`req` / `res` · Hono `Context`
|
|
2970
|
+
|
|
2971
|
+
`c` — `c.req`, `c.params`, `c.query`; **return** a value (JSON) or a
|
|
2972
|
+
`Response`
|
|
2973
|
+
|
|
2974
|
+
`app.use(mw)` · Hono middleware
|
|
2975
|
+
|
|
2976
|
+
`.use()` + the `@nifrajs/middleware` pack (CORS, auth, rate limit, …)
|
|
2977
|
+
|
|
2978
|
+
`express.json()` / body parsing
|
|
2979
|
+
built in — declare a schema and the parsed body is typed
|
|
2980
|
+
|
|
2981
|
+
Fastify JSON schema · `zod` middleware · Elysia `t`
|
|
2982
|
+
|
|
2983
|
+
`{ body, query, params }` validated by `t` (TypeBox) or any
|
|
2984
|
+
[Standard Schema](https://standardschema.dev)
|
|
2985
|
+
|
|
2986
|
+
`res.json(x)` / `reply.send(x)` / `c.json(x)`
|
|
2987
|
+
|
|
2988
|
+
`return x`
|
|
2989
|
+
|
|
2990
|
+
`app.listen(3000)`
|
|
2991
|
+
|
|
2992
|
+
`app.listen()` (Bun) · `@nifrajs/node` · `@nifrajs/deno` ·
|
|
2993
|
+
`toFetchHandler` (edge)
|
|
2994
|
+
|
|
2995
|
+
OpenAPI plugins / swagger
|
|
2996
|
+
generated from the same `t` schemas — no second source of truth
|
|
2997
|
+
|
|
2998
|
+
### Express → Nifra
|
|
2999
|
+
|
|
3000
|
+
The handler returns its result instead of calling `res.json`, and validation moves
|
|
3001
|
+
onto the route. The client then infers all of this for free.
|
|
3002
|
+
|
|
3003
|
+
```ts
|
|
3004
|
+
// Express
|
|
3005
|
+
app.get("/users/:id", (req, res) => res.json({ id: req.params.id }))
|
|
3006
|
+
app.post("/users", express.json(), (req, res) => res.status(201).json(create(req.body)))
|
|
3007
|
+
|
|
3008
|
+
// nifra — return the value (or a Response); c is Web-standard; the body is parsed + validated
|
|
3009
|
+
server()
|
|
3010
|
+
.get("/users/:id", (c) => ({ id: c.params.id }))
|
|
3011
|
+
.post("/users", { body: t.object({ name: t.string() }) }, (c) => create(c.body))
|
|
3012
|
+
```
|
|
3013
|
+
|
|
3014
|
+
### Hono → Nifra
|
|
3015
|
+
|
|
3016
|
+
The closest of the routers — `c.req.param("id")` becomes a typed
|
|
3017
|
+
`c.params.id`, and you return the value rather than `c.json(...)`. Nifra
|
|
3018
|
+
adds the typed client, schema validation, multi-runtime deploy, and (if you want it) SSR.
|
|
3019
|
+
|
|
3020
|
+
```ts
|
|
3021
|
+
// Hono
|
|
3022
|
+
app.get("/users/:id", (c) => c.json({ id: c.req.param("id") }))
|
|
3023
|
+
|
|
3024
|
+
// nifra — nearly identical; params are a typed object, you return the value
|
|
3025
|
+
server().get("/users/:id", (c) => ({ id: c.params.id }))
|
|
3026
|
+
```
|
|
3027
|
+
|
|
3028
|
+
### Fastify & Elysia
|
|
3029
|
+
|
|
3030
|
+
-
|
|
3031
|
+
**Fastify** — its per-route JSON-schema validation maps directly to Nifra's
|
|
3032
|
+
`{ body, query, params }`; plugins become Nifra plugins; `reply.send`
|
|
3033
|
+
becomes a return.
|
|
3034
|
+
|
|
3035
|
+
-
|
|
3036
|
+
**Elysia** — the closest peer (Bun, typed, TypeBox `t`). The shapes are nearly
|
|
3037
|
+
1:1: `.get/.post` + a `t` schema, and an Eden-style typed client. You
|
|
3038
|
+
gain Node / Deno / edge portability and an optional SSR frontend on five UI libraries.
|
|
3039
|
+
|
|
3040
|
+
Then connect a database ([Database](/docs/database)) and, if you're going
|
|
3041
|
+
full-stack, see [Migrating a meta-framework](/docs/migrate-frontend).
|
|
3042
|
+
|
|
3043
|
+
## Nifra — Migrating from Next.js, Nuxt, SvelteKit & SolidStart
|
|
3044
|
+
|
|
3045
|
+
> Move from a meta-framework to Nifra: file routes, data loading, API routes, layouts, and SSG/ISR map across React (Next), Vue (Nuxt), Svelte (SvelteKit), and Solid (SolidStart).
|
|
3046
|
+
|
|
3047
|
+
Nifra is framework-agnostic, so you keep your UI library and replace the meta-framework around
|
|
3048
|
+
it: Next.js → Nifra + React, Nuxt → Nifra + Vue, SvelteKit → Nifra + Svelte, SolidStart → Nifra +
|
|
3049
|
+
Solid. The concepts map one-to-one, and you can migrate incrementally — stand up Nifra's API
|
|
3050
|
+
first, point your existing app at it, then move routes over.
|
|
3051
|
+
|
|
3052
|
+
### How the concepts map
|
|
3053
|
+
|
|
3054
|
+
Meta-framework concept
|
|
3055
|
+
Nifra
|
|
3056
|
+
|
|
3057
|
+
File routes (`pages/`, `app/`, `routes/`)
|
|
3058
|
+
|
|
3059
|
+
`routes/` — `.tsx` / `.vue` / `.svelte` / `.mdx`, same dynamic
|
|
3060
|
+
`[param]` / `[...catch-all]` conventions
|
|
3061
|
+
|
|
3062
|
+
`getServerSideProps` · `load` · `createAsync` · `asyncData`
|
|
3063
|
+
|
|
3064
|
+
`export async function loader()` — runs on the server, typed into the page
|
|
3065
|
+
|
|
3066
|
+
API routes (`pages/api`, `+server.ts`, route handlers)
|
|
3067
|
+
|
|
3068
|
+
a `server()` backend + the typed client — no `fetch()` wrappers
|
|
3069
|
+
|
|
3070
|
+
Layouts (`layout.tsx`, `+layout`, `app.vue`)
|
|
3071
|
+
|
|
3072
|
+
`_layout.tsx` — nested layout chains
|
|
3073
|
+
|
|
3074
|
+
Form actions / route handlers for mutations
|
|
3075
|
+
|
|
3076
|
+
`export async function action()` — typed, progressive-enhancement forms
|
|
3077
|
+
|
|
3078
|
+
`getStaticProps` / `prerender` / `export const prerender`
|
|
3079
|
+
|
|
3080
|
+
`export const prerender = true` (SSG) + ISR via `withISR`
|
|
3081
|
+
|
|
3082
|
+
`<Link>` · `<NuxtLink>` · `<a data-sveltekit-preload>`
|
|
3083
|
+
Nifra's client router — `Link` + hover/focus prefetch, scroll restoration
|
|
3084
|
+
|
|
3085
|
+
`next/image` · `nuxt/image`
|
|
3086
|
+
|
|
3087
|
+
`<Image>` from `@nifrajs/web-<fw>/image`
|
|
3088
|
+
|
|
3089
|
+
`metadata` / `<Head>` / `definePageMeta`
|
|
3090
|
+
|
|
3091
|
+
`export const meta` (or a `meta()` function of the loader data)
|
|
3092
|
+
|
|
3093
|
+
### Data loading (Next.js → Nifra + React)
|
|
3094
|
+
|
|
3095
|
+
The biggest change: data fetching becomes a typed `loader` that calls your backend
|
|
3096
|
+
in-process during SSR — no `fetch` to your own API, no untyped JSON.
|
|
3097
|
+
|
|
3098
|
+
```ts
|
|
3099
|
+
// Next.js — app/users/[id]/page.tsx
|
|
3100
|
+
export default async function Page({ params }) {
|
|
3101
|
+
const res = await fetch(`https://api/users/${params.id}`)
|
|
3102
|
+
const user = await res.json()
|
|
3103
|
+
return <h1>{user.name}</h1>
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// nifra — routes/users/[id].tsx
|
|
3107
|
+
export async function loader({ params, api }: LoaderArgs<typeof backend>) {
|
|
3108
|
+
const res = await api.users({ id: params.id }).get() // typed, in-process during SSR
|
|
3109
|
+
return { user: res.data }
|
|
3110
|
+
}
|
|
3111
|
+
export default function User({ data }: { data: LoaderData<typeof loader> }) {
|
|
3112
|
+
return <h1>{data.user?.name}</h1>
|
|
3113
|
+
}
|
|
3114
|
+
```
|
|
3115
|
+
|
|
3116
|
+
### Svelte / Solid / Vue
|
|
3117
|
+
|
|
3118
|
+
Identical shape — only the page file's extension and component syntax change. The
|
|
3119
|
+
`loader`/`action`/`meta` exports are the same on every
|
|
3120
|
+
framework (that's Nifra's render seam). SvelteKit's `+page.server.ts` load, for
|
|
3121
|
+
example:
|
|
3122
|
+
|
|
3123
|
+
```ts
|
|
3124
|
+
// SvelteKit — +page.server.ts + +page.svelte
|
|
3125
|
+
export async function load({ params }) { return { post: await getPost(params.slug) } }
|
|
3126
|
+
|
|
3127
|
+
// nifra — routes/blog/[slug].svelte (loader is a module export, page is the .svelte)
|
|
3128
|
+
export async function loader({ params }) { return { post: await getPost(params.slug) } }
|
|
3129
|
+
```
|
|
3130
|
+
|
|
3131
|
+
### What Nifra adds
|
|
3132
|
+
|
|
3133
|
+
-
|
|
3134
|
+
**One model, any UI library** — switch React→Solid later by changing one import, not your
|
|
3135
|
+
app.
|
|
3136
|
+
|
|
3137
|
+
-
|
|
3138
|
+
**Much faster SSR** — Nifra renders ~22× Next.js, ~7× Nuxt, ~3× SvelteKit/SolidStart on
|
|
3139
|
+
dynamic pages, with a fraction of the client JS. See [benchmarks](/benchmarks).
|
|
3140
|
+
|
|
3141
|
+
-
|
|
3142
|
+
**End-to-end types with no codegen**, the same app on Bun / Node / Deno / the edge, and a
|
|
3143
|
+
backend you can also ship on its own. Start with [Getting started](/docs).
|
|
3144
|
+
|
|
3145
|
+
Moving a backend (Express, Hono, Fastify, Elysia) instead? See
|
|
3146
|
+
[Migrating a backend](/docs/migrate-backend).
|
|
3147
|
+
|
|
3148
|
+
---
|
|
3149
|
+
|
|
3150
|
+
# Package reference (READMEs)
|
|
3151
|
+
|
|
3152
|
+
## @nifrajs/auth
|
|
3153
|
+
|
|
3154
|
+
Session primitives for nifra — signed-cookie + server-store sessions, CSRF, and route guards. Bring your own identity (Better Auth / Lucia / OAuth); nifra owns the session.
|
|
3155
|
+
|
|
3156
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
3157
|
+
|
|
3158
|
+
## Install
|
|
3159
|
+
|
|
3160
|
+
```sh
|
|
3161
|
+
bun add @nifrajs/auth
|
|
3162
|
+
```
|
|
3163
|
+
|
|
3164
|
+
## Docs
|
|
3165
|
+
|
|
3166
|
+
- Reference: <https://nifra.dev/docs>
|
|
3167
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
3168
|
+
|
|
3169
|
+
MIT
|
|
3170
|
+
|
|
3171
|
+
## @nifrajs/better-auth
|
|
3172
|
+
|
|
3173
|
+
Mount [better-auth](https://better-auth.com) into a [nifra](https://github.com/nifra) app: one
|
|
3174
|
+
`app.use(betterAuth(auth))` wires every better-auth endpoint (sign-in/up/out, OAuth callbacks,
|
|
3175
|
+
session, 2FA, magic links, …) under `/api/auth/*`, plus typed `getSession` / `requireSession` guards
|
|
3176
|
+
for reading and protecting routes.
|
|
3177
|
+
|
|
3178
|
+
This package has **no runtime dependency on better-auth** — it consumes your `auth` object
|
|
3179
|
+
structurally, so your better-auth types flow through by inference and your tests need no DB.
|
|
3180
|
+
|
|
3181
|
+
## Install
|
|
3182
|
+
|
|
3183
|
+
```sh
|
|
3184
|
+
bun add @nifrajs/better-auth better-auth
|
|
3185
|
+
```
|
|
3186
|
+
|
|
3187
|
+
## Setup
|
|
3188
|
+
|
|
3189
|
+
Configure better-auth as usual (database, providers, …), then mount it:
|
|
3190
|
+
|
|
3191
|
+
```ts
|
|
3192
|
+
// auth.ts
|
|
3193
|
+
import { betterAuth as createBetterAuth } from "better-auth"
|
|
3194
|
+
export const auth = createBetterAuth({
|
|
3195
|
+
database: /* your adapter */,
|
|
3196
|
+
emailAndPassword: { enabled: true },
|
|
3197
|
+
// basePath defaults to "/api/auth"
|
|
3198
|
+
})
|
|
3199
|
+
```
|
|
3200
|
+
|
|
3201
|
+
```ts
|
|
3202
|
+
// server.ts
|
|
3203
|
+
import { server } from "@nifrajs/core"
|
|
3204
|
+
import { betterAuth } from "@nifrajs/better-auth"
|
|
3205
|
+
import { auth } from "./auth"
|
|
3206
|
+
|
|
3207
|
+
export const app = server()
|
|
3208
|
+
.use(betterAuth(auth)) // serves GET + POST /api/auth/*
|
|
3209
|
+
.get("/", () => ({ ok: true }))
|
|
3210
|
+
```
|
|
3211
|
+
|
|
3212
|
+
`betterAuth(auth, { basePath })` overrides the mount path; otherwise it uses `auth.options.basePath`,
|
|
3213
|
+
then `/api/auth`. The plugin is **idempotent** (named `"better-auth"`): applying it twice mounts once.
|
|
3214
|
+
|
|
3215
|
+
## Read the session
|
|
3216
|
+
|
|
3217
|
+
`getSession(auth, request)` is a typed wrapper over `auth.api.getSession`. Pass the raw `Request` so it
|
|
3218
|
+
works in both core handlers (`c.req`) and `@nifrajs/web` loaders/actions (`request`):
|
|
3219
|
+
|
|
3220
|
+
```ts
|
|
3221
|
+
import { getSession } from "@nifrajs/better-auth"
|
|
3222
|
+
|
|
3223
|
+
app.get("/me", async (c) => {
|
|
3224
|
+
const session = await getSession(auth, c.req) // { user, session } | null — fully typed
|
|
3225
|
+
return session ? { email: session.user.email } : { email: null }
|
|
3226
|
+
})
|
|
3227
|
+
```
|
|
3228
|
+
|
|
3229
|
+
## Protect a route
|
|
3230
|
+
|
|
3231
|
+
`requireSession(auth, request, options?)` returns the non-null session or **throws a `Response`** —
|
|
3232
|
+
nifra returns a thrown `Response` as-is, short-circuiting the handler:
|
|
3233
|
+
|
|
3234
|
+
```ts
|
|
3235
|
+
import { requireSession } from "@nifrajs/better-auth"
|
|
3236
|
+
|
|
3237
|
+
// 401 JSON { ok: false, error: "unauthorized" } when signed out:
|
|
3238
|
+
app.get("/account", async (c) => {
|
|
3239
|
+
const { user } = await requireSession(auth, c.req)
|
|
3240
|
+
return { id: user.id }
|
|
3241
|
+
})
|
|
3242
|
+
|
|
3243
|
+
// 302 to a login page instead (same-origin path required):
|
|
3244
|
+
export const loader = async ({ request }) => {
|
|
3245
|
+
const { user } = await requireSession(auth, request, { redirectTo: "/login" })
|
|
3246
|
+
return { user }
|
|
3247
|
+
}
|
|
3248
|
+
```
|
|
3249
|
+
|
|
3250
|
+
## API
|
|
3251
|
+
|
|
3252
|
+
| Export | Description |
|
|
3253
|
+
| --- | --- |
|
|
3254
|
+
| `betterAuth(auth, options?)` | Plugin that mounts better-auth's handler at `${basePath}/*` for GET + POST. |
|
|
3255
|
+
| `getSession(auth, request)` | `Promise<SessionOf<A> \| null>` — typed wrapper over `auth.api.getSession`. |
|
|
3256
|
+
| `requireSession(auth, request, options?)` | Returns the session or throws a `Response` (302 `redirectTo` / 401). |
|
|
3257
|
+
| `SessionOf<A>` | The non-null session payload type inferred from your `auth`. |
|
|
3258
|
+
| `BetterAuthLike` | The structural contract a better-auth instance satisfies. |
|
|
3259
|
+
|
|
3260
|
+
## License
|
|
3261
|
+
|
|
3262
|
+
MIT
|
|
3263
|
+
|
|
3264
|
+
## @nifrajs/cli
|
|
3265
|
+
|
|
3266
|
+
The `nifra` CLI — zero-config dev/build/start for a nifra app, plus the agent surfaces (`nifra context`,
|
|
3267
|
+
`nifra mcp`). Run from your project root (it reads `nifra.config.ts`/`framework.ts`, `backend.ts`, and
|
|
3268
|
+
`routes/`).
|
|
3269
|
+
|
|
3270
|
+
```
|
|
3271
|
+
nifra dev [--port <n>] [--poll] True-HMR dev server (Vite + nifra SSR).
|
|
3272
|
+
nifra build [--out <dir>] Content-hashed client bundle + manifest.json.
|
|
3273
|
+
nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun.
|
|
3274
|
+
nifra context Print this project's API + page routes + conventions.
|
|
3275
|
+
nifra mcp MCP server (stdio) exposing this project to a coding agent.
|
|
3276
|
+
nifra check [--json] Gate: typecheck + lints; --json includes fixes/suggestions.
|
|
3277
|
+
nifra doctor [--json] [--auto-fix] Catch undeclared imported packages; auto-fix safe local versions.
|
|
3278
|
+
```
|
|
3279
|
+
|
|
3280
|
+
## `nifra check` — the drift gate (run as "done")
|
|
3281
|
+
|
|
3282
|
+
`nifra check` makes nifra's anti-drift guarantees actually fire instead of relying on the agent to
|
|
3283
|
+
remember them. It does three things and exits non-zero if any fails:
|
|
3284
|
+
|
|
3285
|
+
1. **Typecheck** (`tsc --noEmit`, using the project's own TypeScript) — the frontend↔backend contract is
|
|
3286
|
+
compiler-enforced. `client<typeof app>` derives request inputs **and** `res.data` from your routes, so
|
|
3287
|
+
a screen that expects the wrong shape is a type error. Running the typecheck is what turns that latent
|
|
3288
|
+
guarantee into an enforced one.
|
|
3289
|
+
2. **Typed-client lint** — flags any hand-rolled `fetch()` to your own API (a relative URL like
|
|
3290
|
+
`fetch("/users")`), which bypasses the typed client so the compiler can't see the drift. External
|
|
3291
|
+
absolute-URL fetches are not flagged.
|
|
3292
|
+
3. **Server-only-import lint** — flags a top-level import of server-only code (a DB driver, `node:`/`bun:`
|
|
3293
|
+
builtins, the `./db` module) into a `routes/` page module. Those modules are bundled for the browser,
|
|
3294
|
+
so the import ships server code to the client. Reach it via `c.db` / `ctx.api` inside a loader instead.
|
|
3295
|
+
|
|
3296
|
+
Pass **`--json`** for a machine-readable result (`{ ok, typecheck, diagnostics[] }`) — what the
|
|
3297
|
+
`nifra_check` MCP tool returns, so an agent acts on diagnostics instead of scraping output. Each diagnostic
|
|
3298
|
+
has a short `fix` string and, when safe, a structured `suggestion`: an exact one-line diff, an argv command
|
|
3299
|
+
array (for example `["bun","add","react"]`), or concrete manual steps. Tell a coding agent to run
|
|
3300
|
+
`nifra check` as its definition of done (the scaffolded `AGENTS.md` already does), and wire it into CI — a
|
|
3301
|
+
green check means the frontend and backend can't have silently diverged.
|
|
3302
|
+
|
|
3303
|
+
For hand-rolled own-API `fetch()` calls, `nifra check` stays conservative: simple string-literal calls
|
|
3304
|
+
that match a statically visible Nifra route get an exact typed-client rewrite diff; dynamic URLs, custom
|
|
3305
|
+
headers, bodies, query strings, or ambiguous routes fall back to manual steps.
|
|
3306
|
+
|
|
3307
|
+
## For AI coding agents
|
|
3308
|
+
|
|
3309
|
+
**`nifra context`** prints this project's actual surface — API routes (from `backend.routes()`), page
|
|
3310
|
+
routes (from `routes/`), and the framework conventions — as Markdown. Pipe it into a prompt:
|
|
3311
|
+
|
|
3312
|
+
```sh
|
|
3313
|
+
nifra context | pbcopy # or: nifra context > .agent-context.md
|
|
3314
|
+
```
|
|
3315
|
+
|
|
3316
|
+
**`nifra mcp`** runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so
|
|
3317
|
+
an agent can act on the project, not just read about it. Core tools:
|
|
3318
|
+
|
|
3319
|
+
- **`nifra_context`** — the same project surface as above.
|
|
3320
|
+
- **`nifra_routes`** / **`nifra_openapi`** — structured API route contracts and an OpenAPI 3.1 document.
|
|
3321
|
+
- **`nifra_docs`** / **`nifra_example`** — token-efficient docs slices and verified code snippets.
|
|
3322
|
+
- **`nifra_scaffold`** — map a URL to the correct `routes/` file; optionally write safe JSX stubs.
|
|
3323
|
+
- **`nifra_run`** — run HTTP requests through this project's backend and get structured results
|
|
3324
|
+
(status, headers, parsed body, and any thrown error). The backend is re-loaded in a fresh process
|
|
3325
|
+
each call by default, so it reflects the agent's latest edits — the write → run → see-the-failure
|
|
3326
|
+
→ fix loop. Pass `warm:true` to reuse a hot worker while source files are unchanged.
|
|
3327
|
+
- **`nifra_render`** — SSR a page route and return rendered HTML for quick page verification.
|
|
3328
|
+
- **`nifra_ws`** — verify a WebSocket route with a real Bun WebSocket round-trip and structured output.
|
|
3329
|
+
- **`nifra_test`** — run bounded `bun test` with structured output.
|
|
3330
|
+
- **`nifra_check`** — run the drift gate and get the structured `{ ok, typecheck, diagnostics[] }`
|
|
3331
|
+
result with suggestions, so the agent verifies its own work and fixes each diagnostic before finishing.
|
|
3332
|
+
- **`nifra_doctor`** — catch imported packages missing from `package.json`; with `autoFix:true`, write
|
|
3333
|
+
safe dependency entries when a version can be inferred locally and return exact `bun add` commands
|
|
3334
|
+
for anything that needs a package-manager decision.
|
|
3335
|
+
|
|
3336
|
+
The MCP server also exposes compact resources (`nifra://routes`, `nifra://openapi`, `nifra://package-json`,
|
|
3337
|
+
`nifra://agents-md`) and prompt templates for common tasks.
|
|
3338
|
+
|
|
3339
|
+
For long-running stdio tool calls, nifra follows MCP's standard `_meta.progressToken` progress
|
|
3340
|
+
notifications and `notifications/cancelled` request cancellation. `nifra_run`, `nifra_render`,
|
|
3341
|
+
`nifra_test`, and full `nifra_check` calls stop their subprocess work when cancelled.
|
|
3342
|
+
For token-sensitive clients, `tools/list` also accepts the opt-in extension `{ "compact": true }`;
|
|
3343
|
+
call `tools/describe` with a tool name when you need the full schema and description.
|
|
3344
|
+
|
|
3345
|
+
Wire it into a client. Claude Desktop / Cursor (`mcp.json`):
|
|
3346
|
+
|
|
3347
|
+
```json
|
|
3348
|
+
{
|
|
3349
|
+
"mcpServers": {
|
|
3350
|
+
"nifra": { "command": "nifra", "args": ["mcp"] }
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
```
|
|
3354
|
+
|
|
3355
|
+
Claude Code:
|
|
3356
|
+
|
|
3357
|
+
```sh
|
|
3358
|
+
claude mcp add nifra -- nifra mcp
|
|
3359
|
+
```
|
|
3360
|
+
|
|
3361
|
+
Run the client from (or point it at) your project root. The protocol is hand-rolled
|
|
3362
|
+
(newline-delimited JSON-RPC 2.0) — no SDK dependency.
|
|
3363
|
+
|
|
3364
|
+
## @nifrajs/client
|
|
3365
|
+
|
|
3366
|
+
A browser-safe, **end-to-end-typed** client for [nifra](../../README.md) servers — an
|
|
3367
|
+
Eden-style proxy that **never throws**.
|
|
3368
|
+
|
|
3369
|
+
```sh
|
|
3370
|
+
bun add @nifrajs/client
|
|
3371
|
+
```
|
|
3372
|
+
|
|
3373
|
+
```ts
|
|
3374
|
+
import { client } from "@nifrajs/client"
|
|
3375
|
+
import type { App } from "./server" // `typeof app` from @nifrajs/core
|
|
3376
|
+
|
|
3377
|
+
const api = client<App>("http://localhost:3000")
|
|
3378
|
+
|
|
3379
|
+
const { ok, status, data, error } = await api.users({ id: "42" }).get()
|
|
3380
|
+
// ^? typed from the server route
|
|
3381
|
+
```
|
|
3382
|
+
|
|
3383
|
+
- **Zero codegen.** Types flow from `typeof app` (coupled) — or from a contract via
|
|
3384
|
+
`client(contract, url)` (decoupled), with no dependency on the server's source.
|
|
3385
|
+
- **Proxy chaining.** Path segments are properties, `:params` are calls, verbs are
|
|
3386
|
+
methods: `api.users({ id }).posts({ postId }).get()`. The root is `api.index`.
|
|
3387
|
+
- **Result, never exceptions.** Every call resolves to `{ ok, status, data, error }`,
|
|
3388
|
+
so the failure path is in the types. Bodies are positional (`api.users.post({ name })`);
|
|
3389
|
+
pass `{ query, headers, signal }` as call options.
|
|
3390
|
+
- **Environment-agnostic.** No Bun/Node APIs — runs in the browser, workers, or any
|
|
3391
|
+
runtime with `fetch`.
|
|
3392
|
+
|
|
3393
|
+
ESM-only. MIT.
|
|
3394
|
+
|
|
3395
|
+
## @nifrajs/content
|
|
3396
|
+
|
|
3397
|
+
Typed, schema-validated content collections for nifra — Markdown + frontmatter, framework-agnostic.
|
|
3398
|
+
|
|
3399
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
3400
|
+
|
|
3401
|
+
## Install
|
|
3402
|
+
|
|
3403
|
+
```sh
|
|
3404
|
+
bun add @nifrajs/content
|
|
3405
|
+
```
|
|
3406
|
+
|
|
3407
|
+
## Docs
|
|
3408
|
+
|
|
3409
|
+
- Reference: <https://nifra.dev/docs>
|
|
3410
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
3411
|
+
|
|
3412
|
+
MIT
|
|
3413
|
+
|
|
3414
|
+
## @nifrajs/core
|
|
3415
|
+
|
|
3416
|
+
The Bun-native, contract-first HTTP framework at the heart of [nifra](../../README.md):
|
|
3417
|
+
a radix router, a fully type-inferred server, versionable contracts, lifecycle
|
|
3418
|
+
middleware, and production hardening.
|
|
3419
|
+
|
|
3420
|
+
```sh
|
|
3421
|
+
bun add @nifrajs/core
|
|
3422
|
+
```
|
|
3423
|
+
|
|
3424
|
+
```ts
|
|
3425
|
+
import { server } from "@nifrajs/core"
|
|
3426
|
+
|
|
3427
|
+
const app = server()
|
|
3428
|
+
.get("/users/:id", (c) => ({ id: c.params.id }))
|
|
3429
|
+
.post("/users", { body: nameSchema }, (c) => ({ created: c.body.name }))
|
|
3430
|
+
.listen(3000)
|
|
3431
|
+
|
|
3432
|
+
export type App = typeof app // hand this to @nifrajs/client for end-to-end types
|
|
3433
|
+
```
|
|
3434
|
+
|
|
3435
|
+
- **Inline or contract-first.** Write routes inline (types inferred from the
|
|
3436
|
+
builder), or `defineContract(...)` + `implement(...)` for a decoupled, versionable
|
|
3437
|
+
surface — handlers lift over unchanged.
|
|
3438
|
+
- **Validation at the boundary.** Per-route `body`/`query` is any
|
|
3439
|
+
[Standard Schema](https://standardschema.dev) (zod/valibot/arktype, or `@nifrajs/schema`'s
|
|
3440
|
+
`t`); invalid input is rejected with a structured `400` before the handler runs.
|
|
3441
|
+
- **Lifecycle middleware.** `derive`/`decorate` extend the typed context;
|
|
3442
|
+
`onRequest`/`beforeHandle`/`afterHandle`/`onResponse`/`onError` run around handlers;
|
|
3443
|
+
`use(middleware)` applies a bundle.
|
|
3444
|
+
- **Hardening built in.** `stop({ drainMs })` graceful shutdown (+ opt-in SIGTERM/
|
|
3445
|
+
SIGINT), `requestTimeoutMs` (+ `ctx.signal`), a streaming body-size cap, and a
|
|
3446
|
+
redacting structured `Logger`.
|
|
3447
|
+
|
|
3448
|
+
ESM-only; requires Bun at runtime. MIT.
|
|
3449
|
+
|
|
3450
|
+
## @nifrajs/cron
|
|
3451
|
+
|
|
3452
|
+
Runtime-agnostic **in-process cron** for long-running nifra servers (Bun / Node / Deno). Standard
|
|
3453
|
+
5-field expressions + `@macros`, overlap-safe, error-isolated, graceful stop. Dependency-free.
|
|
3454
|
+
|
|
3455
|
+
```ts
|
|
3456
|
+
import { createScheduler } from "@nifrajs/cron"
|
|
3457
|
+
|
|
3458
|
+
const cron = createScheduler({
|
|
3459
|
+
onError: (err, name) => logger.error("cron job failed", { name, err }),
|
|
3460
|
+
})
|
|
3461
|
+
.add("nightly-digest", "0 2 * * *", () => sendDigest())
|
|
3462
|
+
.add("poll-feed", "*/5 * * * *", async () => { await pollFeed() })
|
|
3463
|
+
.add("weekly-cleanup", "@weekly", () => cleanup())
|
|
3464
|
+
|
|
3465
|
+
cron.start() // checks every 15s; fires each job once per matching minute
|
|
3466
|
+
// on shutdown:
|
|
3467
|
+
cron.stop() // graceful — in-flight handlers finish
|
|
3468
|
+
```
|
|
3469
|
+
|
|
3470
|
+
## Expressions
|
|
3471
|
+
|
|
3472
|
+
`minute hour day-of-month month day-of-week`, **local time**. Each field:
|
|
3473
|
+
|
|
3474
|
+
| syntax | meaning |
|
|
3475
|
+
| --- | --- |
|
|
3476
|
+
| `*` | every value |
|
|
3477
|
+
| `5` | exactly 5 |
|
|
3478
|
+
| `1-5` | range |
|
|
3479
|
+
| `*/15` | every 15th (step) |
|
|
3480
|
+
| `0-30/10` | range with step → 0,10,20,30 |
|
|
3481
|
+
| `1,3,5` | list |
|
|
3482
|
+
|
|
3483
|
+
Macros: `@hourly` `@daily` (`@midnight`) `@weekly` `@monthly` `@yearly` (`@annually`).
|
|
3484
|
+
|
|
3485
|
+
Day-of-month + day-of-week follow the **standard OR rule**: when both are restricted, a match on
|
|
3486
|
+
either fires the job.
|
|
3487
|
+
|
|
3488
|
+
## Guarantees
|
|
3489
|
+
|
|
3490
|
+
- **Overlap-safe** — a job still running when its next minute arrives is *skipped*, not stacked.
|
|
3491
|
+
- **Error-isolated** — a handler that throws or rejects goes to `onError` (default `console.error`);
|
|
3492
|
+
it never tears down the scheduler loop.
|
|
3493
|
+
- **Graceful stop** — `stop()` clears the timer; in-flight handlers run to completion.
|
|
3494
|
+
- **Loud at registration** — a bad expression or a duplicate job name throws at `add()`, not at fire.
|
|
3495
|
+
- **Doesn't pin the process** — the interval is `unref`'d, so the scheduler alone won't keep a
|
|
3496
|
+
process alive.
|
|
3497
|
+
|
|
3498
|
+
## Testing
|
|
3499
|
+
|
|
3500
|
+
`tick(date)` runs every job due at a given instant with no real timers — drive it with controlled
|
|
3501
|
+
dates to test schedules deterministically. `runNow(name)` fires a job off-schedule (e.g. an admin
|
|
3502
|
+
"run now" button). The pure core — `parseCron(expr)`, `matches(fields, date)`, `nextRun(fields, from)`
|
|
3503
|
+
— is exported too.
|
|
3504
|
+
|
|
3505
|
+
## Cloudflare Workers
|
|
3506
|
+
|
|
3507
|
+
Workers has no long-lived process, so in-process cron doesn't apply. Use the platform `scheduled`
|
|
3508
|
+
trigger instead: `toFetchHandler(app, { scheduled })` from `@nifrajs/core` + a `[triggers]` cron in
|
|
3509
|
+
`wrangler.toml`. This package is for Bun/Node/Deno servers.
|
|
3510
|
+
|
|
3511
|
+
## @nifrajs/deno
|
|
3512
|
+
|
|
3513
|
+
Run a [nifra](../../README.md) app on [Deno](https://deno.com) via `Deno.serve`.
|
|
3514
|
+
|
|
3515
|
+
```sh
|
|
3516
|
+
deno add npm:@nifrajs/deno # or import npm:@nifrajs/deno directly
|
|
3517
|
+
```
|
|
3518
|
+
|
|
3519
|
+
```ts
|
|
3520
|
+
import { server } from "@nifrajs/core"
|
|
3521
|
+
import { serve } from "@nifrajs/deno"
|
|
3522
|
+
|
|
3523
|
+
const app = server().get("/users/:id", (c) => ({ id: c.params.id }))
|
|
3524
|
+
|
|
3525
|
+
const running = await serve(app, { port: 3000 })
|
|
3526
|
+
// running.port → the bound port (resolved; useful with `port: 0`)
|
|
3527
|
+
// await running.stop() → drain in-flight (Deno's shutdown()), then force-close
|
|
3528
|
+
```
|
|
3529
|
+
|
|
3530
|
+
Because `Deno.serve`'s handler already speaks Web `Request`/`Response`, the app's `fetch`
|
|
3531
|
+
*is* the handler — there's no stream bridge (unlike `@nifrajs/node`). This adapter just adds
|
|
3532
|
+
a Bun-`listen()`-style graceful `stop()` and opt-in signals.
|
|
3533
|
+
|
|
3534
|
+
## Graceful shutdown on signals
|
|
3535
|
+
|
|
3536
|
+
```ts
|
|
3537
|
+
await serve(app, { port: 3000, signals: true }) // SIGTERM/SIGINT → graceful stop()
|
|
3538
|
+
```
|
|
3539
|
+
|
|
3540
|
+
## What you get for free
|
|
3541
|
+
|
|
3542
|
+
The request timeout (`server({ requestTimeoutMs })` → `503 request_timeout`) and body-size
|
|
3543
|
+
cap live inside `app.fetch`, so they apply through this adapter automatically.
|
|
3544
|
+
|
|
3545
|
+
Works with any `{ fetch(req): Promise<Response> }` handler, not just nifra. ESM/TS. MIT.
|
|
3546
|
+
|
|
3547
|
+
## @nifrajs/env
|
|
3548
|
+
|
|
3549
|
+
Typed, validated environment variables. Define a schema once at startup; get a **frozen, typed**
|
|
3550
|
+
object — or a **loud boot-time failure listing every problem at once**, so a misconfigured deploy
|
|
3551
|
+
fails immediately instead of erroring on the first request that touches a bad var. The config half
|
|
3552
|
+
of nifra's "validate at every boundary" rule. Dependency-free, edge-safe.
|
|
3553
|
+
|
|
3554
|
+
```ts
|
|
3555
|
+
// env.ts — imported once at startup
|
|
3556
|
+
import { defineEnv, env } from "@nifrajs/env"
|
|
3557
|
+
|
|
3558
|
+
export const ENV = defineEnv({
|
|
3559
|
+
DATABASE_URL: env.url(),
|
|
3560
|
+
PORT: env.port({ default: 3000 }),
|
|
3561
|
+
NODE_ENV: env.enum(["development", "production", "test"], { default: "development" }),
|
|
3562
|
+
STRIPE_SECRET: env.string(),
|
|
3563
|
+
DEBUG: env.boolean({ default: false }),
|
|
3564
|
+
SENTRY_DSN: env.url({ optional: true }),
|
|
3565
|
+
})
|
|
3566
|
+
|
|
3567
|
+
// ENV.PORT → number
|
|
3568
|
+
// ENV.NODE_ENV → "development" | "production" | "test"
|
|
3569
|
+
// ENV.DEBUG → boolean
|
|
3570
|
+
// ENV.SENTRY_DSN → string | undefined
|
|
3571
|
+
```
|
|
3572
|
+
|
|
3573
|
+
A missing/invalid var fails at import:
|
|
3574
|
+
|
|
3575
|
+
```
|
|
3576
|
+
[nifra/env] invalid environment — 2 problem(s):
|
|
3577
|
+
DATABASE_URL: must be a valid URL
|
|
3578
|
+
STRIPE_SECRET: is required
|
|
3579
|
+
|
|
3580
|
+
Set the variable(s) above and restart.
|
|
3581
|
+
```
|
|
3582
|
+
|
|
3583
|
+
## Coercing helpers
|
|
3584
|
+
|
|
3585
|
+
Every value in `process.env` is a `string | undefined`; these turn it into the value you want:
|
|
3586
|
+
|
|
3587
|
+
| helper | type | notes |
|
|
3588
|
+
| --- | --- | --- |
|
|
3589
|
+
| `env.string({ default?, optional? })` | `string` | empty string counts as unset |
|
|
3590
|
+
| `env.number({ default?, optional? })` | `number` | finite, coerced |
|
|
3591
|
+
| `env.port({ default? })` | `number` | integer 1–65535 |
|
|
3592
|
+
| `env.boolean({ default? })` | `boolean` | `true`/`1`/`yes`/`on` vs `false`/`0`/`no`/`off`/empty |
|
|
3593
|
+
| `env.enum([...], { default? })` | union | one of the listed values |
|
|
3594
|
+
| `env.url({ default?, optional? })` | `string` | parses with WHATWG `URL`, returns the normalized href |
|
|
3595
|
+
|
|
3596
|
+
`optional: true` makes the type `T | undefined`; `default` supplies a fallback when unset.
|
|
3597
|
+
|
|
3598
|
+
## Bring your own validator
|
|
3599
|
+
|
|
3600
|
+
`defineEnv` accepts **any Standard Schema** (`t` from `@nifrajs/schema`, zod, valibot), not just the
|
|
3601
|
+
`env.*` helpers — handy for a shape the helpers don't cover. Only the `env.*` helpers coerce from
|
|
3602
|
+
strings, though: a plain `t.number()` would see the raw string, so reach for `env.number()` when you
|
|
3603
|
+
need coercion.
|
|
3604
|
+
|
|
3605
|
+
## Edge runtimes
|
|
3606
|
+
|
|
3607
|
+
`defineEnv` reads `process.env` by default (Bun/Node). Cloudflare Workers has no `process.env` —
|
|
3608
|
+
pass the request's bindings: `defineEnv(shape, { source: env })`.
|
|
3609
|
+
|
|
3610
|
+
## Secret safety
|
|
3611
|
+
|
|
3612
|
+
Error messages name the offending variable and the reason — **never its value** (it may be a
|
|
3613
|
+
secret). `defineEnv` returns a frozen object, so a validated config can't be mutated downstream.
|
|
3614
|
+
|
|
3615
|
+
## @nifrajs/i18n
|
|
3616
|
+
|
|
3617
|
+
Framework-agnostic i18n for nifra — locale negotiation + a tiny ICU message formatter on the platform Intl. Dependency-free.
|
|
3618
|
+
|
|
3619
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
3620
|
+
|
|
3621
|
+
## Install
|
|
3622
|
+
|
|
3623
|
+
```sh
|
|
3624
|
+
bun add @nifrajs/i18n
|
|
3625
|
+
```
|
|
3626
|
+
|
|
3627
|
+
## Docs
|
|
3628
|
+
|
|
3629
|
+
- Reference: <https://nifra.dev/docs>
|
|
3630
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
3631
|
+
|
|
3632
|
+
MIT
|
|
3633
|
+
|
|
3634
|
+
## @nifrajs/image
|
|
3635
|
+
|
|
3636
|
+
Image optimization for nifra — a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading. Zero-dependency core; optional Bun.Image-backed resize endpoint at @nifrajs/image/server.
|
|
3637
|
+
|
|
3638
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
3639
|
+
|
|
3640
|
+
## Install
|
|
3641
|
+
|
|
3642
|
+
```sh
|
|
3643
|
+
bun add @nifrajs/image
|
|
3644
|
+
```
|
|
3645
|
+
|
|
3646
|
+
## Docs
|
|
3647
|
+
|
|
3648
|
+
- Reference: <https://nifra.dev/docs>
|
|
3649
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
3650
|
+
|
|
3651
|
+
MIT
|
|
3652
|
+
|
|
3653
|
+
## @nifrajs/islets
|
|
3654
|
+
|
|
3655
|
+
Fine-grained signals + declarative DOM bindings for islands — **interactivity in ~1.4 KB gz**, no
|
|
3656
|
+
framework runtime. The client companion to `@nifrajs/web-vanilla`: the server renders real HTML
|
|
3657
|
+
(zero framework JS); islands attach behavior to it in place. No VDOM, no hydration re-render —
|
|
3658
|
+
the markup the server sent IS the initial state.
|
|
3659
|
+
|
|
3660
|
+
The full island bundle is about 1.4 KB gzipped and has a size test that keeps it under 2 KB. Use
|
|
3661
|
+
it for small interactive widgets where a full framework runtime would be overkill.
|
|
3662
|
+
|
|
3663
|
+
## Server side (any adapter — `@nifrajs/web-vanilla` shown)
|
|
3664
|
+
|
|
3665
|
+
```ts
|
|
3666
|
+
import { html } from "@nifrajs/web-vanilla"
|
|
3667
|
+
import { islandState } from "@nifrajs/islets" // safe to import server-side: it's just JSON.stringify
|
|
3668
|
+
|
|
3669
|
+
html`<section data-island="compare" data-island-state="${islandState({ count: hotels.length })}">
|
|
3670
|
+
<span data-bind-text="count">${hotels.length}</span>
|
|
3671
|
+
<button data-bind-on="click:add" type="button">Compare</button>
|
|
3672
|
+
</section>`
|
|
3673
|
+
```
|
|
3674
|
+
|
|
3675
|
+
The state attribute is the loader-data → client-signal seam: emit it through an escaping renderer
|
|
3676
|
+
(vanilla's `html` escapes attribute quotes) and the island reads it back with `state()` — no
|
|
3677
|
+
separate hydration payload, no data globals.
|
|
3678
|
+
|
|
3679
|
+
## Client side (an `islandScripts` entry)
|
|
3680
|
+
|
|
3681
|
+
```ts
|
|
3682
|
+
import { island, mountIslands } from "@nifrajs/islets"
|
|
3683
|
+
|
|
3684
|
+
island("compare", ({ state }) => {
|
|
3685
|
+
const count = state("count", 0) // seeded from data-island-state, falls back to 0
|
|
3686
|
+
return { add: () => count.set((n) => n + 1) }
|
|
3687
|
+
})
|
|
3688
|
+
|
|
3689
|
+
mountIslands() // idempotent — safe to call again after soft navigation
|
|
3690
|
+
```
|
|
3691
|
+
|
|
3692
|
+
## The binding set (closed — six bindings, no expression language)
|
|
3693
|
+
|
|
3694
|
+
| Attribute | Effect |
|
|
3695
|
+
| --- | --- |
|
|
3696
|
+
| `data-bind-text="sig"` | `textContent ← String(sig())` |
|
|
3697
|
+
| `data-bind-show="sig"` | `hidden ← !sig()` |
|
|
3698
|
+
| `data-bind-class="active:isOpen,b:sigB"` | `classList.toggle` per pair |
|
|
3699
|
+
| `data-bind-attr="aria-expanded:isOpen"` | `setAttribute`; `false`/`null`/`undefined` removes |
|
|
3700
|
+
| `data-bind-value="query"` | two-way `<input>`/`<select>`/`<textarea>` (`input` event) |
|
|
3701
|
+
| `data-bind-on="click:inc,submit:save"` | `addEventListener` per pair |
|
|
3702
|
+
|
|
3703
|
+
Values are signal/handler **names** resolved in the island's scope — never evaluated code, so
|
|
3704
|
+
markup cannot inject behavior. Unknown names warn once and skip; the server-rendered content
|
|
3705
|
+
stays as-is (progressive enhancement never throws).
|
|
3706
|
+
|
|
3707
|
+
## Signals
|
|
3708
|
+
|
|
3709
|
+
`signal(initial)` / `computed(fn)` / `effect(fn) → dispose` / `batch(fn)` — auto-tracking with
|
|
3710
|
+
per-run re-tracking, `Object.is` skips, synchronous updates; `batch` coalesces multiple writes
|
|
3711
|
+
into one flush. Island-scale by design: if a widget outgrows this (lists, ownership trees,
|
|
3712
|
+
async orchestration), that's the signal to use the Solid adapter for that page — both tiers are
|
|
3713
|
+
first-class.
|
|
3714
|
+
|
|
3715
|
+
## @nifrajs/middleware
|
|
3716
|
+
|
|
3717
|
+
Composable, dependency-light middleware for [nifra](../../README.md) — CORS, security headers, body
|
|
3718
|
+
limits, auth, CSRF, JWT/JWKS, IP restriction, response caching, timing, and ops helpers — applied with
|
|
3719
|
+
`app.use()`.
|
|
3720
|
+
|
|
3721
|
+
```sh
|
|
3722
|
+
bun add @nifrajs/middleware
|
|
3723
|
+
```
|
|
3724
|
+
|
|
3725
|
+
```ts
|
|
3726
|
+
import { server } from "@nifrajs/core"
|
|
3727
|
+
import {
|
|
3728
|
+
bodyLimit,
|
|
3729
|
+
cors,
|
|
3730
|
+
MemoryStore,
|
|
3731
|
+
rateLimit,
|
|
3732
|
+
securityHeaders,
|
|
3733
|
+
timing,
|
|
3734
|
+
} from "@nifrajs/middleware"
|
|
3735
|
+
|
|
3736
|
+
const app = server()
|
|
3737
|
+
.use(securityHeaders())
|
|
3738
|
+
.use(cors({ origin: ["https://app.example.com"], credentials: true }))
|
|
3739
|
+
.use(bodyLimit({ maxBytes: 1_000_000 }))
|
|
3740
|
+
.use(rateLimit({
|
|
3741
|
+
store: new MemoryStore(),
|
|
3742
|
+
max: 100,
|
|
3743
|
+
windowMs: 60_000,
|
|
3744
|
+
key: (req) => req.headers.get("x-user-id") ?? "anonymous",
|
|
3745
|
+
}))
|
|
3746
|
+
.use(timing())
|
|
3747
|
+
.get("/", () => ({ ok: true }))
|
|
3748
|
+
```
|
|
3749
|
+
|
|
3750
|
+
- **`bodyLimit({ maxBytes })`** — fail-closed `Content-Length` gate before routing. Lengthless
|
|
3751
|
+
bodies are rejected with `411` by default; use route-level `c.boundedBody()` / schema validation
|
|
3752
|
+
for intentionally streamed endpoints.
|
|
3753
|
+
- **`basicAuth(options)`** — Basic Auth plugin with constant-time static credential comparison or
|
|
3754
|
+
a custom verifier.
|
|
3755
|
+
- **`bearer(options)` / `apiKey(options)`** — token auth plugins with typed principals.
|
|
3756
|
+
- **`csrf({ secret })`** — signed double-submit CSRF protection plus Origin/Referer checking.
|
|
3757
|
+
- **`jwt(options)` + `verifyJwt()` / `tryVerifyJwt()` + `jwk()` / `jwks()`** — JWT auth
|
|
3758
|
+
with explicit algorithm allowlists, required expiration by default, issuer/audience checks,
|
|
3759
|
+
direct JWK, HTTPS JWKS, and an additive no-throw Result helper for manual verification.
|
|
3760
|
+
- **`ipRestriction(options)`** — allow/deny IPv4/IPv6 exact and CIDR matches. Fails closed unless
|
|
3761
|
+
you provide `clientIp`, trusted proxy extraction, or a trusted single-IP header.
|
|
3762
|
+
- **`cors(options)`** — preflight handling + headers on *every* response (errors and
|
|
3763
|
+
404s included). Origin as `"*"` / exact / list / predicate. **Throws** if
|
|
3764
|
+
`credentials: true` is paired with `origin: "*"` (the browser rejects it).
|
|
3765
|
+
- **`securityHeaders(options)`** — `X-Content-Type-Options`, `X-Frame-Options`,
|
|
3766
|
+
`Referrer-Policy` by default; opt-in HSTS and CSP.
|
|
3767
|
+
- **`rateLimit(options)`** — `429` + `Retry-After` + `RateLimit-*` headers, with a
|
|
3768
|
+
pluggable `RateLimitStore`. Configure a trusted `key`, trusted single-IP `header`, or
|
|
3769
|
+
`trustedProxies`; a missing key source fails closed instead of silently sharing one
|
|
3770
|
+
bucket. The bundled `MemoryStore` **refuses to run in production** (a per-instance
|
|
3771
|
+
limiter is unsafe across instances) — provide a shared store (Redis, etc.) there.
|
|
3772
|
+
- **`cache({ store, ttlMs })` / `responseCache(...)`** — full response cache with a pluggable
|
|
3773
|
+
store, `Vary`-aware keys, `Age`, byte caps, and `Cache-Control` / `Set-Cookie` safety defaults.
|
|
3774
|
+
`MemoryResponseCache` is dev/single-instance only unless explicitly allowed in production.
|
|
3775
|
+
- **`timing()`** — `Server-Timing` plus typed `c.timing.metric/mark/measure` controls.
|
|
3776
|
+
- **`prettyJson()`** — capped, JSON-only pretty printing for debugging and developer-facing APIs.
|
|
3777
|
+
- **`methodOverride()`** — header/query method tunneling for clients that can only send `POST`.
|
|
3778
|
+
Header override is on by default; query override is opt-in.
|
|
3779
|
+
- **`trimTrailingSlash()` / `appendTrailingSlash()`** — redirect or rewrite URL canonicalization.
|
|
3780
|
+
- **`poweredBy()`** — opt-in product/framework header; Nifra emits no powered-by header by default.
|
|
3781
|
+
- **`language()` / `pickLanguage()`** — `Accept-Language` negotiation with typed `c.language`.
|
|
3782
|
+
- **`combine()` / `namedCombine()`** — reusable runtime bundles for middleware/plugins.
|
|
3783
|
+
- **`requestId()` / `logger()` / `etag()` / `compression()` / `cacheControl()` /
|
|
3784
|
+
`idempotency()` / `healthcheck()` / `openapi()`** — additional operational middleware for APIs.
|
|
3785
|
+
|
|
3786
|
+
Request timeouts are configured at the core server boundary so they can abort `c.signal` and race the
|
|
3787
|
+
whole lifecycle:
|
|
3788
|
+
|
|
3789
|
+
```ts
|
|
3790
|
+
const app = server({ requestTimeoutMs: 5_000 })
|
|
3791
|
+
```
|
|
3792
|
+
|
|
3793
|
+
`@nifrajs/core` is a peer dependency. ESM-only. MIT.
|
|
3794
|
+
|
|
3795
|
+
## @nifrajs/node
|
|
3796
|
+
|
|
3797
|
+
Run a [nifra](../../README.md) app on Node's `http` server — nifra's ergonomics, deployed
|
|
3798
|
+
on Node instead of Bun.
|
|
3799
|
+
|
|
3800
|
+
```sh
|
|
3801
|
+
bun add @nifrajs/node # or: npm add @nifrajs/node
|
|
3802
|
+
```
|
|
3803
|
+
|
|
3804
|
+
```ts
|
|
3805
|
+
import { server } from "@nifrajs/core"
|
|
3806
|
+
import { serve } from "@nifrajs/node"
|
|
3807
|
+
|
|
3808
|
+
const app = server().get("/users/:id", (c) => ({ id: c.params.id }))
|
|
3809
|
+
|
|
3810
|
+
const node = await serve(app, { port: 3000 })
|
|
3811
|
+
// node.port → the bound port (resolved; useful with `port: 0`)
|
|
3812
|
+
// await node.stop() → drain in-flight, then force-close (graceful shutdown)
|
|
3813
|
+
```
|
|
3814
|
+
|
|
3815
|
+
`serve(app, { port })` bridges Node's stream-based `(req, res)` to/from the Web
|
|
3816
|
+
`Request`/`Response` that `app.fetch` speaks — that's the only Bun-specific seam, since
|
|
3817
|
+
nifra's lifecycle is already Web-standard. It also gives you a graceful `stop()`, the
|
|
3818
|
+
Node equivalent of Bun's `app.listen()`.
|
|
3819
|
+
|
|
3820
|
+
## Public URL protocol
|
|
3821
|
+
|
|
3822
|
+
The adapter creates a plain Node `http` server, so `Request.url` uses `http://` by
|
|
3823
|
+
default. If TLS terminates in front of Node, set the public protocol explicitly:
|
|
3824
|
+
|
|
3825
|
+
```ts
|
|
3826
|
+
await serve(app, { port: 3000, protocol: "https" })
|
|
3827
|
+
```
|
|
3828
|
+
|
|
3829
|
+
Forwarded protocol headers are not trusted automatically; pass a function only when your
|
|
3830
|
+
own trusted infrastructure has validated the request:
|
|
3831
|
+
|
|
3832
|
+
```ts
|
|
3833
|
+
await serve(app, {
|
|
3834
|
+
port: 3000,
|
|
3835
|
+
protocol: (req) => req.headers["x-forwarded-proto"] === "https" ? "https" : "http",
|
|
3836
|
+
})
|
|
3837
|
+
```
|
|
3838
|
+
|
|
3839
|
+
## Graceful shutdown on signals
|
|
3840
|
+
|
|
3841
|
+
Opt in to SIGTERM/SIGINT handling so a `docker stop` / Ctrl-C drains in-flight requests
|
|
3842
|
+
before exit (mirrors Bun's `listen({ gracefulSignals })` — off by default, since owning
|
|
3843
|
+
process signals should be a deliberate choice):
|
|
3844
|
+
|
|
3845
|
+
```ts
|
|
3846
|
+
await serve(app, { port: 3000, signals: true })
|
|
3847
|
+
```
|
|
3848
|
+
|
|
3849
|
+
## What you get for free
|
|
3850
|
+
|
|
3851
|
+
The request timeout (`server({ requestTimeoutMs })` → `503 request_timeout`) and body-size
|
|
3852
|
+
cap live **inside `app.fetch`**, not Bun's `listen()` — so they apply through this adapter
|
|
3853
|
+
with no extra wiring. Slow-client / slow-loris protection is Node's built-in
|
|
3854
|
+
`requestTimeout` (300s) and `headersTimeout` (60s) defaults.
|
|
3855
|
+
|
|
3856
|
+
Works with any `{ fetch(req): Promise<Response> }` handler, not just nifra. ESM-only. MIT.
|
|
3857
|
+
|
|
3858
|
+
## @nifrajs/otel
|
|
3859
|
+
|
|
3860
|
+
Distributed tracing for nifra. The `tracing()` plugin continues (or starts) a **W3C trace** per
|
|
3861
|
+
request, opens an **OpenTelemetry-semantic-convention** span, and exposes `c.trace` so you can
|
|
3862
|
+
forward the trace to downstream services. Spans go to a **pluggable exporter** — bridge to the
|
|
3863
|
+
OpenTelemetry SDK, or log them directly. No SDK bundled; edge-safe.
|
|
3864
|
+
|
|
3865
|
+
```ts
|
|
3866
|
+
import { tracing, traceHeaders, consoleSpanExporter } from "@nifrajs/otel"
|
|
3867
|
+
|
|
3868
|
+
const app = server()
|
|
3869
|
+
.use(tracing({ exporter: consoleSpanExporter(), serviceName: "orders-api" }))
|
|
3870
|
+
.get("/orders/:id", async (c) => {
|
|
3871
|
+
// continue the trace into a downstream call:
|
|
3872
|
+
const res = await fetch(`${INVENTORY_URL}/stock`, { headers: traceHeaders(c.trace) })
|
|
3873
|
+
return { id: c.params.id, inStock: (await res.json()).ok }
|
|
3874
|
+
})
|
|
3875
|
+
```
|
|
3876
|
+
|
|
3877
|
+
## What it does per request
|
|
3878
|
+
|
|
3879
|
+
- **Continues an inbound trace** — parses the `traceparent` header; reuses its `trace-id` and records
|
|
3880
|
+
the inbound span as the parent. No inbound header → starts a fresh trace.
|
|
3881
|
+
- **Opens a span** with HTTP semantic-convention attributes (`http.request.method`, `url.path`,
|
|
3882
|
+
`http.response.status_code`, optional `service.name`), ended on response with duration + status
|
|
3883
|
+
(`error` for 5xx, `ok` otherwise).
|
|
3884
|
+
- **Exposes `c.trace`** (`{ traceId, spanId, parentSpanId?, sampled, traceparent }`) — spread
|
|
3885
|
+
`traceHeaders(c.trace)` into any downstream `fetch`/`ctx.api` call to continue the trace.
|
|
3886
|
+
- `responseHeader: true` also sets `traceparent` on the response (browser/client correlation).
|
|
3887
|
+
|
|
3888
|
+
## Exporters
|
|
3889
|
+
|
|
3890
|
+
Implement `SpanExporter` to send spans wherever you collect them:
|
|
3891
|
+
|
|
3892
|
+
```ts
|
|
3893
|
+
interface SpanExporter {
|
|
3894
|
+
onStart?(span: NifraSpan): void
|
|
3895
|
+
onEnd(span: NifraSpan): void
|
|
3896
|
+
}
|
|
3897
|
+
```
|
|
3898
|
+
|
|
3899
|
+
- `consoleSpanExporter()` — logs each completed span as one structured line (dev / starting point).
|
|
3900
|
+
- **OpenTelemetry SDK bridge** — a ~10-line adapter maps `NifraSpan` onto a real OTel `Span` from a
|
|
3901
|
+
`Tracer` (the attribute names already follow OTel conventions, so they pass straight through). Your
|
|
3902
|
+
app depends on `@opentelemetry/*`; `@nifrajs/otel` does not.
|
|
3903
|
+
|
|
3904
|
+
## Connect your collector
|
|
3905
|
+
|
|
3906
|
+
Use `traceparent` and the built-in semantic attributes in every request span, then send spans to
|
|
3907
|
+
your own collector through an exporter. Keep the package edge-safe by installing the OpenTelemetry
|
|
3908
|
+
SDK only in apps that need that exporter.
|
|
3909
|
+
|
|
3910
|
+
## @nifrajs/runner
|
|
3911
|
+
|
|
3912
|
+
Run requests through a nifra app and capture **structured results** — the shared engine behind the
|
|
3913
|
+
website playground (humans) and the agent run/verify tool (an AI writes code → runs it → sees the
|
|
3914
|
+
failure → fixes it). It only touches `app.fetch(Request) → Response`, which is Web-standard, so it runs
|
|
3915
|
+
unchanged in the browser, Bun, Node, Deno, and on the edge — and depends on nothing.
|
|
3916
|
+
|
|
3917
|
+
```ts
|
|
3918
|
+
import { runApp } from "@nifrajs/runner"
|
|
3919
|
+
import { app } from "./app"
|
|
3920
|
+
|
|
3921
|
+
const results = await runApp(app, [
|
|
3922
|
+
{ path: "/users/1" },
|
|
3923
|
+
{ method: "POST", path: "/users", body: { name: "Ada" } },
|
|
3924
|
+
])
|
|
3925
|
+
// → [{ method, path, ok, status, headers, body, durationMs }, …]
|
|
3926
|
+
```
|
|
3927
|
+
|
|
3928
|
+
- **`runApp(app, requests, options?)`** — run a batch in order; one `RunResult` per request. Never
|
|
3929
|
+
throws: a crash on any request becomes that result's `error` and the batch continues.
|
|
3930
|
+
- **`runRequest(app, spec, options?)`** — a single request.
|
|
3931
|
+
|
|
3932
|
+
A plain object/array `body` is JSON-encoded (with a JSON content-type unless you set one); a
|
|
3933
|
+
string/`Uint8Array` is sent as-is. Relative paths resolve against `options.origin`
|
|
3934
|
+
(default `http://nifra.local`). Body text is captured up to `options.maxBodyChars` (default 64 KB).
|
|
3935
|
+
|
|
3936
|
+
It's a **runner, not a security sandbox**: it calls the app you hand it in-process, with no isolation.
|
|
3937
|
+
Isolation, when you need it, comes from the host — the browser tab for the playground, your own
|
|
3938
|
+
process/CI for the agent runner. Don't feed it code you wouldn't already run.
|
|
3939
|
+
|
|
3940
|
+
## @nifrajs/schema
|
|
3941
|
+
|
|
3942
|
+
The optional, batteries-included schema builder for [nifra](../../README.md): `t` is
|
|
3943
|
+
TypeBox-backed, so it **validates** at the boundary *and* gives you **OpenAPI for
|
|
3944
|
+
free** (a TypeBox schema is a JSON Schema). Plus `toOpenAPI(contract | app)`.
|
|
3945
|
+
|
|
3946
|
+
```sh
|
|
3947
|
+
bun add @nifrajs/schema
|
|
3948
|
+
```
|
|
3949
|
+
|
|
3950
|
+
```ts
|
|
3951
|
+
import { server } from "@nifrajs/core"
|
|
3952
|
+
import { t, toOpenAPI } from "@nifrajs/schema"
|
|
3953
|
+
|
|
3954
|
+
const app = server().post("/users", { body: t.object({ name: t.string(), age: t.integer() }) }, (c) => ({
|
|
3955
|
+
id: "u1",
|
|
3956
|
+
name: c.body.name, // typed + validated
|
|
3957
|
+
}))
|
|
3958
|
+
|
|
3959
|
+
const openapi = toOpenAPI(app) // OpenAPI 3.1
|
|
3960
|
+
```
|
|
3961
|
+
|
|
3962
|
+
- **`t` builder** — `string`/`number`/`integer`/`boolean`/`null`/`literal`/`object`/
|
|
3963
|
+
`array`/`optional`/`union`/`record`, with options (`min`/`max`, `pattern`, `format`,
|
|
3964
|
+
…) that become JSON Schema constraints. Validators are compiled (fast).
|
|
3965
|
+
- **Validating string formats** — `email`/`uuid`/`date-time`/`date`/`time`/`uri`/`ipv4`
|
|
3966
|
+
validate *and* annotate; register more with `registerFormat`.
|
|
3967
|
+
- **`toOpenAPI`** — richest from a contract (it carries `response` schemas + op names →
|
|
3968
|
+
`operationId`s); also works on a live app. Routes using a BYO Standard Schema are
|
|
3969
|
+
emitted without a detailed schema (Standard Schema exposes no JSON Schema).
|
|
3970
|
+
|
|
3971
|
+
### `toOpenAPI` coverage
|
|
3972
|
+
|
|
3973
|
+
It emits `paths`, `parameters` (path + object query), `requestBody`, and `responses`, plus:
|
|
3974
|
+
|
|
3975
|
+
- **`servers`**, **top-level `tags`**, and an info **`description`** (document options).
|
|
3976
|
+
- **`securitySchemes`** → `components.securitySchemes`, a document-wide **`security`**, and per-operation
|
|
3977
|
+
`security` (`[]` marks an operation explicitly public).
|
|
3978
|
+
- **Non-200 responses** and **non-JSON content** — a contract op's `responses` map declares extra status
|
|
3979
|
+
codes; `requestContentType` / `responseContentType` set media types other than `application/json`.
|
|
3980
|
+
- **`$ref` reuse** — a schema with a `$id` (`t.object({…}, { $id: "User" })`) is hoisted into
|
|
3981
|
+
`components.schemas` once and referenced by `$ref` everywhere it's used.
|
|
3982
|
+
- Per-operation **`summary`**, **`description`**, **`tags`**, **`deprecated`** — declared on the contract op.
|
|
3983
|
+
|
|
3984
|
+
A **contract** is richest (its ops carry all of the above); in **app** mode a route's response is a
|
|
3985
|
+
generic `200` with no schema — declare a `defineContract` with `response` schemas, or pass
|
|
3986
|
+
`options.operations` (keyed by `"METHOD /path"`) to enrich app routes. Routes using a BYO Standard Schema
|
|
3987
|
+
are still emitted without body/response detail (Standard Schema exposes no JSON Schema).
|
|
3988
|
+
|
|
3989
|
+
`@nifrajs/core` is a peer dependency. ESM-only. MIT.
|
|
3990
|
+
|
|
3991
|
+
## @nifrajs/uploads
|
|
3992
|
+
|
|
3993
|
+
File-upload hardening for nifra — magic-byte MIME detection, size + type validation, short-TTL signed download URLs, and EXIF/metadata stripping. Dependency-free, edge-safe.
|
|
3994
|
+
|
|
3995
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
3996
|
+
|
|
3997
|
+
## Install
|
|
3998
|
+
|
|
3999
|
+
```sh
|
|
4000
|
+
bun add @nifrajs/uploads
|
|
4001
|
+
```
|
|
4002
|
+
|
|
4003
|
+
## Docs
|
|
4004
|
+
|
|
4005
|
+
- Reference: <https://nifra.dev/docs>
|
|
4006
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4007
|
+
|
|
4008
|
+
MIT
|
|
4009
|
+
|
|
4010
|
+
## @nifrajs/web
|
|
4011
|
+
|
|
4012
|
+
Framework-agnostic SSR core for nifra — the render seam + HTML document orchestration.
|
|
4013
|
+
|
|
4014
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4015
|
+
|
|
4016
|
+
## Install
|
|
4017
|
+
|
|
4018
|
+
```sh
|
|
4019
|
+
bun add @nifrajs/web
|
|
4020
|
+
```
|
|
4021
|
+
|
|
4022
|
+
## Docs
|
|
4023
|
+
|
|
4024
|
+
- Reference: <https://nifra.dev/docs>
|
|
4025
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4026
|
+
|
|
4027
|
+
MIT
|
|
4028
|
+
|
|
4029
|
+
## @nifrajs/web-preact
|
|
4030
|
+
|
|
4031
|
+
Preact render adapter for @nifrajs/web — streaming SSR + hydration (no build plugin).
|
|
4032
|
+
|
|
4033
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4034
|
+
|
|
4035
|
+
## Install
|
|
4036
|
+
|
|
4037
|
+
```sh
|
|
4038
|
+
bun add @nifrajs/web-preact
|
|
4039
|
+
```
|
|
4040
|
+
|
|
4041
|
+
## Docs
|
|
4042
|
+
|
|
4043
|
+
- Reference: <https://nifra.dev/docs>
|
|
4044
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4045
|
+
|
|
4046
|
+
MIT
|
|
4047
|
+
|
|
4048
|
+
## @nifrajs/web-react
|
|
4049
|
+
|
|
4050
|
+
React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).
|
|
4051
|
+
|
|
4052
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4053
|
+
|
|
4054
|
+
## Install
|
|
4055
|
+
|
|
4056
|
+
```sh
|
|
4057
|
+
bun add @nifrajs/web-react
|
|
4058
|
+
```
|
|
4059
|
+
|
|
4060
|
+
## Docs
|
|
4061
|
+
|
|
4062
|
+
- Reference: <https://nifra.dev/docs>
|
|
4063
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4064
|
+
|
|
4065
|
+
MIT
|
|
4066
|
+
|
|
4067
|
+
## @nifrajs/web-solid
|
|
4068
|
+
|
|
4069
|
+
Solid render adapter for @nifrajs/web — SSR + hydration + the Solid Babel build plugin.
|
|
4070
|
+
|
|
4071
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4072
|
+
|
|
4073
|
+
## Install
|
|
4074
|
+
|
|
4075
|
+
```sh
|
|
4076
|
+
bun add @nifrajs/web-solid
|
|
4077
|
+
```
|
|
4078
|
+
|
|
4079
|
+
## Docs
|
|
4080
|
+
|
|
4081
|
+
- Reference: <https://nifra.dev/docs>
|
|
4082
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4083
|
+
|
|
4084
|
+
MIT
|
|
4085
|
+
|
|
4086
|
+
## @nifrajs/web-svelte
|
|
4087
|
+
|
|
4088
|
+
Svelte 5 render adapter for @nifrajs/web — SSR + hydration + the .svelte compiler Bun-plugin.
|
|
4089
|
+
|
|
4090
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4091
|
+
|
|
4092
|
+
## Install
|
|
4093
|
+
|
|
4094
|
+
```sh
|
|
4095
|
+
bun add @nifrajs/web-svelte
|
|
4096
|
+
```
|
|
4097
|
+
|
|
4098
|
+
## Docs
|
|
4099
|
+
|
|
4100
|
+
- Reference: <https://nifra.dev/docs>
|
|
4101
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4102
|
+
|
|
4103
|
+
MIT
|
|
4104
|
+
|
|
4105
|
+
## @nifrajs/web-vanilla
|
|
4106
|
+
|
|
4107
|
+
Zero-framework render adapter for @nifrajs/web — auto-escaping tagged-template HTML, ~0 KB client JS, interactivity via @nifrajs/web/islands.
|
|
4108
|
+
|
|
4109
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4110
|
+
|
|
4111
|
+
## Install
|
|
4112
|
+
|
|
4113
|
+
```sh
|
|
4114
|
+
bun add @nifrajs/web-vanilla
|
|
4115
|
+
```
|
|
4116
|
+
|
|
4117
|
+
## Docs
|
|
4118
|
+
|
|
4119
|
+
- Reference: <https://nifra.dev/docs>
|
|
4120
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4121
|
+
|
|
4122
|
+
MIT
|
|
4123
|
+
|
|
4124
|
+
## @nifrajs/web-vue
|
|
4125
|
+
|
|
4126
|
+
Vue render adapter for @nifrajs/web — streaming SSR + hydration, with a .vue SFC compiler Bun plugin (@nifrajs/web-vue/plugin).
|
|
4127
|
+
|
|
4128
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4129
|
+
|
|
4130
|
+
## Install
|
|
4131
|
+
|
|
4132
|
+
```sh
|
|
4133
|
+
bun add @nifrajs/web-vue
|
|
4134
|
+
```
|
|
4135
|
+
|
|
4136
|
+
## Docs
|
|
4137
|
+
|
|
4138
|
+
- Reference: <https://nifra.dev/docs>
|
|
4139
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4140
|
+
|
|
4141
|
+
MIT
|
|
4142
|
+
|
|
4143
|
+
## @nifrajs/workers
|
|
4144
|
+
|
|
4145
|
+
Cloudflare Workers helpers for nifra — a Durable Object hub for cross-connection WebSocket broadcast.
|
|
4146
|
+
|
|
4147
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework — one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
4148
|
+
|
|
4149
|
+
## Install
|
|
4150
|
+
|
|
4151
|
+
```sh
|
|
4152
|
+
bun add @nifrajs/workers
|
|
4153
|
+
```
|
|
4154
|
+
|
|
4155
|
+
## Docs
|
|
4156
|
+
|
|
4157
|
+
- Reference: <https://nifra.dev/docs>
|
|
4158
|
+
- AI-readable: <https://nifra.dev/llms.txt>
|
|
4159
|
+
|
|
4160
|
+
MIT
|
|
4161
|
+
|
|
4162
|
+
## create-nifra
|
|
4163
|
+
|
|
4164
|
+
Scaffold a new [nifra](../../README.md) app.
|
|
4165
|
+
|
|
4166
|
+
```sh
|
|
4167
|
+
bun create nifra my-app
|
|
4168
|
+
# or: npm create nifra my-app
|
|
4169
|
+
```
|
|
4170
|
+
|
|
4171
|
+
## Templates
|
|
4172
|
+
|
|
4173
|
+
`--template <name>` (default `api`):
|
|
4174
|
+
|
|
4175
|
+
- **`api`** — a typed nifra server (`src/app.ts` + `src/index.ts`) with an example test and
|
|
4176
|
+
`dev`/`start`/`test`/`typecheck` scripts.
|
|
4177
|
+
- **`site`** — a nifra + React SSR site, one source deployable to Cloudflare Pages, Node, Deno, or
|
|
4178
|
+
Vercel Edge.
|
|
4179
|
+
- **`isr`** — a nifra + React app with **Incremental Static Regeneration** on Cloudflare Workers + KV
|
|
4180
|
+
(pages cached + served stale-while-revalidate; on-demand purge endpoint).
|
|
4181
|
+
|
|
4182
|
+
```sh
|
|
4183
|
+
bun create nifra my-app --template isr
|
|
4184
|
+
```
|
|
4185
|
+
|
|
4186
|
+
Then:
|
|
4187
|
+
|
|
4188
|
+
```sh
|
|
4189
|
+
cd my-app
|
|
4190
|
+
bun install
|
|
4191
|
+
bun run dev
|
|
4192
|
+
```
|
|
4193
|
+
|
|
4194
|
+
MIT.
|
|
4195
|
+
|
|
4196
|
+
## nifra
|
|
4197
|
+
|
|
4198
|
+
The unscoped meta-entry for the **nifra** full-stack framework. It re-exports [`@nifrajs/core`](https://www.npmjs.com/package/@nifrajs/core), so:
|
|
4199
|
+
|
|
4200
|
+
```ts
|
|
4201
|
+
import { server } from "nifra" // === @nifrajs/core
|
|
4202
|
+
```
|
|
4203
|
+
|
|
4204
|
+
(The schema builder `t` lives in `@nifrajs/schema`; the rest of the framework is under `@nifrajs/*`.)
|
|
4205
|
+
|
|
4206
|
+
Most apps start with the scaffolder rather than this package:
|
|
4207
|
+
|
|
4208
|
+
```bash
|
|
4209
|
+
bun create nifra my-app
|
|
4210
|
+
```
|
|
4211
|
+
|
|
4212
|
+
The framework is a set of focused packages under the `@nifrajs/*` scope:
|
|
4213
|
+
|
|
4214
|
+
- `@nifrajs/core` — the Bun-native, Web-standard server (`server()`, routing, validation). This package re-exports it.
|
|
4215
|
+
- `@nifrajs/web` + `@nifrajs/web-{react,vue,svelte,solid,preact,vanilla}` — full-stack SSR on five UI frameworks.
|
|
4216
|
+
- `@nifrajs/client` — the end-to-end typed client.
|
|
4217
|
+
- `@nifrajs/schema` · `@nifrajs/middleware` · `@nifrajs/better-auth` · `@nifrajs/otel` · `@nifrajs/cli` … — schema, middleware, auth, tracing, the CLI/MCP toolchain, and more.
|
|
4218
|
+
|
|
4219
|
+
Docs + an AI-readable reference: [`/llms.txt`](https://nifra.dev/llms.txt) · [`/llms-full.txt`](https://nifra.dev/llms-full.txt).
|
|
4220
|
+
|
|
4221
|
+
MIT.
|
|
4222
|
+
|
|
4223
|
+
---
|
|
4224
|
+
|
|
4225
|
+
# Complete export index (generated from each package's src/index.ts)
|
|
4226
|
+
|
|
4227
|
+
## @nifrajs/auth
|
|
4228
|
+
|
|
4229
|
+
`createSessions`, `csrf`, `CsrfOptions`, `GuardOptions`, `KVNamespaceLike`, `KVSessionStore`, `MemorySessionStore`, `MemorySessionStoreOptions`, `requireSession`, `requireUser`, `Session`, `SessionContext`, `SessionCookieOptions`, `SessionManager`, `SessionOptions`, `SessionRecord`, `SessionStore`
|
|
4230
|
+
|
|
4231
|
+
## @nifrajs/better-auth
|
|
4232
|
+
|
|
4233
|
+
`betterAuth`, `BetterAuthLike`, `BetterAuthOptions`, `getSession`, `requireSession`, `RequireSessionOptions`, `SessionOf`
|
|
4234
|
+
|
|
4235
|
+
## @nifrajs/client
|
|
4236
|
+
|
|
4237
|
+
`ActionArgs`, `ActionData`, `ApiError`, `ApiProxy`, `client`, `ClientOptions`, `FetchFn`, `inProcessClient`, `Jsonify`, `LoaderArgs`, `LoaderData`, `RegistryOf`, `Result`, `testClient`, `Treaty`, `TreatyFromRegistry`
|
|
4238
|
+
|
|
4239
|
+
## @nifrajs/content
|
|
4240
|
+
|
|
4241
|
+
`bakeCollection`, `BakedCollection`, `Entry`, `fromBaked`, `InferSchema`, `parseEntry`, `ParseEntryOptions`, `parseFrontmatter`, `StandardSchemaV1`, `StaticCollection`
|
|
4242
|
+
|
|
4243
|
+
## @nifrajs/core
|
|
4244
|
+
|
|
4245
|
+
`AnyServer`, `attachWebSocket`, `commonSecretPatterns`, `Context`, `ContextForOp`, `ContractShape`, `CookieOptions`, `defineContract`, `defineIdentityPlugin`, `definePlugin`, `DurableObjectNamespaceLike`, `ExecutionContext`, `FRAMEWORK_NAME`, `FrameworkError`, `FrameworkName`, `Handler`, `HandlersFor`, `IdentityPlugin`, `implement`, `InferInput`, `InferOutput`, `jsonLogger`, `LogFields`, `Logger`, `Method`, `METHODS`, `Middleware`, `NifraPlugin`, `NifraWebSocket`, `NodeServeOutcome`, `OnRequestResult`, `OperationDef`, `Params`, `parseCookies`, `Platform`, `Prettify`, `redactLogFields`, `RedactOptions`, `Registry`, `RegistryFor`, `RegistryFromImpl`, `ResponseControls`, `ResponseDef`, `robots`, `RobotsOptions`, `RobotsRule`, `RouteConfigError`, `RouteConfigErrorCode`, `RouteDescriptor`, `RouteInfo`, `Router`, `RouterMatch`, `RouteSchema`, `RunningServer`, `ScheduledController`, `ScheduledHandler`, `serializeCookie`, `server`, `Server`, `ServerOptions`, `SignatureEncoding`, `signValue`, `silentLogger`, `sitemap`, `SitemapChangeFreq`, `SitemapEntry`, `SitemapOptions`, `sse`, `SSEContext`, `SSEInit`, `SSEMessage`, `SSEStream`, `StandardIssue`, `StandardResult`, `StandardSchemaV1`, `StandardTypes`, `StandardWebSocket`, `toFetchHandler`, `TopicRegistry`, `unsignValue`, `validateStandard`, `ValidationOutcome`, `verifyWebhook`, `VerifyWebhookOptions`, `Version`, `VERSION`, `WebhookFailureReason`, `WebhookProvider`, `WebhookResult`, `WebSocketContext`, `WebSocketData`, `WebSocketHandler`, `WebSocketUpgradeOutcome`
|
|
4246
|
+
|
|
4247
|
+
## @nifrajs/cron
|
|
4248
|
+
|
|
4249
|
+
`createScheduler`, `CronError`, `CronFields`, `CronHandler`, `matches`, `nextRun`, `parseCron`, `Scheduler`, `SchedulerOptions`
|
|
4250
|
+
|
|
4251
|
+
## @nifrajs/deno
|
|
4252
|
+
|
|
4253
|
+
`DenoServer`, `FetchHandler`, `serve`, `ServeOptions`
|
|
4254
|
+
|
|
4255
|
+
## @nifrajs/env
|
|
4256
|
+
|
|
4257
|
+
`boolean`, `defineEnv`, `DefineEnvOptions`, `enumValue`, `env`, `EnvResult`, `EnvShape`, `InferOutput`, `number`, `port`, `StandardResult`, `StandardSchemaV1`, `string`, `url`
|
|
4258
|
+
|
|
4259
|
+
## @nifrajs/i18n
|
|
4260
|
+
|
|
4261
|
+
`createFormatter`, `Formatter`, `Locale`, `Messages`, `negotiateLocale`, `NegotiateOptions`
|
|
4262
|
+
|
|
4263
|
+
## @nifrajs/image
|
|
4264
|
+
|
|
4265
|
+
`cloudflareLoader`, `CloudflareLoaderOptions`, `HtmlImageAttrs`, `identityLoader`, `imageDimensions`, `ImageFormat`, `ImageInfo`, `ImageLoader`, `ImageProps`, `readImageDimensions`, `ResolvedImage`, `resolveImage`, `selfHostedLoader`, `SelfHostedLoaderOptions`, `signImageUrl`, `SignImageUrlOptions`, `toHtmlAttrs`
|
|
4266
|
+
|
|
4267
|
+
## @nifrajs/islets
|
|
4268
|
+
|
|
4269
|
+
`batch`, `BindableElement`, `BindableRoot`, `bindScope`, `computed`, `effect`, `island`, `IslandContext`, `IslandHost`, `IslandScope`, `IslandSetup`, `islandState`, `mountIslands`, `signal`, `Signal`
|
|
4270
|
+
|
|
4271
|
+
## @nifrajs/middleware
|
|
4272
|
+
|
|
4273
|
+
`apiKey`, `ApiKeyStaticOptions`, `ApiKeyVerifyOptions`, `appendTrailingSlash`, `AuthPlugin`, `basicAuth`, `BasicAuthPlugin`, `BasicAuthStaticOptions`, `BasicAuthVerifyOptions`, `bearer`, `BearerOptions`, `bodyLimit`, `BodyLimitOptions`, `buildOpenApiDocument`, `cache`, `cacheControl`, `CacheControlOptions`, `CachedResponse`, `CacheOptions`, `combine`, `Composable`, `compression`, `CompressionOptions`, `cors`, `CorsOptions`, `createCsrfToken`, `csrf`, `CsrfOptions`, `etag`, `ETagOptions`, `healthcheck`, `HealthcheckOptions`, `idempotency`, `IdempotencyClaim`, `IdempotencyOptions`, `IdempotencyRecord`, `IdempotencyStore`, `IpMatcher`, `ipRestriction`, `IpRestrictionOptions`, `jwk`, `JwkKey`, `jwks`, `JwksOptions`, `jwt`, `JwtAlgorithm`, `JwtClaims`, `JwtHeader`, `JwtKeyResolver`, `JwtOptions`, `JwtPlugin`, `JwtVerificationKey`, `language`, `LanguageMatch`, `LanguageOptions`, `logger`, `LoggerOptions`, `MemoryIdempotencyStore`, `MemoryIdempotencyStoreOptions`, `MemoryResponseCache`, `MemoryResponseCacheOptions`, `MemoryStore`, `MemoryStoreOptions`, `methodOverride`, `MethodOverrideOptions`, `namedCombine`, `openapi`, `OpenApiInfo`, `OpenApiOptions`, `OpenApiServer`, `OpenApiTag`, `OpenApiUiOptions`, `pickLanguage`, `poweredBy`, `PoweredByOptions`, `prettyJson`, `PrettyJsonOptions`, `rateLimit`, `RateLimitOptions`, `RateLimitResult`, `RateLimitStore`, `requestId`, `RequestIdOptions`, `RequestLogFields`, `responseCache`, `ResponseCacheStore`, `RouteLike`, `securityHeaders`, `SecurityHeadersOptions`, `SecurityRequirement`, `timing`, `TimingControls`, `TimingMetric`, `TimingOptions`, `TrailingSlashOptions`, `trimTrailingSlash`, `tryVerifyJwt`, `VerifiedJwt`, `verifyCsrfToken`, `verifyJwt`, `VerifyJwtOptions`, `VerifyJwtResult`
|
|
4274
|
+
|
|
4275
|
+
## @nifrajs/node
|
|
4276
|
+
|
|
4277
|
+
`FetchHandler`, `NodeServer`, `RequestProtocol`, `RequestProtocolOption`, `serve`, `ServeOptions`, `ServeStaticOptions`
|
|
4278
|
+
|
|
4279
|
+
## @nifrajs/otel
|
|
4280
|
+
|
|
4281
|
+
`AttributeValue`, `consoleSpanExporter`, `formatTraceparent`, `generateSpanId`, `generateTraceId`, `NifraSpan`, `ParsedTraceparent`, `parseTraceparent`, `SpanExporter`, `SpanStatus`, `TraceContext`, `traceHeaders`, `tracing`, `TracingOptions`
|
|
4282
|
+
|
|
4283
|
+
## @nifrajs/runner
|
|
4284
|
+
|
|
4285
|
+
`AppLike`, `RequestSpec`, `runApp`, `RunOptions`, `runRequest`, `RunResult`
|
|
4286
|
+
|
|
4287
|
+
## @nifrajs/schema
|
|
4288
|
+
|
|
4289
|
+
`fromTypeBox`, `NifraSchema`, `OpenAPIDocument`, `OpenAPIInfo`, `registerFormat`, `t`, `toOpenAPI`
|
|
4290
|
+
|
|
4291
|
+
## @nifrajs/uploads
|
|
4292
|
+
|
|
4293
|
+
`detectFileType`, `FileType`, `ImageReencoder`, `signDownloadUrl`, `SignDownloadUrlOptions`, `stripImageMetadata`, `StripImageMetadataOptions`, `UploadResult`, `validateUpload`, `ValidateUploadOptions`, `verifyDownloadUrl`
|
|
4294
|
+
|
|
4295
|
+
## @nifrajs/web
|
|
4296
|
+
|
|
4297
|
+
`Action`, `ACTION_GLOBAL`, `buildManifest`, `CachedResponse`, `CacheStore`, `clientEntry`, `ClientRouter`, `ClientRouterOptions`, `createClientRouter`, `createMatcher`, `createQueryClient`, `createWebApp`, `CreateWebAppOptions`, `DATA_GLOBAL`, `DATA_HEADER`, `defer`, `Deferred`, `disableDraft`, `DRAFT_COOKIE`, `DraftCookieControls`, `enableDraft`, `EnableDraftOptions`, `enumeratePrerenderedPaths`, `enumerateStaticRoutes`, `Fetcher`, `FetcherState`, `FetchRouteData`, `filePathToPattern`, `filePathToPatterns`, `FontDisplay`, `fontFace`, `FontFace`, `fontPreload`, `FontPreloadInput`, `FontSource`, `generateClientEntry`, `GenerateClientEntryOptions`, `generateServerManifest`, `GenerateServerManifestOptions`, `GetStaticPaths`, `hashQueryKey`, `isDraftEnabled`, `ISR_REVALIDATE_HEADER`, `ISR_STATUS_HEADER`, `ISRApp`, `ISROptions`, `ISRPlatform`, `KVCacheStore`, `KVCacheStoreOptions`, `KVNamespaceLike`, `LayoutEntry`, `Loader`, `LoaderContext`, `manifest`, `Manifest`, `MemoryCacheStore`, `MemoryCacheStoreOptions`, `Meta`, `MetaArgs`, `MetaInput`, `MountRouterOptions`, `QueryClient`, `QueryClientOptions`, `QueryHandle`, `QueryState`, `QueryStatus`, `redirect`, `REDIRECT_HEADER`, `RedirectOptions`, `RenderAdapter`, `RenderedPage`, `renderPage`, `RenderPageOptions`, `renderPageResult`, `RenderProps`, `resolveMeta`, `revalidate`, `REVALIDATE_HEADER`, `revalidateEndpoint`, `RevalidateEndpointOptions`, `RevalidateResult`, `ROUTE_GLOBAL`, `RouteEntry`, `RouteMatch`, `RouteModule`, `RoutePattern`, `RouterState`, `serializeData`, `StaticPath`, `StaticPaths`, `StaticRoutes`, `Submission`, `SubmitOptions`, `withISR`
|
|
4298
|
+
|
|
4299
|
+
## @nifrajs/web-preact
|
|
4300
|
+
|
|
4301
|
+
`preactAdapter`
|
|
4302
|
+
|
|
4303
|
+
## @nifrajs/web-react
|
|
4304
|
+
|
|
4305
|
+
`reactAdapter`
|
|
4306
|
+
|
|
4307
|
+
## @nifrajs/web-solid
|
|
4308
|
+
|
|
4309
|
+
`solidAdapter`, `solidBunPlugin`
|
|
4310
|
+
|
|
4311
|
+
## @nifrajs/web-svelte
|
|
4312
|
+
|
|
4313
|
+
`svelteAdapter`, `svelteBunPlugin`
|
|
4314
|
+
|
|
4315
|
+
## @nifrajs/web-vanilla
|
|
4316
|
+
|
|
4317
|
+
`compose`, `html`, `HtmlValue`, `hydrate`, `raw`, `RawHtml`, `Template`, `vanillaAdapter`, `VanillaComponent`
|
|
4318
|
+
|
|
4319
|
+
## @nifrajs/web-vue
|
|
4320
|
+
|
|
4321
|
+
`vueAdapter`
|
|
4322
|
+
|
|
4323
|
+
## @nifrajs/workers
|
|
4324
|
+
|
|
4325
|
+
`createWebSocketHub`, `NifraWebSocketHub`, `WebSocketHubApp`, `WebSocketHubClass`
|