@netrojs/fnetro 0.1.5 → 0.2.0

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/README.md CHANGED
@@ -1,257 +1,196 @@
1
- # ⬡ FNetro
1
+ # @netrojs/fnetro
2
2
 
3
- > Full-stack [Hono](https://hono.dev) framework — SSR, SPA, Vue-like reactivity, route groups, middleware and raw API routes in **3 files**.
3
+ > Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+** — SSR, SPA, SEO, server & client middleware, TypeScript-first.
4
4
 
5
- [![npm](https://img.shields.io/npm/v/@netrojs/fnetro?color=6b8cff&label=fnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
6
- [![npm](https://img.shields.io/npm/v/@netrojs/create-fnetro?color=3ecf8e&label=create-fnetro)](https://www.npmjs.com/package/@netrojs/create-fnetro)
7
- [![CI](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml/badge.svg)](https://github.com/netrosolutions/fnetro/actions)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
+ [![npm version](https://img.shields.io/npm/v/@netrojs/fnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
6
+ [![license](https://img.shields.io/npm/l/@netrojs/fnetro)](./LICENSE)
9
7
 
10
8
  ---
11
9
 
12
10
  ## Table of contents
13
11
 
14
- - [Quick start](#quick-start)
15
- - [How it works](#how-it-works)
16
- - [Project structure](#project-structure)
17
- - [Core concepts](#core-concepts)
18
- - [definePage](#definepage)
19
- - [defineGroup](#definegroup)
20
- - [defineLayout](#definelayout)
21
- - [defineApiRoute](#defineapiroute)
22
- - [defineMiddleware](#definemiddleware)
23
- - [Reactivity](#reactivity)
24
- - [ref](#ref)
25
- - [reactive](#reactive)
26
- - [computed](#computed)
27
- - [watch](#watch)
28
- - [watchEffect](#watcheffect)
29
- - [effectScope](#effectscope)
30
- - [Helpers](#helpers)
31
- - [Component hooks](#component-hooks)
32
- - [Routing](#routing)
33
- - [Dynamic segments](#dynamic-segments)
34
- - [Route groups](#route-groups)
35
- - [Layout overrides](#layout-overrides)
36
- - [Server](#server)
37
- - [createFNetro](#createfnetro)
38
- - [serve](#serve)
39
- - [Runtime detection](#runtime-detection)
40
- - [Client](#client)
41
- - [boot](#boot)
42
- - [navigate](#navigate)
43
- - [prefetch](#prefetch)
44
- - [Lifecycle hooks](#lifecycle-hooks)
45
- - [Vite plugin](#vite-plugin)
46
- - [Dev server](#dev-server)
47
- - [Global store pattern](#global-store-pattern)
48
- - [TypeScript](#typescript)
49
- - [Runtime support](#runtime-support)
50
- - [API reference](#api-reference)
12
+ 1. [Quick start](#quick-start)
13
+ 2. [Installation](#installation)
14
+ 3. [Project structure](#project-structure)
15
+ 4. [Core concepts](#core-concepts)
16
+ 5. [Routing](#routing)
17
+ - [definePage](#definepage)
18
+ - [defineGroup](#definegroup)
19
+ - [defineLayout](#definelayout)
20
+ - [defineApiRoute](#defineapiroute)
21
+ 6. [Loaders](#loaders)
22
+ 7. [SEO](#seo)
23
+ 8. [Middleware](#middleware)
24
+ - [Server middleware](#server-middleware)
25
+ - [Client middleware](#client-middleware)
26
+ 9. [SolidJS reactivity](#solidjs-reactivity)
27
+ 10. [Navigation](#navigation)
28
+ 11. [Asset handling](#asset-handling)
29
+ 12. [Multi-runtime `serve()`](#multi-runtime-serve)
30
+ 13. [Vite plugin](#vite-plugin)
31
+ 14. [TypeScript](#typescript)
32
+ 15. [API reference](#api-reference)
51
33
 
52
34
  ---
53
35
 
54
36
  ## Quick start
55
37
 
56
38
  ```bash
57
- # npm
58
- npm create @netrojs/fnetro@latest
59
-
60
- # bun
61
- bun x @netrojs/create-fnetro
62
-
63
- # pnpm
64
- pnpm create @netrojs/fnetro@latest
39
+ npm create @netrojs/fnetro@latest my-app
40
+ cd my-app
41
+ npm install
42
+ npm run dev
65
43
  ```
66
44
 
67
- The CLI will prompt for your project name, runtime (Node / Bun / Deno / Cloudflare / generic), template, and package manager, then scaffold a ready-to-run app.
45
+ ---
46
+
47
+ ## Installation
68
48
 
69
49
  ```bash
70
- cd my-app
71
- bun install
72
- bun run dev # dev server — no build step required
73
- ```
50
+ # npm
51
+ npm install @netrojs/fnetro solid-js hono
74
52
 
75
- **Manual install:**
53
+ # Dev deps (build toolchain)
54
+ npm install -D vite vite-plugin-solid @hono/vite-dev-server typescript
55
+ ```
76
56
 
57
+ For Node.js runtime add:
77
58
  ```bash
78
- npm install @netrojs/fnetro hono
79
- npm install -D vite typescript @hono/vite-dev-server
80
- # Node.js only:
81
59
  npm install -D @hono/node-server
82
60
  ```
83
61
 
84
- ---
62
+ ### Peer dependencies
85
63
 
86
- ## How it works
87
-
88
- FNetro is **three files** and no magic:
89
-
90
- | File | Size | Purpose |
64
+ | Package | Version | Required? |
91
65
  |---|---|---|
92
- | `fnetro/core` | ~734 lines | Vue-like reactivity + all route/layout/middleware type definitions |
93
- | `fnetro/server` | ~415 lines | `createFNetro()`, SSR renderer, `serve()` (auto-detects runtime), Vite plugin |
94
- | `fnetro/client` | ~307 lines | SPA boot, click interception, hover prefetch, navigation lifecycle |
95
-
96
- **First load (SSR):**
97
- ```
98
- Browser → GET /posts/hello
99
- Server → runs loader({ slug: 'hello' }) → { post: {...} }
100
- Server → renderToString(<Layout><PostPage post={...} /></Layout>)
101
- Server → injects window.__FNETRO_STATE__ = { "/posts/hello": { post: {...} } }
102
- Browser → receives full HTML — visible immediately, works without JS
103
- Browser → loads client.js → reads __FNETRO_STATE__ synchronously
104
- Client → render(<Layout><PostPage post={...} /></Layout>) → live DOM
105
- ↑ zero extra fetch — same data, no loading spinner
106
- ```
107
-
108
- **SPA navigation:**
109
- ```
110
- User clicks <a href="/posts/world">
111
- Client intercepts click
112
- Client → fetch('/posts/world', { 'x-fnetro-spa': '1' })
113
- Server → runs loader() → returns JSON { html, state, params, url }
114
- Client → render(new page tree) → swaps DOM in place
115
- Client → history.pushState() → URL updates, scroll resets
116
- ```
66
+ | `solid-js` | `>=1.9.11` | Always |
67
+ | `hono` | `>=4.0.0` | Always |
68
+ | `vite` | `>=5.0.0` | Build only |
69
+ | `vite-plugin-solid` | `>=2.11.11` | Build only |
117
70
 
118
71
  ---
119
72
 
120
73
  ## Project structure
121
74
 
122
- The scaffold generates this layout:
123
-
124
75
  ```
125
76
  my-app/
126
- ├── app.ts # Shared FNetro app — used by dev server AND server.ts
127
- ├── server.ts # Production entry — calls serve()
77
+ ├── app.ts # Shared FNetro app — used by dev server and server.ts
78
+ ├── server.ts # Production server entry — calls serve()
128
79
  ├── client.ts # Browser entry — calls boot()
129
- ├── vite.config.ts # fnetroVitePlugin + @hono/vite-dev-server
130
- ├── tsconfig.json
131
- ├── package.json
132
-
133
80
  ├── app/
134
- │ ├── layouts.tsx # Root layout (nav, footer, theme)
135
- │ ├── store.ts # Global reactive state (optional)
81
+ │ ├── layouts.tsx # defineLayout() shared nav/footer shell
136
82
  │ └── routes/
137
- │ ├── home.tsx # GET /
138
- │ ├── about.tsx # GET /about
139
- ├── api.ts # Raw Hono routes at /api
140
- │ └── posts/
141
- ├── index.tsx # GET /posts
142
- │ └── [slug].tsx # GET /posts/:slug
143
-
144
- └── public/
145
- └── style.css
83
+ │ ├── home.tsx # definePage({ path: '/' })
84
+ │ ├── about.tsx # definePage({ path: '/about' })
85
+ └── api.ts # defineApiRoute('/api', ...)
86
+ ├── public/
87
+ └── style.css # Static assets served at /
88
+ ├── vite.config.ts
89
+ └── tsconfig.json
146
90
  ```
147
91
 
148
- **`app.ts`** exports `fnetro` and `default` (the fetch handler). `@hono/vite-dev-server` imports the default export during development. `server.ts` imports `fnetro` for production.
92
+ ---
93
+
94
+ ## Core concepts
149
95
 
150
- ```ts
151
- // app.ts
152
- import { createFNetro } from '@netrojs/fnetro/server'
153
- import { RootLayout } from './app/layouts'
154
- import home from './app/routes/home'
96
+ FNetro is built on three files:
155
97
 
156
- export const fnetro = createFNetro({ layout: RootLayout, routes: [home] })
157
- export default fnetro.handler // consumed by @hono/vite-dev-server in dev
158
- ```
98
+ | File | Purpose |
99
+ |---|---|
100
+ | `@netrojs/fnetro` (core) | Route builders, SEO types, path matching utilities |
101
+ | `@netrojs/fnetro/server` | Hono app factory, SSR renderer, Vite plugin, `serve()` |
102
+ | `@netrojs/fnetro/client` | SolidJS hydration, SPA routing, client middleware |
159
103
 
160
- ```ts
161
- // server.ts — production only
162
- import { serve } from '@netrojs/fnetro/server'
163
- import { fnetro } from './app'
164
- await serve({ app: fnetro, port: 3000 })
104
+ **Data flow:**
105
+
106
+ ```
107
+ Request
108
+ Hono middleware
109
+ → Route match
110
+ → Loader runs (server-side)
111
+ → SolidJS SSR renders HTML
112
+ → HTML + state injected into shell
113
+ → Client hydrates
114
+ → SPA navigation takes over (no full page reloads)
165
115
  ```
166
116
 
167
117
  ---
168
118
 
169
- ## Core concepts
119
+ ## Routing
170
120
 
171
121
  ### `definePage`
172
122
 
173
- A page is a path + optional server loader + JSX component. Everything in one file — no "use client" directives, no separate API routes, no split files.
123
+ Define a page with a path, optional loader, optional SEO, and a SolidJS component.
174
124
 
175
125
  ```tsx
176
- // app/routes/post.tsx
177
- import { definePage, ref, use } from '@netrojs/fnetro/core'
178
-
179
- // Module-level signal — value persists across SPA navigations
180
- const viewCount = ref(0)
126
+ // app/routes/home.tsx
127
+ import { definePage } from '@netrojs/fnetro'
181
128
 
182
129
  export default definePage({
183
- path: '/posts/[slug]',
130
+ path: '/',
184
131
 
185
- // Runs on the server. Return value becomes Page props.
186
- // Serialized into window.__FNETRO_STATE__ — client reads it without refetching.
187
- async loader(c) {
188
- const slug = c.req.param('slug')
189
- const post = await db.findPost(slug)
190
- if (!post) throw new Error('Not found')
191
- return { post }
132
+ // Optional server-side data loader
133
+ loader: async (c) => {
134
+ const data = await fetchSomeData()
135
+ return { items: data }
192
136
  },
193
137
 
194
- // Rendered server-side on first load, client-side on SPA navigation.
195
- // Same JSX source — two runtimes (hono/jsx on server, hono/jsx/dom on client).
196
- Page({ post, url, params }) {
197
- const views = use(viewCount) // reactive subscription
138
+ // Optional SEO (see § SEO)
139
+ seo: {
140
+ title: 'Home My App',
141
+ description: 'Welcome to my app.',
142
+ },
143
+
144
+ // SolidJS component — receives loader data + url + params
145
+ Page({ items, url, params }) {
198
146
  return (
199
- <article>
200
- <h1>{post.title}</h1>
201
- <p>{views} views this session</p>
202
- <button onClick={() => viewCount.value++}>👁</button>
203
- </article>
147
+ <ul>
148
+ {items.map(item => <li>{item.name}</li>)}
149
+ </ul>
204
150
  )
205
151
  },
206
152
  })
207
153
  ```
208
154
 
209
- Props available in every `Page`:
155
+ **Dynamic segments** use `[param]` syntax:
210
156
 
211
- | Prop | Type | Description |
212
- |---|---|---|
213
- | `url` | `string` | Current pathname, e.g. `/posts/hello` |
214
- | `params` | `Record<string, string>` | Parsed path params, e.g. `{ slug: 'hello' }` |
215
- | `...loaderData` | inferred | Every key returned by `loader()` |
157
+ ```ts
158
+ // matches /posts/hello-world → params.slug = 'hello-world'
159
+ definePage({ path: '/posts/[slug]', ... })
160
+
161
+ // catch-all: matches /files/a/b/c params.rest = 'a/b/c'
162
+ definePage({ path: '/files/[...rest]', ... })
163
+ ```
216
164
 
217
165
  ---
218
166
 
219
167
  ### `defineGroup`
220
168
 
221
- Groups nest routes under a prefix, sharing a layout and middleware chain.
222
-
223
- ```tsx
224
- import { defineGroup, definePage } from '@netrojs/fnetro/core'
225
- import { AdminLayout } from '../layouts'
226
- import { requireAuth, auditLog } from '../middleware'
169
+ Group routes under a shared prefix, layout, and middleware.
227
170
 
228
- const dashboard = definePage({ path: '', loader: ..., Page: ... }) // /admin
229
- const users = definePage({ path: '/users', loader: ..., Page: ... }) // /admin/users
230
- const settings = definePage({ path: '/settings', loader: ..., Page: ... })
171
+ ```ts
172
+ import { defineGroup } from '@netrojs/fnetro'
173
+ import { requireAuth } from './middleware/auth'
174
+ import { AdminLayout } from './layouts'
175
+ import dashboard from './routes/admin/dashboard'
176
+ import users from './routes/admin/users'
231
177
 
232
178
  export const adminGroup = defineGroup({
233
- prefix: '/admin',
234
- layout: AdminLayout, // overrides app-level layout
235
- middleware: [requireAuth, auditLog], // applied to every route in group
236
- routes: [dashboard, users, settings],
179
+ prefix: '/admin',
180
+ layout: AdminLayout,
181
+ middleware: [requireAuth],
182
+ routes: [dashboard, users],
237
183
  })
238
184
  ```
239
185
 
240
- Groups nest arbitrarily:
186
+ Groups can be nested:
241
187
 
242
- ```tsx
188
+ ```ts
243
189
  defineGroup({
244
- prefix: '/org/[orgId]',
245
- middleware: [loadOrg],
190
+ prefix: '/api',
246
191
  routes: [
247
- definePage({ path: '', ... }),
248
- defineGroup({
249
- prefix: '/team',
250
- middleware: [requireTeamMember],
251
- routes: [
252
- definePage({ path: '/[teamId]', ... }) // /org/:orgId/team/:teamId
253
- ],
254
- }),
192
+ defineGroup({ prefix: '/v1', routes: [v1Routes] }),
193
+ defineGroup({ prefix: '/v2', routes: [v2Routes] }),
255
194
  ],
256
195
  })
257
196
  ```
@@ -260,935 +199,599 @@ defineGroup({
260
199
 
261
200
  ### `defineLayout`
262
201
 
263
- A layout wraps pages with shared chrome nav, footer, theme, auth state.
202
+ Create a shared layout component that wraps page content.
264
203
 
265
204
  ```tsx
266
- // app/layouts.tsx
267
- import { defineLayout, use, ref } from '@netrojs/fnetro/core'
268
- import { theme, toggleTheme } from './store'
269
-
270
- export const RootLayout = defineLayout(function Layout({ children, url, params }) {
271
- const t = use(theme) // reactive re-renders when theme changes
272
-
273
- return (
274
- <div class={`app theme-${t}`}>
275
- <nav>
276
- <a href="/">Home</a>
277
- <a href="/posts">Posts</a>
278
- <button onClick={toggleTheme}>
279
- {t === 'dark' ? '☀️' : '🌙'}
280
- </button>
281
- </nav>
282
- <main>{children}</main>
283
- <footer>Built with ⬡ FNetro</footer>
284
- </div>
285
- )
286
- })
205
+ import { defineLayout } from '@netrojs/fnetro'
206
+ import { createSignal } from 'solid-js'
207
+
208
+ const [mobileOpen, setMobileOpen] = createSignal(false)
209
+
210
+ export const RootLayout = defineLayout(({ children, url, params }) => (
211
+ <div class="app">
212
+ <nav class="navbar">
213
+ <a href="/" class="logo">My App</a>
214
+ <a href="/" class={url === '/' ? 'active' : ''}>Home</a>
215
+ <a href="/about" class={url === '/about' ? 'active' : ''}>About</a>
216
+ </nav>
217
+ <main>{children}</main>
218
+ <footer>© 2025</footer>
219
+ </div>
220
+ ))
287
221
  ```
288
222
 
289
- **Override or remove the layout per page:**
223
+ **Per-page layout override:**
290
224
 
291
- ```tsx
292
- // Use a custom layout just for this page
293
- definePage({ path: '/landing', layout: FullscreenLayout, Page: ... })
225
+ ```ts
226
+ // Use a different layout for this page
227
+ definePage({ path: '/landing', layout: LandingLayout, Page: ... })
294
228
 
295
- // Render without any layout (bare HTML)
296
- definePage({ path: '/embed', layout: false, Page: ... })
229
+ // No layout for this page
230
+ definePage({ path: '/embed', layout: false, Page: ... })
297
231
  ```
298
232
 
299
233
  ---
300
234
 
301
235
  ### `defineApiRoute`
302
236
 
303
- Mount raw Hono routes at any path. Full Hono API: routing, middleware, validators, streaming, WebSockets. API routes are registered before the page handler so `/api/*` is never caught by SPA navigation.
237
+ Mount raw Hono routes at a path. Full Hono API available.
304
238
 
305
- ```tsx
306
- // app/routes/api.ts
307
- import { defineApiRoute } from '@netrojs/fnetro/core'
239
+ ```ts
240
+ import { defineApiRoute } from '@netrojs/fnetro'
308
241
  import { zValidator } from '@hono/zod-validator'
309
242
  import { z } from 'zod'
310
243
 
311
- export const apiRoutes = defineApiRoute('/api', (app) => {
312
- // GET /api/health
244
+ export const api = defineApiRoute('/api', (app) => {
313
245
  app.get('/health', (c) => c.json({ status: 'ok', ts: Date.now() }))
314
246
 
315
- // GET /api/posts
316
- app.get('/posts', async (c) => {
317
- const posts = await db.posts.findAll()
318
- return c.json({ posts })
247
+ app.get('/users/:id', async (c) => {
248
+ const user = await db.users.find(c.req.param('id'))
249
+ if (!user) return c.json({ error: 'Not found' }, 404)
250
+ return c.json(user)
319
251
  })
320
252
 
321
- // POST /api/posts — with Zod validation
322
253
  app.post(
323
- '/posts',
324
- zValidator('json', z.object({ title: z.string().min(1), body: z.string() })),
254
+ '/items',
255
+ zValidator('json', z.object({ name: z.string().min(1) })),
325
256
  async (c) => {
326
- const data = c.req.valid('json')
327
- const post = await db.posts.create(data)
328
- return c.json(post, 201)
329
- }
257
+ const body = c.req.valid('json')
258
+ const item = await db.items.create(body)
259
+ return c.json(item, 201)
260
+ },
330
261
  )
331
262
 
332
- // Mount a sub-app
333
- app.route('/admin', adminRpc)
263
+ // WebSocket example
264
+ app.get('/ws', upgradeWebSocket(() => ({
265
+ onMessage(e, ws) { ws.send(`Echo: ${e.data}`) },
266
+ })))
334
267
  })
335
268
  ```
336
269
 
337
270
  ---
338
271
 
339
- ### `defineMiddleware`
272
+ ## Loaders
340
273
 
341
- Works at app, group, or page level. Receives the Hono `Context` and a `next` function.
274
+ Loaders run **server-side on every request** (both SSR and SPA navigation).
275
+ The return value is serialized to JSON and passed to the Page component as props.
342
276
 
343
277
  ```ts
344
- // app/middleware/auth.ts
345
- import { defineMiddleware } from '@netrojs/fnetro/core'
346
-
347
- export const requireAuth = defineMiddleware(async (c, next) => {
348
- const token = c.req.header('Authorization')?.replace('Bearer ', '')
349
- const user = token ? verifyJwt(token) : null
350
- if (!user) return c.json({ error: 'Unauthorized' }, 401)
351
- c.set('user', user)
352
- await next()
353
- })
278
+ definePage({
279
+ path: '/posts/[slug]',
354
280
 
355
- export const rateLimit = defineMiddleware(async (c, next) => {
356
- const ip = c.req.header('cf-connecting-ip') ?? 'unknown'
357
- if (await limiter.isLimited(ip)) return c.json({ error: 'Rate limited' }, 429)
358
- await next()
359
- })
281
+ loader: async (c) => {
282
+ // c is a Hono Context — access headers, cookies, query params, etc.
283
+ const slug = c.req.param('slug')
284
+ const token = getCookie(c, 'session')
285
+ const post = await db.posts.findBySlug(slug)
360
286
 
361
- export const logger = defineMiddleware(async (c, next) => {
362
- const start = Date.now()
363
- await next()
364
- console.log(`${c.req.method} ${c.req.url} ${c.res.status} ${Date.now() - start}ms`)
365
- })
366
- ```
287
+ if (!post) {
288
+ // Return a 404 response from the loader
289
+ return c.notFound()
290
+ }
367
291
 
368
- **Apply at every level:**
292
+ return { post }
293
+ },
369
294
 
370
- ```ts
371
- createFNetro({
372
- middleware: [logger], // every request
373
- routes: [
374
- defineGroup({
375
- middleware: [requireAuth], // every route in group
376
- routes: [
377
- definePage({
378
- middleware: [rateLimit], // this page only
379
- Page: ...,
380
- }),
381
- ],
382
- }),
383
- ],
295
+ Page({ post }) { ... },
384
296
  })
385
297
  ```
386
298
 
387
- ---
388
-
389
- ## Reactivity
390
-
391
- FNetro implements the complete Vue Reactivity API from scratch (~500 lines, no external dependencies). All functions work identically on server (SSR, no DOM) and client (live re-renders).
392
-
393
- ### `ref`
394
-
395
- A reactive container for any value. Read with `.value`, write with `.value =`.
299
+ **Type-safe loaders:**
396
300
 
397
301
  ```ts
398
- import { ref } from '@netrojs/fnetro/core'
399
-
400
- const count = ref(0)
401
- count.value++ // triggers all watchers
402
- console.log(count.value) // 1
403
- ```
404
-
405
- **`shallowRef`** — reactive only at the top level (mutations inside an object won't trigger):
406
-
407
- ```ts
408
- const list = shallowRef<string[]>([])
409
- list.value.push('a') // won't trigger — shallow
410
- list.value = [...list.value, 'a'] // triggers — new reference
411
- ```
412
-
413
- **`triggerRef`** — manually force-trigger a shallow ref after an internal mutation:
302
+ interface PostData {
303
+ post: Post
304
+ author: User
305
+ }
414
306
 
415
- ```ts
416
- list.value.push('a')
417
- triggerRef(list) // force subscribers to re-run
307
+ definePage<PostData>({
308
+ loader: async (c): Promise<PostData> => ({
309
+ post: await db.posts.find(c.req.param('id')),
310
+ author: await db.users.find(post.authorId),
311
+ }),
312
+ Page({ post, author }) { /* fully typed */ },
313
+ })
418
314
  ```
419
315
 
420
316
  ---
421
317
 
422
- ### `reactive`
318
+ ## SEO
423
319
 
424
- Deep reactive proxy of an object. All nested reads and writes are tracked automatically.
320
+ Every page can declare `seo` as a **static object** or a **function of loader data**.
321
+ App-level `seo` provides defaults; page-level values override them.
425
322
 
426
323
  ```ts
427
- import { reactive } from '@netrojs/fnetro/core'
428
-
429
- const state = reactive({
430
- user: { name: 'Alice', role: 'admin' },
431
- cart: { items: [] as CartItem[] },
324
+ // app.ts global defaults
325
+ createFNetro({
326
+ seo: {
327
+ ogType: 'website',
328
+ ogSiteName: 'My App',
329
+ twitterCard: 'summary_large_image',
330
+ twitterSite: '@myapp',
331
+ robots: 'index, follow',
332
+ },
333
+ routes: [...],
432
334
  })
433
335
 
434
- state.user.name = 'Bob' // triggers any watcher that read state.user.name
435
- state.cart.items.push(item) // array mutations tracked
336
+ // app/routes/post.tsx page-level (merges with app defaults)
337
+ definePage({
338
+ path: '/posts/[slug]',
339
+ loader: async (c) => ({ post: await getPost(c.req.param('slug')) }),
340
+
341
+ // Function form — receives loader data and params
342
+ seo: (data, params) => ({
343
+ title: `${data.post.title} — My Blog`,
344
+ description: data.post.excerpt,
345
+ canonical: `https://myapp.com/posts/${params.slug}`,
346
+ ogTitle: data.post.title,
347
+ ogDescription: data.post.excerpt,
348
+ ogImage: data.post.coverImageUrl,
349
+ ogImageWidth: '1200',
350
+ ogImageHeight: '630',
351
+ twitterTitle: data.post.title,
352
+ twitterImage: data.post.coverImageUrl,
353
+ jsonLd: {
354
+ '@context': 'https://schema.org',
355
+ '@type': 'Article',
356
+ headline: data.post.title,
357
+ author: { '@type': 'Person', name: data.post.authorName },
358
+ datePublished: data.post.publishedAt,
359
+ },
360
+ }),
361
+ Page({ post }) { ... },
362
+ })
436
363
  ```
437
364
 
438
- **`shallowReactive`** tracks only top-level keys, not nested objects:
365
+ ### All SEO fields
439
366
 
440
- ```ts
441
- const form = shallowReactive({ name: '', email: '' })
442
- ```
367
+ | Field | HTML output |
368
+ |---|---|
369
+ | `title` | `<title>` |
370
+ | `description` | `<meta name="description">` |
371
+ | `keywords` | `<meta name="keywords">` |
372
+ | `author` | `<meta name="author">` |
373
+ | `robots` | `<meta name="robots">` |
374
+ | `canonical` | `<link rel="canonical">` |
375
+ | `themeColor` | `<meta name="theme-color">` |
376
+ | `ogTitle` | `<meta property="og:title">` |
377
+ | `ogDescription` | `<meta property="og:description">` |
378
+ | `ogImage` | `<meta property="og:image">` |
379
+ | `ogImageAlt` | `<meta property="og:image:alt">` |
380
+ | `ogImageWidth` | `<meta property="og:image:width">` |
381
+ | `ogImageHeight` | `<meta property="og:image:height">` |
382
+ | `ogUrl` | `<meta property="og:url">` |
383
+ | `ogType` | `<meta property="og:type">` |
384
+ | `ogSiteName` | `<meta property="og:site_name">` |
385
+ | `ogLocale` | `<meta property="og:locale">` |
386
+ | `twitterCard` | `<meta name="twitter:card">` |
387
+ | `twitterSite` | `<meta name="twitter:site">` |
388
+ | `twitterCreator` | `<meta name="twitter:creator">` |
389
+ | `twitterTitle` | `<meta name="twitter:title">` |
390
+ | `twitterDescription` | `<meta name="twitter:description">` |
391
+ | `twitterImage` | `<meta name="twitter:image">` |
392
+ | `jsonLd` | `<script type="application/ld+json">` |
393
+ | `extra` | Custom `<meta>` tags |
394
+
395
+ **Client-side SEO sync:** On SPA navigation, all `<meta>` tags and `document.title` are updated automatically — no full reload needed.
443
396
 
444
397
  ---
445
398
 
446
- ### `computed`
447
-
448
- A lazily-evaluated, cached derived value. Re-evaluates only when its dependencies change.
449
-
450
- ```ts
451
- import { ref, computed } from '@netrojs/fnetro/core'
452
-
453
- const firstName = ref('Alice')
454
- const lastName = ref('Smith')
455
-
456
- // Read-only
457
- const fullName = computed(() => `${firstName.value} ${lastName.value}`)
458
- console.log(fullName.value) // 'Alice Smith'
459
-
460
- // Writable
461
- const name = computed({
462
- get: () => `${firstName.value} ${lastName.value}`,
463
- set: (v) => {
464
- const [f, l] = v.split(' ')
465
- firstName.value = f
466
- lastName.value = l
467
- },
468
- })
469
- name.value = 'Bob Jones'
470
- console.log(firstName.value) // 'Bob'
471
- ```
472
-
473
- ---
399
+ ## Middleware
474
400
 
475
- ### `watch`
401
+ ### Server middleware
476
402
 
477
- Runs a callback when a source changes. Not immediate by default.
403
+ Hono middleware applied at three levels:
478
404
 
479
405
  ```ts
480
- import { ref, watch } from '@netrojs/fnetro/core'
481
-
482
- const count = ref(0)
406
+ import { createFNetro } from '@netrojs/fnetro/server'
407
+ import { cors } from 'hono/cors'
408
+ import { logger } from 'hono/logger'
409
+ import { bearerAuth } from 'hono/bearer-auth'
483
410
 
484
- // Single source
485
- watch(count, (newVal, oldVal) => {
486
- console.log(`${oldVal} ${newVal}`)
487
- })
411
+ // 1. Global — runs before every route
412
+ const fnetro = createFNetro({
413
+ middleware: [logger(), cors({ origin: 'https://myapp.com' })],
488
414
 
489
- // Multiple sources
490
- const a = ref(1), b = ref(2)
491
- watch([a, b], ([newA, newB], [oldA, oldB]) => {
492
- console.log(newA, newB)
415
+ routes: [
416
+ // 2. Group-level runs for all routes in the group
417
+ defineGroup({
418
+ prefix: '/dashboard',
419
+ middleware: [bearerAuth({ token: process.env.API_KEY! })],
420
+ routes: [
421
+ // 3. Page-level — runs for this route only
422
+ definePage({
423
+ path: '/settings',
424
+ middleware: [rateLimiter({ max: 10, window: '1m' })],
425
+ Page: Settings,
426
+ }),
427
+ ],
428
+ }),
429
+ ],
493
430
  })
431
+ ```
494
432
 
495
- // Options
496
- watch(count, (n, o, cleanup) => {
497
- const timer = setTimeout(() => sync(n), 500)
498
- cleanup(() => clearTimeout(timer)) // runs before the next invocation
499
- }, {
500
- immediate: true, // fire immediately with the current value
501
- deep: true, // deep equality check (objects)
502
- once: true, // auto-stop after first invocation
503
- })
433
+ Middleware can short-circuit by returning a `Response`:
504
434
 
505
- // Stop watching
506
- const stop = watch(count, () => { ... })
507
- stop()
435
+ ```ts
436
+ const requireAuth: HonoMiddleware = async (c, next) => {
437
+ const session = getCookie(c, 'session')
438
+ if (!session) return c.redirect('/login')
439
+ c.set('user', await verifySession(session))
440
+ await next()
441
+ }
508
442
  ```
509
443
 
510
444
  ---
511
445
 
512
- ### `watchEffect`
446
+ ### Client middleware
513
447
 
514
- Like `watch` but auto-tracks every reactive value read inside the function body. Runs immediately.
448
+ Client middleware runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** calling `boot()`.
515
449
 
516
450
  ```ts
517
- import { ref, reactive, watchEffect } from '@netrojs/fnetro/core'
518
-
519
- const user = reactive({ name: 'Alice' })
520
- const theme = ref('dark')
451
+ // client.ts
452
+ import { boot, useClientMiddleware, navigate } from '@netrojs/fnetro/client'
521
453
 
522
- // Automatically tracks user.name and theme.value
523
- const stop = watchEffect(() => {
524
- document.title = `${user.name} — ${theme.value} mode`
454
+ // Analytics
455
+ useClientMiddleware(async (url, next) => {
456
+ await next()
457
+ analytics.track('pageview', { url })
525
458
  })
526
459
 
527
- user.name = 'Bob' // re-runs
528
- theme.value = 'light' // re-runs
460
+ // Auth guard
461
+ useClientMiddleware(async (url, next) => {
462
+ const protectedPaths = ['/dashboard', '/settings', '/profile']
463
+ const isProtected = protectedPaths.some(p => url.startsWith(p))
529
464
 
530
- stop() // remove the effect
531
- ```
532
-
533
- ---
534
-
535
- ### `effectScope`
536
-
537
- Groups effects together so they can all be stopped at once. Useful for feature-level cleanup (e.g. when a modal closes, stop all effects created inside it).
538
-
539
- ```ts
540
- import { ref, watchEffect, effectScope, onScopeDispose } from '@netrojs/fnetro/core'
541
-
542
- const scope = effectScope()
465
+ if (isProtected && !isAuthenticated()) {
466
+ await navigate(`/login?redirect=${encodeURIComponent(url)}`)
467
+ return // cancel original navigation
468
+ }
543
469
 
544
- scope.run(() => {
545
- // These effects are tied to `scope`
546
- watchEffect(() => { ... })
547
- watchEffect(() => { ... })
470
+ await next()
471
+ })
548
472
 
549
- // Runs when scope.stop() is called
550
- onScopeDispose(() => cleanup())
473
+ // Loading indicator
474
+ useClientMiddleware(async (url, next) => {
475
+ showLoadingBar()
476
+ try {
477
+ await next()
478
+ } finally {
479
+ hideLoadingBar()
480
+ }
551
481
  })
552
482
 
553
- // Stops all effects in the scope + runs cleanups
554
- scope.stop()
483
+ boot({ routes, layout })
555
484
  ```
556
485
 
557
- ---
558
-
559
- ### Helpers
560
-
561
- ```ts
562
- import {
563
- isRef, // (v) → v is Ref<unknown>
564
- isReactive, // (v) → boolean
565
- isReadonly, // (v) → boolean
566
- unref, // (r) → unwraps a Ref or returns the value as-is
567
- toRef, // (object, key) → a Ref linked to object[key]
568
- toRefs, // (object) → { [key]: Ref } — reactive-safe destructure
569
- markRaw, // (object) → never proxied (e.g. third-party class instances)
570
- toRaw, // (proxy) → the original unwrapped object
571
- readonly, // (object) → readonly proxy — mutations warn in dev
572
- } from '@netrojs/fnetro/core'
573
-
574
- // toRefs — destructure a reactive object without losing reactivity
575
- const pos = reactive({ x: 0, y: 0 })
576
- const { x, y } = toRefs(pos)
577
- x.value = 10 // mutates pos.x
578
- pos.x = 20 // x.value reads 20
579
-
580
- // markRaw — prevent third-party instances from being proxied
581
- const chart = markRaw(new Chart(canvas, config))
582
- state.chart = chart // stored as-is, not wrapped in a Proxy
583
- ```
486
+ Middleware runs in registration order. The chain is `mw1 → mw2 → ... → actual navigation`.
584
487
 
585
488
  ---
586
489
 
587
- ### Component hooks
490
+ ## SolidJS reactivity
588
491
 
589
- These are the bridge between signals and JSX components. On the server they return plain values (no reactivity needed). On the client they're wired to `hono/jsx/dom` to schedule re-renders.
590
-
591
- #### `use(source)` — subscribe to any Ref or getter
492
+ Use SolidJS primitives directly no FNetro wrappers needed.
592
493
 
593
494
  ```tsx
594
- import { ref, computed, use } from '@netrojs/fnetro/core'
595
-
596
- // Module-level shared across all components and page navigations
597
- const cartCount = ref(0)
598
- const doubled = computed(() => cartCount.value * 2)
599
-
600
- function CartIcon() {
601
- const count = use(cartCount) // re-renders when cartCount changes
602
- const dbl = use(doubled) // re-renders when doubled changes
603
- const total = use(() => cartCount.value * 9.99) // getter — auto-computed
495
+ import { createSignal, createMemo, createEffect, For, Show } from 'solid-js'
496
+ import { createStore, produce } from 'solid-js/store'
497
+ import { definePage } from '@netrojs/fnetro'
604
498
 
605
- return <span>🛒 {count} (${total.toFixed(2)})</span>
606
- }
607
- ```
499
+ // Module-level signals persist across SPA navigations
500
+ const [count, setCount] = createSignal(0)
501
+ const doubled = createMemo(() => count() * 2)
608
502
 
609
- #### `useLocalRef(init)` — component-scoped Ref
503
+ export default definePage({
504
+ path: '/counter',
505
+ Page() {
506
+ // Effects run automatically when signals they read change
507
+ createEffect(() => {
508
+ document.title = `Count: ${count()}`
509
+ })
610
510
 
611
- ```tsx
612
- import { useLocalRef, use } from '@netrojs/fnetro/core'
613
-
614
- function Toggle() {
615
- const open = useLocalRef(false) // created once, lost on unmount
616
- const isOpen = use(open)
617
- return (
618
- <div>
619
- <button onClick={() => open.value = !isOpen}>
620
- {isOpen ? 'Close' : 'Open'}
621
- </button>
622
- {isOpen && <div class="panel">...</div>}
623
- </div>
624
- )
625
- }
511
+ return (
512
+ <div>
513
+ <p>Count: {count()}</p>
514
+ <p>Doubled: {doubled()}</p>
515
+ <button onClick={() => setCount(n => n + 1)}>+</button>
516
+ </div>
517
+ )
518
+ },
519
+ })
626
520
  ```
627
521
 
628
- #### `useLocalReactive(init)` — component-scoped reactive object
522
+ **Store example:**
629
523
 
630
524
  ```tsx
631
- import { useLocalReactive } from '@netrojs/fnetro/core'
525
+ import { createStore, produce } from 'solid-js/store'
632
526
 
633
- function LoginForm() {
634
- const form = useLocalReactive({ email: '', password: '', loading: false })
527
+ interface Todo { id: number; text: string; done: boolean }
635
528
 
636
- async function submit() {
637
- form.loading = true
638
- await api.login(form.email, form.password)
639
- form.loading = false
640
- }
529
+ const [todos, setTodos] = createStore<{ items: Todo[] }>({ items: [] })
641
530
 
642
- return (
643
- <form onSubmit={submit}>
644
- <input value={form.email} onInput={(e: any) => form.email = e.target.value} />
645
- <input type="password" value={form.password} onInput={(e: any) => form.password = e.target.value} />
646
- <button disabled={form.loading}>{form.loading ? 'Signing in…' : 'Sign in'}</button>
647
- </form>
648
- )
531
+ function addTodo(text: string) {
532
+ setTodos('items', l => [...l, { id: Date.now(), text, done: false }])
649
533
  }
650
- ```
651
-
652
- ---
653
-
654
- ## Routing
655
-
656
- ### Dynamic segments
657
534
 
658
- | Pattern | Example URL | `params` |
659
- |---|---|---|
660
- | `/posts/[slug]` | `/posts/hello-world` | `{ slug: 'hello-world' }` |
661
- | `/files/[...path]` | `/files/a/b/c.pdf` | `{ path: 'a/b/c.pdf' }` |
662
- | `/[org]/[repo]` | `/acme/backend` | `{ org: 'acme', repo: 'backend' }` |
663
-
664
- Params are available in `loader` via `c.req.param('key')` and in `Page` via the `params` prop.
535
+ function toggleTodo(id: number) {
536
+ setTodos('items', t => t.id === id, produce(t => { t.done = !t.done }))
537
+ }
665
538
 
666
- ```tsx
667
- definePage({
668
- path: '/posts/[slug]',
669
- loader: (c) => {
670
- const slug = c.req.param('slug')
671
- return { post: db.findBySlug(slug) }
672
- },
673
- Page({ post, params }) {
674
- // params.slug is also available here
675
- return <article><h1>{post.title}</h1></article>
539
+ export default definePage({
540
+ path: '/todos',
541
+ Page() {
542
+ return (
543
+ <ul>
544
+ <For each={todos.items}>
545
+ {todo => (
546
+ <li
547
+ style={{ 'text-decoration': todo.done ? 'line-through' : 'none' }}
548
+ onClick={() => toggleTodo(todo.id)}
549
+ >
550
+ {todo.text}
551
+ </li>
552
+ )}
553
+ </For>
554
+ </ul>
555
+ )
676
556
  },
677
557
  })
678
558
  ```
679
559
 
680
- ### Route groups
681
-
682
- Prefix, layout, and middleware are inherited by all routes in the group:
683
-
684
- ```tsx
685
- createFNetro({
686
- layout: RootLayout,
687
- routes: [
688
- apiRoutes, // defineApiRoute — registered before the page handler
689
- adminGroup, // defineGroup — layout + middleware override
690
- home,
691
- posts,
692
- postDetail,
693
- ],
694
- })
695
- ```
696
-
697
- ### Layout overrides
698
-
699
- Priority order (highest wins): **page-level** → **group-level** → **app-level**
700
-
701
- ```tsx
702
- const adminGroup = defineGroup({
703
- prefix: '/admin',
704
- layout: AdminLayout, // overrides RootLayout for all /admin/* routes
705
- routes: [
706
- definePage({
707
- path: '/secret',
708
- layout: false, // no layout at all — bare HTML response
709
- Page: () => <div>secret</div>,
710
- }),
711
- ],
712
- })
713
- ```
714
-
715
560
  ---
716
561
 
717
- ## Server
718
-
719
- ### `createFNetro`
562
+ ## Navigation
720
563
 
721
- Assembles a Hono app from your route tree. Returns a `FNetroApp` with `.app` (the raw Hono instance) and `.handler` (the fetch function).
564
+ ### Link-based (automatic)
722
565
 
723
- ```ts
724
- import { createFNetro } from '@netrojs/fnetro/server'
566
+ Any `<a href="...">` pointing to a registered route is intercepted automatically — no special component needed.
725
567
 
726
- const fnetro = createFNetro({
727
- layout: RootLayout,
728
- middleware: [logger, sessionMiddleware],
729
- routes: [apiRoutes, adminGroup, home, posts],
730
- notFound: () => <NotFoundPage />,
731
- })
568
+ ```tsx
569
+ // These all work — SPA navigation, no full reload
570
+ <a href="/about">About</a>
571
+ <a href="/posts/hello">Read post</a>
732
572
 
733
- // Access the raw Hono instance for anything not covered by createFNetro
734
- fnetro.app.onError((err, c) => c.json({ error: err.message }, 500))
735
- fnetro.app.use('/healthz', (c) => c.text('ok'))
573
+ // Opt out with data-no-spa or rel="external"
574
+ <a href="/legacy" data-no-spa>Legacy page</a>
575
+ <a href="https://external.com" rel="external">External</a>
736
576
  ```
737
577
 
738
- **`AppConfig` options:**
739
-
740
- | Option | Type | Description |
741
- |---|---|---|
742
- | `layout` | `LayoutDef` | Default layout for all pages |
743
- | `middleware` | `FNetroMiddleware[]` | Global middleware, applied to every request |
744
- | `routes` | `(PageDef \| GroupDef \| ApiRouteDef)[]` | Route definitions |
745
- | `notFound` | `() => AnyJSX` | Custom 404 page |
746
-
747
- ---
748
-
749
- ### `serve`
750
-
751
- Starts the HTTP server. Auto-detects the runtime unless `runtime` is specified.
578
+ ### Programmatic navigation
752
579
 
753
580
  ```ts
754
- import { serve } from '@netrojs/fnetro/server'
755
- import { fnetro } from './app'
756
-
757
- // Auto-detect (works for Node, Bun, Deno)
758
- await serve({ app: fnetro, port: 3000 })
759
-
760
- // Explicit
761
- await serve({ app: fnetro, port: 8080, runtime: 'bun', hostname: '127.0.0.1' })
762
- ```
763
-
764
- **`ServeOptions`:**
581
+ import { navigate } from '@netrojs/fnetro/client'
765
582
 
766
- | Option | Type | Default | Description |
767
- |---|---|---|---|
768
- | `app` | `FNetroApp` | required | Returned by `createFNetro()` |
769
- | `port` | `number` | `3000` | Port to listen on (env `PORT` also checked) |
770
- | `hostname` | `string` | `'0.0.0.0'` | Bind address |
771
- | `runtime` | `Runtime` | auto-detected | Override auto-detection |
772
- | `staticDir` | `string` | `'./dist'` | Root dir for static asset serving (Node only) |
583
+ // Push to history (default)
584
+ await navigate('/about')
773
585
 
774
- **Edge runtimes** don't call `serve()`, just export the handler:
586
+ // Replace current history entry
587
+ await navigate('/login', { replace: true })
775
588
 
776
- ```ts
777
- // Cloudflare Workers / generic WinterCG
778
- export default { fetch: fnetro.handler }
589
+ // Prevent scroll-to-top
590
+ await navigate('/modal', { scroll: false })
779
591
  ```
780
592
 
781
- ### Runtime detection
593
+ ### Prefetch
782
594
 
783
595
  ```ts
784
- import { detectRuntime } from '@netrojs/fnetro/server'
596
+ import { prefetch } from '@netrojs/fnetro/client'
785
597
 
786
- const runtime = detectRuntime()
787
- // → 'bun' | 'deno' | 'node' | 'edge' | 'unknown'
598
+ // On hover/focus — warms the loader cache
599
+ prefetch('/about')
788
600
  ```
789
601
 
790
- Detection order: `Bun` global `Deno` global `process.versions.node` → `'edge'`.
791
-
792
- ---
793
-
794
- ## Client
795
-
796
- ### `boot`
797
-
798
- Call once in `client.ts`. Reads `window.__FNETRO_STATE__` injected by the server and hydrates the page — no extra network request.
602
+ Hover-based prefetching is enabled by default in `boot()`:
799
603
 
800
604
  ```ts
801
- // client.ts
802
- import { boot } from '@netrojs/fnetro/client'
803
- import { RootLayout } from './app/layouts'
804
- import home from './app/routes/home'
805
- import posts from './app/routes/posts'
806
-
807
605
  boot({
808
- layout: RootLayout,
809
- routes: [home, posts],
810
- prefetchOnHover: true, // default: true
606
+ prefetchOnHover: true, // default: true
607
+ routes,
811
608
  })
812
609
  ```
813
610
 
814
- Routes in `boot()` must match the routes in `createFNetro()` exactly (same array, same order). The client uses them for path matching during SPA navigation.
815
-
816
611
  ---
817
612
 
818
- ### `navigate`
613
+ ## Asset handling
819
614
 
820
- Programmatic SPA navigation.
615
+ ### Development
821
616
 
822
- ```ts
823
- import { navigate } from '@netrojs/fnetro/client'
617
+ `@hono/vite-dev-server` injects Vite's dev client automatically. No asset configuration needed.
824
618
 
825
- // Push a new history entry and navigate
826
- await navigate('/posts/new-post')
619
+ ### Production
827
620
 
828
- // Replace current entry (no back button entry)
829
- await navigate('/dashboard', { replace: true })
621
+ The Vite plugin produces a `manifest.json` alongside the client bundle. The server reads it at startup to inject correct hashed URLs into every HTML response.
830
622
 
831
- // Navigate without scrolling to the top
832
- await navigate('/modal-route', { scroll: false })
623
+ ```ts
624
+ // app.ts production configuration
625
+ createFNetro({
626
+ routes,
627
+ assets: {
628
+ // Path to the directory containing manifest.json
629
+ manifestDir: 'dist/assets',
630
+ // Key in the manifest (usually the entry filename)
631
+ manifestEntry: 'client.ts',
632
+ },
633
+ })
833
634
  ```
834
635
 
835
- Plain `<a>` tags are intercepted automatically no `<Link>` component required:
636
+ **Manual asset paths** (edge runtimes / when manifest is unavailable):
836
637
 
837
- ```html
838
- <a href="/posts/hello">Normal anchor — SPA handled</a>
839
- <a href="/download.zip" data-no-spa>Force full navigation</a>
840
- <a href="https://external.com" rel="external">External link</a>
638
+ ```ts
639
+ createFNetro({
640
+ assets: {
641
+ scripts: ['/assets/client-abc123.js'],
642
+ styles: ['/assets/style-def456.css'],
643
+ },
644
+ })
841
645
  ```
842
646
 
843
- ---
647
+ **Public directory:** Static files in `public/` (images, fonts, robots.txt) are served at the root path by the Node.js `serve()` helper automatically.
844
648
 
845
- ### `prefetch`
649
+ ---
846
650
 
847
- Warms the SPA fetch cache for a URL. By default called automatically on `mouseover`. Call manually for more aggressive prefetching (e.g. on `mousedown` or when an item enters the viewport).
651
+ ## Multi-runtime `serve()`
848
652
 
849
653
  ```ts
850
- import { prefetch } from '@netrojs/fnetro/client'
851
-
852
- // Prefetch on mousedown — faster than waiting for click
853
- button.addEventListener('mousedown', () => prefetch('/posts/next'))
654
+ import { serve } from '@netrojs/fnetro/server'
854
655
 
855
- // Prefetch a list of likely-next pages on page load
856
- const likelyNextRoutes = ['/posts/hello', '/about']
857
- likelyNextRoutes.forEach(prefetch)
858
- ```
656
+ // Auto-detects the runtime
657
+ await serve({ app: fnetro })
859
658
 
860
- Disable automatic hover prefetch:
861
- ```ts
862
- boot({ prefetchOnHover: false, ... })
659
+ // Explicit configuration
660
+ await serve({
661
+ app: fnetro,
662
+ port: 3000,
663
+ hostname: '0.0.0.0',
664
+ runtime: 'node', // 'node' | 'bun' | 'deno'
665
+ staticDir: './dist', // where dist/assets/ lives
666
+ })
863
667
  ```
864
668
 
865
- ---
866
-
867
- ### Lifecycle hooks
669
+ **Edge runtimes** (Cloudflare Workers, Deno Deploy, etc.) — just export the handler:
868
670
 
869
671
  ```ts
870
- import { onBeforeNavigate, onAfterNavigate } from '@netrojs/fnetro/client'
871
-
872
- // Runs before every SPA navigation — async, awaited
873
- // Throw any error to cancel the navigation
874
- const stopBefore = onBeforeNavigate(async (url) => {
875
- if (formHasUnsavedChanges) {
876
- const confirmed = await showConfirmDialog('Leave page?')
877
- if (!confirmed) throw new Error('navigation cancelled')
878
- }
879
- })
880
-
881
- // Runs after navigation completes — including the initial boot
882
- const stopAfter = onAfterNavigate((url) => {
883
- analytics.page(url)
884
- window.posthog?.capture('$pageview', { url })
885
- })
886
-
887
- // Remove a listener
888
- stopBefore()
889
- stopAfter()
672
+ // server.ts
673
+ import { fnetro } from './app'
674
+ export default { fetch: fnetro.handler }
890
675
  ```
891
676
 
892
677
  ---
893
678
 
894
679
  ## Vite plugin
895
680
 
896
- `fnetroVitePlugin()` produces both bundles from a single `vite build` command.
897
-
898
681
  ```ts
899
682
  // vite.config.ts
900
683
  import { defineConfig } from 'vite'
901
684
  import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
902
685
  import devServer from '@hono/vite-dev-server'
903
- import bunAdapter from '@hono/vite-dev-server/bun'
904
686
 
905
687
  export default defineConfig({
906
688
  plugins: [
689
+ // Handles JSX transform (vite-plugin-solid) + production dual build
907
690
  fnetroVitePlugin({
908
- serverEntry: 'server.ts', // default
909
- clientEntry: 'client.ts', // default
910
- serverOutDir: 'dist/server', // default
911
- clientOutDir: 'dist/assets', // default
912
- serverExternal: ['pg', 'redis'], // keep out of server bundle
913
- }),
914
- devServer({
915
- adapter: bunAdapter,
916
- entry: 'app.ts', // must export fnetro.handler as default
691
+ serverEntry: 'app/server.ts', // default: 'app/server.ts'
692
+ clientEntry: 'client.ts', // default: 'client.ts'
693
+ serverOutDir: 'dist/server', // default: 'dist/server'
694
+ clientOutDir: 'dist/assets', // default: 'dist/assets'
695
+ // Extra packages to externalize in the server bundle
696
+ serverExternal: ['@myorg/db'],
697
+ // Options forwarded to vite-plugin-solid
698
+ solidOptions: { extensions: ['.mdx'] },
917
699
  }),
700
+
701
+ // Dev server — serves the app with hot-reload
702
+ devServer({ entry: 'app.ts' }),
918
703
  ],
919
- server: {
920
- watch: { ignored: ['**/dist/**'] },
921
- },
922
704
  })
923
705
  ```
924
706
 
925
- **`FNetroPluginOptions`:**
707
+ ### Build output
926
708
 
927
- | Option | Default | Description |
928
- |---|---|---|
929
- | `serverEntry` | `'server.ts'` | Production server entry |
930
- | `clientEntry` | `'client.ts'` | Browser SPA entry |
931
- | `serverOutDir` | `'dist/server'` | Output dir for server bundle |
932
- | `clientOutDir` | `'dist/assets'` | Output dir for client bundle |
933
- | `serverExternal` | `[]` | Packages excluded from the server bundle (always excludes `node:*` and `@hono/node-server`) |
934
-
935
- **Build output:**
936
709
  ```
937
710
  dist/
938
711
  ├── server/
939
- │ └── server.js # Node-compatible ESM, imports fnetro.handler and calls serve()
712
+ │ └── server.js # SSR server bundle
940
713
  └── assets/
941
- ├── client.js # Browser ESM, boots the SPA
942
- └── style.css # Your CSS
943
- ```
944
-
945
- ---
946
-
947
- ## Dev server
948
-
949
- `@hono/vite-dev-server` routes HTTP requests directly through your FNetro app inside the Vite process. No `dist/` directory needed — changes to `.ts` and `.tsx` files are reflected instantly.
950
-
951
- ```bash
952
- # Node
953
- vite
954
-
955
- # Bun (uses Bun's runtime instead of Node for Vite internals)
956
- bun --bun vite --host
957
-
958
- # Deno
959
- deno run -A npm:vite
960
- ```
961
-
962
- The `entry` option must point to a file that exports the Hono fetch handler as its default export:
963
-
964
- ```ts
965
- // app.ts
966
- export const fnetro = createFNetro({ ... })
967
- export default fnetro.handler // ← this is what @hono/vite-dev-server imports
968
- ```
969
-
970
- ---
971
-
972
- ## Global store pattern
973
-
974
- Module-level reactive state persists across SPA navigations because ES modules are cached. Use this for shared auth state, cart, theme, notifications, etc.
975
-
976
- ```ts
977
- // app/store.ts
978
- import { ref, reactive, computed, watch } from '@netrojs/fnetro/core'
979
-
980
- // ── Theme ────────────────────────────────────────────────────────────────────
981
- export const theme = ref<'dark' | 'light'>('dark')
982
- export const toggleTheme = () => {
983
- theme.value = theme.value === 'dark' ? 'light' : 'dark'
984
- }
985
-
986
- // Persist to localStorage on the client
987
- if (typeof window !== 'undefined') {
988
- const saved = localStorage.getItem('theme') as 'dark' | 'light' | null
989
- if (saved) theme.value = saved
990
- watch(theme, (t) => localStorage.setItem('theme', t))
991
- }
992
-
993
- // ── Auth ─────────────────────────────────────────────────────────────────────
994
- export const user = reactive({
995
- id: null as string | null,
996
- name: '',
997
- role: 'guest' as 'guest' | 'user' | 'admin',
998
- })
999
- export const isLoggedIn = computed(() => user.id !== null)
1000
- export const isAdmin = computed(() => user.role === 'admin')
1001
-
1002
- // ── Cart ─────────────────────────────────────────────────────────────────────
1003
- export interface CartItem { id: string; name: string; qty: number; price: number }
1004
- export const cart = reactive<{ items: CartItem[] }>({ items: [] })
1005
- export const cartCount = computed(() => cart.items.reduce((s, i) => s + i.qty, 0))
1006
- export const cartTotal = computed(() => cart.items.reduce((s, i) => s + i.qty * i.price, 0))
1007
-
1008
- export function addToCart(item: Omit<CartItem, 'qty'>) {
1009
- const existing = cart.items.find((i) => i.id === item.id)
1010
- if (existing) { existing.qty++; return }
1011
- cart.items.push({ ...item, qty: 1 })
1012
- }
1013
-
1014
- export function removeFromCart(id: string) {
1015
- cart.items = cart.items.filter((i) => i.id !== id)
1016
- }
1017
- ```
1018
-
1019
- Using the store in a layout or page:
1020
-
1021
- ```tsx
1022
- import { use } from '@netrojs/fnetro/core'
1023
- import { cartCount, isLoggedIn, user, theme } from '../store'
1024
-
1025
- function NavBar({ url }: { url: string }) {
1026
- const count = use(cartCount)
1027
- const loggedIn = use(isLoggedIn)
1028
- const name = use(() => user.name)
1029
- const t = use(theme)
1030
-
1031
- return (
1032
- <nav class={`nav theme-${t}`}>
1033
- <a href="/">Home</a>
1034
- {loggedIn
1035
- ? <span>👤 {name}</span>
1036
- : <a href="/login">Sign in</a>
1037
- }
1038
- <a href="/cart">🛒 {count > 0 && <span class="badge">{count}</span>}</a>
1039
- </nav>
1040
- )
1041
- }
714
+ ├── manifest.json # Asset manifest (for hashed URL resolution)
715
+ ├── client-abc123.js # Hydration bundle
716
+ └── style-def456.css # CSS (if imported in JS)
1042
717
  ```
1043
718
 
1044
719
  ---
1045
720
 
1046
721
  ## TypeScript
1047
722
 
1048
- Page props are inferred directly from the loader return type — no annotation needed:
1049
-
1050
- ```ts
1051
- definePage({
1052
- path: '/user/[id]',
1053
- async loader(c) {
1054
- const user = await getUser(c.req.param('id'))
1055
- return { user, role: 'admin' as const }
1056
- },
1057
- Page({ user, role, url, params }) {
1058
- // ^^^^ User ^^^^ 'admin' — fully inferred
1059
- },
1060
- })
1061
- ```
1062
-
1063
- Explicit typing when the loader is defined separately:
1064
-
1065
- ```ts
1066
- interface PageData {
1067
- user: User
1068
- role: 'admin' | 'member'
1069
- }
1070
-
1071
- definePage<PageData>({
1072
- path: '/user/[id]',
1073
- loader: async (c): Promise<PageData> => { ... },
1074
- Page: ({ user, role }) => { ... },
1075
- })
1076
- ```
1077
-
1078
- **`tsconfig.json` for a FNetro project:**
723
+ `tsconfig.json` for a FNetro project:
1079
724
 
1080
725
  ```json
1081
726
  {
1082
727
  "compilerOptions": {
1083
- "target": "ESNext",
1084
- "module": "ESNext",
1085
- "moduleResolution": "bundler",
1086
- "lib": ["ESNext", "DOM"],
1087
- "jsx": "react-jsx",
1088
- "jsxImportSource": "hono/jsx",
1089
- "strict": true,
1090
- "skipLibCheck": true,
1091
- "noEmit": true,
728
+ "target": "ESNext",
729
+ "module": "ESNext",
730
+ "moduleResolution": "bundler",
731
+ "lib": ["ESNext", "DOM"],
732
+ "jsx": "preserve",
733
+ "jsxImportSource": "solid-js",
734
+ "strict": true,
735
+ "skipLibCheck": true,
736
+ "noEmit": true,
1092
737
  "allowImportingTsExtensions": true,
1093
- "resolveJsonModule": true,
1094
- "isolatedModules": true,
1095
- "verbatimModuleSyntax": true
1096
- },
1097
- "include": ["**/*.ts", "**/*.tsx"],
1098
- "exclude": ["node_modules", "dist"]
738
+ "resolveJsonModule": true,
739
+ "isolatedModules": true,
740
+ "verbatimModuleSyntax": true
741
+ }
1099
742
  }
1100
743
  ```
1101
744
 
1102
745
  ---
1103
746
 
1104
- ## Runtime support
1105
-
1106
- | Runtime | `dev` | `build` | `start` | Notes |
1107
- |---|---|---|---|---|
1108
- | **Node.js 18+** | `vite` | `vite build` | `node dist/server/server.js` | `@hono/node-server` required |
1109
- | **Bun** | `bun --bun vite --host` | `bun --bun vite build` | `bun dist/server/server.js` | Native Bun adapter for dev server |
1110
- | **Deno** | `deno run -A npm:vite` | `deno run -A npm:vite build` | `deno run -A dist/server/server.js` | |
1111
- | **Cloudflare Workers** | `wrangler dev` | `vite build` | `wrangler deploy` | Export `fnetro.handler` as default |
1112
- | **Generic WinterCG** | `vite` | `vite build` | — | Export `fnetro.handler` as default |
1113
-
1114
- `serve()` auto-detects the runtime at startup. Pass `runtime` explicitly if auto-detection fails (e.g. when a bundler strips runtime globals):
1115
-
1116
- ```ts
1117
- await serve({ app: fnetro, port: 3000, runtime: 'bun' })
1118
- ```
1119
-
1120
- ---
1121
-
1122
747
  ## API reference
1123
748
 
1124
- ### `@netrojs/fnetro/core`
749
+ ### `@netrojs/fnetro` (core)
1125
750
 
1126
- **Reactivity**
751
+ | Export | Description |
752
+ |---|---|
753
+ | `definePage(def)` | Define a page route |
754
+ | `defineGroup(def)` | Define a route group |
755
+ | `defineLayout(Component)` | Define a layout component |
756
+ | `defineApiRoute(path, register)` | Define raw Hono sub-routes |
757
+ | `resolveRoutes(routes, opts)` | Internal: flatten route tree |
758
+ | `compilePath(path)` | Internal: compile a path pattern |
759
+ | `matchPath(compiled, pathname)` | Internal: match a compiled path |
760
+ | `SPA_HEADER` | `'x-fnetro-spa'` |
761
+ | `STATE_KEY` | `'__FNETRO_STATE__'` |
762
+ | `PARAMS_KEY` | `'__FNETRO_PARAMS__'` |
763
+ | `SEO_KEY` | `'__FNETRO_SEO__'` |
764
+
765
+ **Types:** `AppConfig`, `PageDef<T>`, `GroupDef`, `LayoutDef`, `ApiRouteDef`, `Route`, `PageProps<T>`, `LayoutProps`, `SEOMeta`, `HonoMiddleware`, `LoaderCtx`, `ClientMiddleware`, `ResolvedRoute`, `CompiledPath`
1127
766
 
1128
- | Symbol | Signature | Description |
1129
- |---|---|---|
1130
- | `ref` | `<T>(value: T) → Ref<T>` | Reactive primitive |
1131
- | `shallowRef` | `<T>(value: T) → Ref<T>` | Reactive at top level only |
1132
- | `triggerRef` | `(r: Ref) → void` | Force-trigger a shallow ref |
1133
- | `isRef` | `(v) → v is Ref` | Type guard |
1134
- | `unref` | `<T>(r: T \| Ref<T>) → T` | Unwrap a ref |
1135
- | `reactive` | `<T extends object>(t: T) → T` | Deep reactive proxy |
1136
- | `shallowReactive` | `<T extends object>(t: T) → T` | Shallow reactive proxy |
1137
- | `readonly` | `<T extends object>(t: T) → Readonly<T>` | Readonly proxy |
1138
- | `computed` | `<T>(getter) → ComputedRef<T>` | Derived cached value |
1139
- | `computed` | `<T>({ get, set }) → WritableComputedRef<T>` | Writable computed |
1140
- | `watch` | `(source, cb, opts?) → StopHandle` | Reactive watcher |
1141
- | `watchEffect` | `(fn, opts?) → StopHandle` | Auto-tracked side effect |
1142
- | `effect` | `(fn) → StopHandle` | Raw reactive effect |
1143
- | `effectScope` | `() → EffectScope` | Grouped effect lifecycle |
1144
- | `getCurrentScope` | `() → EffectScope \| undefined` | Current active scope |
1145
- | `onScopeDispose` | `(fn) → void` | Register scope cleanup |
1146
- | `toRef` | `(obj, key) → Ref` | Ref linked to object key |
1147
- | `toRefs` | `(obj) → { [k]: Ref }` | Reactive-safe destructure |
1148
- | `markRaw` | `<T>(v: T) → T` | Opt out of reactivity |
1149
- | `toRaw` | `<T>(proxy: T) → T` | Unwrap proxy to original |
1150
- | `isReactive` | `(v) → boolean` | |
1151
- | `isReadonly` | `(v) → boolean` | |
1152
-
1153
- **Component hooks**
1154
-
1155
- | Symbol | Signature | Description |
1156
- |---|---|---|
1157
- | `use` | `<T>(source: Ref<T> \| (() => T)) → T` | Subscribe in JSX component |
1158
- | `useLocalRef` | `<T>(init: T) → Ref<T>` | Component-scoped ref |
1159
- | `useLocalReactive` | `<T>(init: T) → T` | Component-scoped reactive object |
767
+ ---
1160
768
 
1161
- **Route definitions**
769
+ ### `@netrojs/fnetro/server`
1162
770
 
1163
- | Symbol | Description |
771
+ | Export | Description |
1164
772
  |---|---|
1165
- | `definePage(def)` | Define a route |
1166
- | `defineGroup(def)` | Nest routes with shared prefix/layout/middleware |
1167
- | `defineLayout(Component)` | Create a layout |
1168
- | `defineMiddleware(handler)` | Create a middleware |
1169
- | `defineApiRoute(path, register)` | Mount raw Hono routes |
773
+ | `createFNetro(config)` | Create the FNetro/Hono app |
774
+ | `serve(opts)` | Start server for Node/Bun/Deno |
775
+ | `detectRuntime()` | Auto-detect the current JS runtime |
776
+ | `fnetroVitePlugin(opts?)` | Vite plugin for SSR + client builds |
1170
777
 
1171
- ### `@netrojs/fnetro/server`
778
+ **Types:** `FNetroOptions`, `FNetroApp`, `ServeOptions`, `Runtime`, `AssetConfig`, `FNetroPluginOptions`
1172
779
 
1173
- | Symbol | Description |
1174
- |---|---|
1175
- | `createFNetro(config)` | Assemble the Hono app → `FNetroApp` |
1176
- | `serve(opts)` | Start the HTTP server (auto-detects runtime) |
1177
- | `detectRuntime()` | Returns `'node' \| 'bun' \| 'deno' \| 'edge' \| 'unknown'` |
1178
- | `fnetroVitePlugin(opts?)` | Vite plugin — produces server + client bundles |
780
+ ---
1179
781
 
1180
782
  ### `@netrojs/fnetro/client`
1181
783
 
1182
- | Symbol | Description |
784
+ | Export | Description |
1183
785
  |---|---|
1184
- | `boot(options)` | Mount the SPA reads `__FNETRO_STATE__`, no refetch |
786
+ | `boot(options)` | Hydrate SSR HTML and start SPA |
1185
787
  | `navigate(to, opts?)` | Programmatic SPA navigation |
1186
- | `prefetch(url)` | Warm the fetch cache for a URL |
1187
- | `onBeforeNavigate(fn)` | Hook runs before each navigation, can cancel |
1188
- | `onAfterNavigate(fn)` | Hook — runs after each navigation + initial boot |
788
+ | `prefetch(url)` | Pre-warm the loader cache |
789
+ | `useClientMiddleware(fn)` | Register client navigation middleware |
790
+
791
+ **Types:** `BootOptions`, `NavigateOptions`
1189
792
 
1190
793
  ---
1191
794
 
1192
795
  ## License
1193
796
 
1194
- MIT © [Netro Solutions](https://github.com/netrosolutions)
797
+ MIT © [Netro Solutions](https://netrosolutions.com)