@netrojs/fnetro 0.1.6 → 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 +500 -897
- package/client.ts +220 -200
- package/core.ts +146 -644
- package/dist/client.d.ts +99 -155
- package/dist/client.js +177 -570
- package/dist/core.d.ts +69 -156
- package/dist/core.js +31 -452
- package/dist/server.d.ts +120 -179
- package/dist/server.js +278 -553
- package/package.json +17 -8
- package/server.ts +455 -247
package/README.md
CHANGED
|
@@ -1,257 +1,196 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @netrojs/fnetro
|
|
2
2
|
|
|
3
|
-
> Full-stack [Hono](https://hono.dev) framework — SSR, SPA,
|
|
3
|
+
> Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+** — SSR, SPA, SEO, server & client middleware, TypeScript-first.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/netrosolutions/fnetro/actions)
|
|
8
|
-
[](./LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
6
|
+
[](./LICENSE)
|
|
9
7
|
|
|
10
8
|
---
|
|
11
9
|
|
|
12
10
|
## Table of contents
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
68
48
|
|
|
69
49
|
```bash
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
bun run dev # dev server — no build step required
|
|
73
|
-
```
|
|
50
|
+
# npm
|
|
51
|
+
npm install @netrojs/fnetro solid-js hono
|
|
74
52
|
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
FNetro is **three files** and no magic:
|
|
89
|
-
|
|
90
|
-
| File | Size | Purpose |
|
|
64
|
+
| Package | Version | Required? |
|
|
91
65
|
|---|---|---|
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
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
|
|
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 #
|
|
135
|
-
│ ├── store.ts # Global reactive state (optional)
|
|
81
|
+
│ ├── layouts.tsx # defineLayout() — shared nav/footer shell
|
|
136
82
|
│ └── routes/
|
|
137
|
-
│ ├── home.tsx #
|
|
138
|
-
│ ├── about.tsx #
|
|
139
|
-
│
|
|
140
|
-
|
|
141
|
-
│
|
|
142
|
-
|
|
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
|
-
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Core concepts
|
|
149
95
|
|
|
150
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
##
|
|
119
|
+
## Routing
|
|
170
120
|
|
|
171
121
|
### `definePage`
|
|
172
122
|
|
|
173
|
-
|
|
123
|
+
Define a page with a path, optional loader, optional SEO, and a SolidJS component.
|
|
174
124
|
|
|
175
125
|
```tsx
|
|
176
|
-
// app/routes/
|
|
177
|
-
import { definePage
|
|
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: '/
|
|
130
|
+
path: '/',
|
|
184
131
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
<
|
|
200
|
-
<
|
|
201
|
-
|
|
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
|
-
|
|
155
|
+
**Dynamic segments** use `[param]` syntax:
|
|
210
156
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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:
|
|
234
|
-
layout:
|
|
235
|
-
middleware: [requireAuth
|
|
236
|
-
routes:
|
|
179
|
+
prefix: '/admin',
|
|
180
|
+
layout: AdminLayout,
|
|
181
|
+
middleware: [requireAuth],
|
|
182
|
+
routes: [dashboard, users],
|
|
237
183
|
})
|
|
238
184
|
```
|
|
239
185
|
|
|
240
|
-
Groups
|
|
186
|
+
Groups can be nested:
|
|
241
187
|
|
|
242
|
-
```
|
|
188
|
+
```ts
|
|
243
189
|
defineGroup({
|
|
244
|
-
prefix: '/
|
|
245
|
-
middleware: [loadOrg],
|
|
190
|
+
prefix: '/api',
|
|
246
191
|
routes: [
|
|
247
|
-
|
|
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
|
-
|
|
202
|
+
Create a shared layout component that wraps page content.
|
|
264
203
|
|
|
265
204
|
```tsx
|
|
266
|
-
|
|
267
|
-
import {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
**
|
|
223
|
+
**Per-page layout override:**
|
|
290
224
|
|
|
291
|
-
```
|
|
292
|
-
// Use a
|
|
293
|
-
definePage({ path: '/landing', layout:
|
|
225
|
+
```ts
|
|
226
|
+
// Use a different layout for this page
|
|
227
|
+
definePage({ path: '/landing', layout: LandingLayout, Page: ... })
|
|
294
228
|
|
|
295
|
-
//
|
|
296
|
-
definePage({ path: '/embed',
|
|
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
|
|
237
|
+
Mount raw Hono routes at a path. Full Hono API available.
|
|
304
238
|
|
|
305
|
-
```
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return c.json(
|
|
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
|
-
'/
|
|
324
|
-
zValidator('json', z.object({
|
|
254
|
+
'/items',
|
|
255
|
+
zValidator('json', z.object({ name: z.string().min(1) })),
|
|
325
256
|
async (c) => {
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
return c.json(
|
|
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
|
-
//
|
|
333
|
-
app.
|
|
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
|
-
|
|
272
|
+
## Loaders
|
|
340
273
|
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
})
|
|
366
|
-
```
|
|
287
|
+
if (!post) {
|
|
288
|
+
// Return a 404 response from the loader
|
|
289
|
+
return c.notFound()
|
|
290
|
+
}
|
|
367
291
|
|
|
368
|
-
|
|
292
|
+
return { post }
|
|
293
|
+
},
|
|
369
294
|
|
|
370
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
318
|
+
## SEO
|
|
423
319
|
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
365
|
+
### All SEO fields
|
|
439
366
|
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
401
|
+
### Server middleware
|
|
476
402
|
|
|
477
|
-
|
|
403
|
+
Hono middleware applied at three levels:
|
|
478
404
|
|
|
479
405
|
```ts
|
|
480
|
-
import {
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
})
|
|
411
|
+
// 1. Global — runs before every route
|
|
412
|
+
const fnetro = createFNetro({
|
|
413
|
+
middleware: [logger(), cors({ origin: 'https://myapp.com' })],
|
|
488
414
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
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
|
-
###
|
|
446
|
+
### Client middleware
|
|
513
447
|
|
|
514
|
-
|
|
448
|
+
Client middleware runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** calling `boot()`.
|
|
515
449
|
|
|
516
450
|
```ts
|
|
517
|
-
|
|
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
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
454
|
+
// Analytics
|
|
455
|
+
useClientMiddleware(async (url, next) => {
|
|
456
|
+
await next()
|
|
457
|
+
analytics.track('pageview', { url })
|
|
525
458
|
})
|
|
526
459
|
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
watchEffect(() => { ... })
|
|
547
|
-
watchEffect(() => { ... })
|
|
470
|
+
await next()
|
|
471
|
+
})
|
|
548
472
|
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
+
## SolidJS reactivity
|
|
588
491
|
|
|
589
|
-
|
|
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 {
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
522
|
+
**Store example:**
|
|
629
523
|
|
|
630
524
|
```tsx
|
|
631
|
-
import {
|
|
525
|
+
import { createStore, produce } from 'solid-js/store'
|
|
632
526
|
|
|
633
|
-
|
|
634
|
-
const form = useLocalReactive({ email: '', password: '', loading: false })
|
|
527
|
+
interface Todo { id: number; text: string; done: boolean }
|
|
635
528
|
|
|
636
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
##
|
|
718
|
-
|
|
719
|
-
### `createFNetro`
|
|
562
|
+
## Navigation
|
|
720
563
|
|
|
721
|
-
|
|
564
|
+
### Link-based (automatic)
|
|
722
565
|
|
|
723
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
586
|
+
// Replace current history entry
|
|
587
|
+
await navigate('/login', { replace: true })
|
|
775
588
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
export default { fetch: fnetro.handler }
|
|
589
|
+
// Prevent scroll-to-top
|
|
590
|
+
await navigate('/modal', { scroll: false })
|
|
779
591
|
```
|
|
780
592
|
|
|
781
|
-
###
|
|
593
|
+
### Prefetch
|
|
782
594
|
|
|
783
595
|
```ts
|
|
784
|
-
import {
|
|
596
|
+
import { prefetch } from '@netrojs/fnetro/client'
|
|
785
597
|
|
|
786
|
-
|
|
787
|
-
|
|
598
|
+
// On hover/focus — warms the loader cache
|
|
599
|
+
prefetch('/about')
|
|
788
600
|
```
|
|
789
601
|
|
|
790
|
-
|
|
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
|
-
|
|
809
|
-
routes
|
|
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
|
-
|
|
613
|
+
## Asset handling
|
|
819
614
|
|
|
820
|
-
|
|
615
|
+
### Development
|
|
821
616
|
|
|
822
|
-
|
|
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
|
-
|
|
826
|
-
await navigate('/posts/new-post')
|
|
619
|
+
### Production
|
|
827
620
|
|
|
828
|
-
|
|
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
|
-
|
|
832
|
-
|
|
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
|
-
|
|
636
|
+
**Manual asset paths** (edge runtimes / when manifest is unavailable):
|
|
836
637
|
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
649
|
+
---
|
|
846
650
|
|
|
847
|
-
|
|
651
|
+
## Multi-runtime `serve()`
|
|
848
652
|
|
|
849
653
|
```ts
|
|
850
|
-
import {
|
|
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
|
-
//
|
|
856
|
-
|
|
857
|
-
likelyNextRoutes.forEach(prefetch)
|
|
858
|
-
```
|
|
656
|
+
// Auto-detects the runtime
|
|
657
|
+
await serve({ app: fnetro })
|
|
859
658
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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:
|
|
909
|
-
clientEntry: 'client.ts',
|
|
910
|
-
serverOutDir: 'dist/server',
|
|
911
|
-
clientOutDir: 'dist/assets',
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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
|
|
712
|
+
│ └── server.js # SSR server bundle
|
|
940
713
|
└── assets/
|
|
941
|
-
├──
|
|
942
|
-
|
|
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
|
-
|
|
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":
|
|
1084
|
-
"module":
|
|
1085
|
-
"moduleResolution":
|
|
1086
|
-
"lib":
|
|
1087
|
-
"jsx":
|
|
1088
|
-
"jsxImportSource":
|
|
1089
|
-
"strict":
|
|
1090
|
-
"skipLibCheck":
|
|
1091
|
-
"noEmit":
|
|
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":
|
|
1094
|
-
"isolatedModules":
|
|
1095
|
-
"verbatimModuleSyntax":
|
|
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
|
|
749
|
+
### `@netrojs/fnetro` (core)
|
|
1125
750
|
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
769
|
+
### `@netrojs/fnetro/server`
|
|
1162
770
|
|
|
1163
|
-
|
|
|
771
|
+
| Export | Description |
|
|
1164
772
|
|---|---|
|
|
1165
|
-
| `
|
|
1166
|
-
| `
|
|
1167
|
-
| `
|
|
1168
|
-
| `
|
|
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
|
-
|
|
778
|
+
**Types:** `FNetroOptions`, `FNetroApp`, `ServeOptions`, `Runtime`, `AssetConfig`, `FNetroPluginOptions`
|
|
1172
779
|
|
|
1173
|
-
|
|
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
|
-
|
|
|
784
|
+
| Export | Description |
|
|
1183
785
|
|---|---|
|
|
1184
|
-
| `boot(options)` |
|
|
786
|
+
| `boot(options)` | Hydrate SSR HTML and start SPA |
|
|
1185
787
|
| `navigate(to, opts?)` | Programmatic SPA navigation |
|
|
1186
|
-
| `prefetch(url)` |
|
|
1187
|
-
| `
|
|
1188
|
-
|
|
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://
|
|
797
|
+
MIT © [Netro Solutions](https://netrosolutions.com)
|