@netrojs/fnetro 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +686 -871
- 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,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FNetro
|
|
2
2
|
|
|
3
|
-
> Full-stack [Hono](https://hono.dev) framework
|
|
3
|
+
> Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+** —
|
|
4
|
+
> SSR · SPA · SEO · server & client middleware · multi-runtime · TypeScript-first.
|
|
4
5
|
|
|
5
|
-
[](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml)
|
|
7
|
+
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
8
|
+
[](https://www.npmjs.com/package/@netrojs/create-fnetro)
|
|
9
|
+
[](./LICENSE)
|
|
9
10
|
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
## Table of contents
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
|
15
|
+
1. [Packages](#packages)
|
|
16
|
+
2. [Quick start](#quick-start)
|
|
17
|
+
3. [How it works](#how-it-works)
|
|
18
|
+
4. [Routing](#routing)
|
|
19
|
+
- [definePage](#definepage)
|
|
20
|
+
- [defineGroup](#definegroup)
|
|
21
|
+
- [defineLayout](#definelayout)
|
|
22
|
+
- [defineApiRoute](#defineapiroute)
|
|
23
|
+
5. [Loaders](#loaders)
|
|
24
|
+
6. [SEO](#seo)
|
|
25
|
+
7. [Middleware](#middleware)
|
|
26
|
+
- [Server middleware](#server-middleware)
|
|
27
|
+
- [Client middleware](#client-middleware)
|
|
28
|
+
8. [SolidJS reactivity](#solidjs-reactivity)
|
|
29
|
+
9. [Navigation](#navigation)
|
|
30
|
+
10. [Asset handling](#asset-handling)
|
|
31
|
+
11. [Multi-runtime serve()](#multi-runtime-serve)
|
|
32
|
+
12. [Vite plugin](#vite-plugin)
|
|
33
|
+
13. [Project structure](#project-structure)
|
|
34
|
+
14. [TypeScript](#typescript)
|
|
35
|
+
15. [create-fnetro CLI](#create-fnetro-cli)
|
|
36
|
+
16. [API reference](#api-reference)
|
|
37
|
+
17. [Monorepo development](#monorepo-development)
|
|
38
|
+
18. [Publishing & releases](#publishing--releases)
|
|
51
39
|
|
|
52
40
|
---
|
|
53
41
|
|
|
54
|
-
##
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
# npm
|
|
58
|
-
npm create @netrojs/fnetro@latest
|
|
42
|
+
## Packages
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
| Package | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| [`@netrojs/fnetro`](./packages/fnetro) | Core framework — SSR renderer, SPA routing, SEO, middleware, Vite plugin |
|
|
47
|
+
| [`@netrojs/create-fnetro`](./packages/create-fnetro) | Interactive project scaffolding CLI |
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
pnpm create @netrojs/fnetro@latest
|
|
65
|
-
```
|
|
49
|
+
---
|
|
66
50
|
|
|
67
|
-
|
|
51
|
+
## Quick start
|
|
68
52
|
|
|
69
53
|
```bash
|
|
54
|
+
npm create @netrojs/fnetro@latest my-app
|
|
70
55
|
cd my-app
|
|
71
|
-
|
|
72
|
-
|
|
56
|
+
npm install
|
|
57
|
+
npm run dev
|
|
73
58
|
```
|
|
74
59
|
|
|
75
|
-
|
|
60
|
+
Or with other package managers:
|
|
76
61
|
|
|
77
62
|
```bash
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
npm install -D @hono/node-server
|
|
63
|
+
pnpm create @netrojs/fnetro@latest my-app
|
|
64
|
+
bun create @netrojs/fnetro my-app
|
|
65
|
+
deno run -A npm:create-fnetro my-app
|
|
82
66
|
```
|
|
83
67
|
|
|
84
68
|
---
|
|
85
69
|
|
|
86
70
|
## How it works
|
|
87
71
|
|
|
88
|
-
FNetro is **three files** and no magic:
|
|
89
|
-
|
|
90
|
-
| File | Size | Purpose |
|
|
91
|
-
|---|---|---|
|
|
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
72
|
```
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
73
|
+
Browser Server (Hono)
|
|
74
|
+
──────────────────────────────── ─────────────────────────────────────
|
|
75
|
+
Global middleware
|
|
76
|
+
↓
|
|
77
|
+
Route match ([id], [...slug], *)
|
|
78
|
+
↓
|
|
79
|
+
Route middleware
|
|
80
|
+
↓
|
|
81
|
+
Loader (async, type-safe)
|
|
82
|
+
↓
|
|
83
|
+
SSR ────── SolidJS renderToStringAsync()
|
|
84
|
+
│ ↓
|
|
85
|
+
HTML + hydration script ◄─┘ SEO <head> injection
|
|
86
|
+
↓
|
|
87
|
+
HTML shell (state + params + seo embedded)
|
|
88
|
+
↓
|
|
89
|
+
SPA ────── JSON payload (state + seo only)
|
|
90
|
+
│
|
|
91
|
+
hydrate() ◄───────────────┘
|
|
92
|
+
↓
|
|
93
|
+
Client middleware chain
|
|
94
|
+
↓
|
|
95
|
+
SolidJS reactive component tree
|
|
96
|
+
(module-level signals persist across navigations)
|
|
116
97
|
```
|
|
117
98
|
|
|
118
99
|
---
|
|
119
100
|
|
|
120
|
-
##
|
|
121
|
-
|
|
122
|
-
The scaffold generates this layout:
|
|
123
|
-
|
|
124
|
-
```
|
|
125
|
-
my-app/
|
|
126
|
-
├── app.ts # Shared FNetro app — used by dev server AND server.ts
|
|
127
|
-
├── server.ts # Production entry — calls serve()
|
|
128
|
-
├── client.ts # Browser entry — calls boot()
|
|
129
|
-
├── vite.config.ts # fnetroVitePlugin + @hono/vite-dev-server
|
|
130
|
-
├── tsconfig.json
|
|
131
|
-
├── package.json
|
|
132
|
-
│
|
|
133
|
-
├── app/
|
|
134
|
-
│ ├── layouts.tsx # Root layout (nav, footer, theme)
|
|
135
|
-
│ ├── store.ts # Global reactive state (optional)
|
|
136
|
-
│ └── 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
|
|
146
|
-
```
|
|
147
|
-
|
|
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.
|
|
149
|
-
|
|
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'
|
|
155
|
-
|
|
156
|
-
export const fnetro = createFNetro({ layout: RootLayout, routes: [home] })
|
|
157
|
-
export default fnetro.handler // consumed by @hono/vite-dev-server in dev
|
|
158
|
-
```
|
|
159
|
-
|
|
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 })
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
---
|
|
168
|
-
|
|
169
|
-
## Core concepts
|
|
101
|
+
## Routing
|
|
170
102
|
|
|
171
103
|
### `definePage`
|
|
172
104
|
|
|
173
|
-
|
|
105
|
+
Define a route with an optional SSR loader, SEO config, and a SolidJS component.
|
|
174
106
|
|
|
175
107
|
```tsx
|
|
176
108
|
// app/routes/post.tsx
|
|
177
|
-
import { definePage
|
|
178
|
-
|
|
179
|
-
// Module-level signal — value persists across SPA navigations
|
|
180
|
-
const viewCount = ref(0)
|
|
109
|
+
import { definePage } from '@netrojs/fnetro'
|
|
181
110
|
|
|
182
111
|
export default definePage({
|
|
183
112
|
path: '/posts/[slug]',
|
|
184
113
|
|
|
185
|
-
|
|
186
|
-
// Serialized into window.__FNETRO_STATE__ — client reads it without refetching.
|
|
187
|
-
async loader(c) {
|
|
114
|
+
loader: async (c) => {
|
|
188
115
|
const slug = c.req.param('slug')
|
|
189
|
-
const post = await db.
|
|
190
|
-
if (!post)
|
|
116
|
+
const post = await db.posts.findBySlug(slug)
|
|
117
|
+
if (!post) return c.notFound()
|
|
191
118
|
return { post }
|
|
192
119
|
},
|
|
193
120
|
|
|
194
|
-
|
|
195
|
-
|
|
121
|
+
seo: (data) => ({
|
|
122
|
+
title: `${data.post.title} — My Blog`,
|
|
123
|
+
description: data.post.excerpt,
|
|
124
|
+
ogImage: data.post.coverUrl,
|
|
125
|
+
twitterCard: 'summary_large_image',
|
|
126
|
+
}),
|
|
127
|
+
|
|
196
128
|
Page({ post, url, params }) {
|
|
197
|
-
|
|
198
|
-
return (
|
|
199
|
-
<article>
|
|
200
|
-
<h1>{post.title}</h1>
|
|
201
|
-
<p>{views} views this session</p>
|
|
202
|
-
<button onClick={() => viewCount.value++}>👁</button>
|
|
203
|
-
</article>
|
|
204
|
-
)
|
|
129
|
+
return <article>{post.title}</article>
|
|
205
130
|
},
|
|
206
131
|
})
|
|
207
132
|
```
|
|
208
133
|
|
|
209
|
-
|
|
134
|
+
**Path patterns:**
|
|
210
135
|
|
|
211
|
-
|
|
|
136
|
+
| Pattern | Matches | `params` |
|
|
212
137
|
|---|---|---|
|
|
213
|
-
| `
|
|
214
|
-
| `
|
|
215
|
-
|
|
|
138
|
+
| `/posts/[slug]` | `/posts/hello-world` | `{ slug: 'hello-world' }` |
|
|
139
|
+
| `/files/[...rest]` | `/files/a/b/c` | `{ rest: 'a/b/c' }` |
|
|
140
|
+
| `/shop/*` | `/shop/anything` | *(positional)* |
|
|
216
141
|
|
|
217
142
|
---
|
|
218
143
|
|
|
219
144
|
### `defineGroup`
|
|
220
145
|
|
|
221
|
-
|
|
146
|
+
Group routes under a shared URL prefix, layout, and middleware.
|
|
222
147
|
|
|
223
|
-
```
|
|
224
|
-
import { defineGroup
|
|
225
|
-
import { AdminLayout } from '../layouts'
|
|
226
|
-
import { requireAuth, auditLog } from '../middleware'
|
|
227
|
-
|
|
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: ... })
|
|
148
|
+
```ts
|
|
149
|
+
import { defineGroup } from '@netrojs/fnetro'
|
|
231
150
|
|
|
232
151
|
export const adminGroup = defineGroup({
|
|
233
|
-
prefix:
|
|
234
|
-
layout:
|
|
235
|
-
middleware: [requireAuth, auditLog],
|
|
236
|
-
routes:
|
|
152
|
+
prefix: '/admin',
|
|
153
|
+
layout: AdminLayout, // optional — overrides app default
|
|
154
|
+
middleware: [requireAuth, auditLog],
|
|
155
|
+
routes: [dashboard, users, settings],
|
|
237
156
|
})
|
|
238
157
|
```
|
|
239
158
|
|
|
240
159
|
Groups nest arbitrarily:
|
|
241
160
|
|
|
242
|
-
```
|
|
161
|
+
```ts
|
|
243
162
|
defineGroup({
|
|
244
|
-
prefix: '/
|
|
245
|
-
middleware: [loadOrg],
|
|
163
|
+
prefix: '/api',
|
|
246
164
|
routes: [
|
|
247
|
-
|
|
248
|
-
defineGroup({
|
|
249
|
-
prefix: '/team',
|
|
250
|
-
middleware: [requireTeamMember],
|
|
251
|
-
routes: [
|
|
252
|
-
definePage({ path: '/[teamId]', ... }) // /org/:orgId/team/:teamId
|
|
253
|
-
],
|
|
254
|
-
}),
|
|
165
|
+
defineGroup({ prefix: '/v1', routes: [v1] }),
|
|
166
|
+
defineGroup({ prefix: '/v2', routes: [v2] }),
|
|
255
167
|
],
|
|
256
168
|
})
|
|
257
169
|
```
|
|
@@ -260,935 +172,838 @@ defineGroup({
|
|
|
260
172
|
|
|
261
173
|
### `defineLayout`
|
|
262
174
|
|
|
263
|
-
|
|
175
|
+
Wrap every page with a shared shell (nav, footer, providers).
|
|
264
176
|
|
|
265
177
|
```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
|
-
})
|
|
178
|
+
import { defineLayout } from '@netrojs/fnetro'
|
|
179
|
+
import { createSignal } from 'solid-js'
|
|
180
|
+
|
|
181
|
+
// Module-level signal — persists across SPA navigations
|
|
182
|
+
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
|
183
|
+
|
|
184
|
+
export const RootLayout = defineLayout(({ children, url, params }) => (
|
|
185
|
+
<div class="app">
|
|
186
|
+
<nav>
|
|
187
|
+
<a href="/" class={url === '/' ? 'active' : ''}>Home</a>
|
|
188
|
+
<a href="/about" class={url === '/about' ? 'active' : ''}>About</a>
|
|
189
|
+
</nav>
|
|
190
|
+
<main>{children}</main>
|
|
191
|
+
<footer>© 2025</footer>
|
|
192
|
+
</div>
|
|
193
|
+
))
|
|
287
194
|
```
|
|
288
195
|
|
|
289
|
-
**
|
|
196
|
+
**Per-page override:**
|
|
290
197
|
|
|
291
|
-
```
|
|
292
|
-
// Use a
|
|
293
|
-
definePage({ path: '/landing', layout:
|
|
198
|
+
```ts
|
|
199
|
+
// Use a different layout
|
|
200
|
+
definePage({ path: '/landing', layout: LandingLayout, Page: ... })
|
|
294
201
|
|
|
295
|
-
//
|
|
296
|
-
definePage({ path: '/embed', layout: false,
|
|
202
|
+
// Disable layout entirely
|
|
203
|
+
definePage({ path: '/embed', layout: false, Page: ... })
|
|
297
204
|
```
|
|
298
205
|
|
|
299
206
|
---
|
|
300
207
|
|
|
301
208
|
### `defineApiRoute`
|
|
302
209
|
|
|
303
|
-
Mount raw Hono routes
|
|
210
|
+
Mount raw Hono sub-routes. Full Hono API — REST, RPC, WebSocket, streaming.
|
|
304
211
|
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
import { defineApiRoute } from '@netrojs/fnetro/core'
|
|
212
|
+
```ts
|
|
213
|
+
import { defineApiRoute } from '@netrojs/fnetro'
|
|
308
214
|
import { zValidator } from '@hono/zod-validator'
|
|
309
215
|
import { z } from 'zod'
|
|
310
216
|
|
|
311
|
-
export const
|
|
312
|
-
|
|
313
|
-
|
|
217
|
+
export const api = defineApiRoute('/api', (app) => {
|
|
218
|
+
app.get('/health', (c) =>
|
|
219
|
+
c.json({ status: 'ok', ts: Date.now() }),
|
|
220
|
+
)
|
|
314
221
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return c.json({ posts })
|
|
222
|
+
app.get('/users/:id', async (c) => {
|
|
223
|
+
const user = await db.users.find(c.req.param('id'))
|
|
224
|
+
return user ? c.json(user) : c.json({ error: 'not found' }, 404)
|
|
319
225
|
})
|
|
320
226
|
|
|
321
|
-
// POST /api/posts — with Zod validation
|
|
322
227
|
app.post(
|
|
323
|
-
'/
|
|
324
|
-
zValidator('json', z.object({
|
|
228
|
+
'/items',
|
|
229
|
+
zValidator('json', z.object({ name: z.string().min(1) })),
|
|
325
230
|
async (c) => {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
231
|
+
const item = await db.items.create(c.req.valid('json'))
|
|
232
|
+
return c.json(item, 201)
|
|
233
|
+
},
|
|
330
234
|
)
|
|
331
|
-
|
|
332
|
-
// Mount a sub-app
|
|
333
|
-
app.route('/admin', adminRpc)
|
|
334
235
|
})
|
|
335
236
|
```
|
|
336
237
|
|
|
337
238
|
---
|
|
338
239
|
|
|
339
|
-
|
|
240
|
+
## Loaders
|
|
340
241
|
|
|
341
|
-
|
|
242
|
+
Loaders run **on the server on every request** — both initial SSR and SPA navigations. The return value is JSON-serialised and injected as page props.
|
|
342
243
|
|
|
343
244
|
```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
|
-
})
|
|
245
|
+
definePage({
|
|
246
|
+
path: '/dashboard',
|
|
354
247
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
})
|
|
248
|
+
loader: async (c) => {
|
|
249
|
+
// Full Hono Context — headers, cookies, query params, env bindings
|
|
250
|
+
const session = getCookie(c, 'session')
|
|
251
|
+
if (!session) return c.redirect('/login')
|
|
360
252
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
253
|
+
const user = await auth.verify(session)
|
|
254
|
+
const stats = await db.stats.forUser(user.id)
|
|
255
|
+
return { user, stats }
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
Page({ user, stats }) { /* typed */ },
|
|
365
259
|
})
|
|
366
260
|
```
|
|
367
261
|
|
|
368
|
-
**
|
|
262
|
+
**Type-safe loaders:**
|
|
369
263
|
|
|
370
264
|
```ts
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
Page: ...,
|
|
380
|
-
}),
|
|
381
|
-
],
|
|
382
|
-
}),
|
|
383
|
-
],
|
|
265
|
+
interface DashboardData { user: User; stats: Stats }
|
|
266
|
+
|
|
267
|
+
definePage<DashboardData>({
|
|
268
|
+
loader: async (c): Promise<DashboardData> => ({
|
|
269
|
+
user: await getUser(c),
|
|
270
|
+
stats: await getStats(c),
|
|
271
|
+
}),
|
|
272
|
+
Page({ user, stats }) { /* DashboardData & { url, params } */ },
|
|
384
273
|
})
|
|
385
274
|
```
|
|
386
275
|
|
|
387
276
|
---
|
|
388
277
|
|
|
389
|
-
##
|
|
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).
|
|
278
|
+
## SEO
|
|
392
279
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
A reactive container for any value. Read with `.value`, write with `.value =`.
|
|
280
|
+
Every page can declare `seo` as a **static object** or a **function of loader data**.
|
|
281
|
+
App-level `seo` provides global defaults; page-level values override them.
|
|
396
282
|
|
|
397
283
|
```ts
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
284
|
+
// app.ts — global defaults applied to every page
|
|
285
|
+
createFNetro({
|
|
286
|
+
seo: {
|
|
287
|
+
ogType: 'website',
|
|
288
|
+
ogSiteName: 'My App',
|
|
289
|
+
twitterCard: 'summary_large_image',
|
|
290
|
+
twitterSite: '@myapp',
|
|
291
|
+
robots: 'index, follow',
|
|
292
|
+
themeColor: '#0d0f14',
|
|
293
|
+
},
|
|
294
|
+
routes: [...],
|
|
295
|
+
})
|
|
403
296
|
```
|
|
404
297
|
|
|
405
|
-
**`shallowRef`** — reactive only at the top level (mutations inside an object won't trigger):
|
|
406
|
-
|
|
407
298
|
```ts
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
299
|
+
// app/routes/post.tsx — page overrides (merged with app defaults)
|
|
300
|
+
definePage({
|
|
301
|
+
path: '/posts/[slug]',
|
|
302
|
+
loader: (c) => ({ post: await getPost(c.req.param('slug')) }),
|
|
303
|
+
|
|
304
|
+
seo: (data, params) => ({
|
|
305
|
+
title: `${data.post.title} — My Blog`,
|
|
306
|
+
description: data.post.excerpt,
|
|
307
|
+
canonical: `https://example.com/posts/${params.slug}`,
|
|
308
|
+
ogTitle: data.post.title,
|
|
309
|
+
ogDescription: data.post.excerpt,
|
|
310
|
+
ogImage: data.post.coverUrl,
|
|
311
|
+
ogImageWidth: '1200',
|
|
312
|
+
ogImageHeight: '630',
|
|
313
|
+
twitterTitle: data.post.title,
|
|
314
|
+
twitterImage: data.post.coverUrl,
|
|
315
|
+
jsonLd: {
|
|
316
|
+
'@context': 'https://schema.org',
|
|
317
|
+
'@type': 'Article',
|
|
318
|
+
headline: data.post.title,
|
|
319
|
+
author: { '@type': 'Person', name: data.post.authorName },
|
|
320
|
+
datePublished: data.post.publishedAt,
|
|
321
|
+
image: data.post.coverUrl,
|
|
322
|
+
},
|
|
323
|
+
extra: [
|
|
324
|
+
{ name: 'article:author', content: data.post.authorName },
|
|
325
|
+
],
|
|
326
|
+
}),
|
|
327
|
+
|
|
328
|
+
Page({ post }) { ... },
|
|
329
|
+
})
|
|
411
330
|
```
|
|
412
331
|
|
|
413
|
-
|
|
332
|
+
### All SEO fields
|
|
414
333
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
334
|
+
| Field | `<head>` output |
|
|
335
|
+
|---|---|
|
|
336
|
+
| `title` | `<title>` |
|
|
337
|
+
| `description` | `<meta name="description">` |
|
|
338
|
+
| `keywords` | `<meta name="keywords">` |
|
|
339
|
+
| `author` | `<meta name="author">` |
|
|
340
|
+
| `robots` | `<meta name="robots">` |
|
|
341
|
+
| `canonical` | `<link rel="canonical">` |
|
|
342
|
+
| `themeColor` | `<meta name="theme-color">` |
|
|
343
|
+
| `ogTitle` | `<meta property="og:title">` |
|
|
344
|
+
| `ogDescription` | `<meta property="og:description">` |
|
|
345
|
+
| `ogImage` | `<meta property="og:image">` |
|
|
346
|
+
| `ogImageAlt` | `<meta property="og:image:alt">` |
|
|
347
|
+
| `ogImageWidth` | `<meta property="og:image:width">` |
|
|
348
|
+
| `ogImageHeight` | `<meta property="og:image:height">` |
|
|
349
|
+
| `ogUrl` | `<meta property="og:url">` |
|
|
350
|
+
| `ogType` | `<meta property="og:type">` |
|
|
351
|
+
| `ogSiteName` | `<meta property="og:site_name">` |
|
|
352
|
+
| `ogLocale` | `<meta property="og:locale">` |
|
|
353
|
+
| `twitterCard` | `<meta name="twitter:card">` |
|
|
354
|
+
| `twitterSite` | `<meta name="twitter:site">` |
|
|
355
|
+
| `twitterCreator` | `<meta name="twitter:creator">` |
|
|
356
|
+
| `twitterTitle` | `<meta name="twitter:title">` |
|
|
357
|
+
| `twitterDescription` | `<meta name="twitter:description">` |
|
|
358
|
+
| `twitterImage` | `<meta name="twitter:image">` |
|
|
359
|
+
| `twitterImageAlt` | `<meta name="twitter:image:alt">` |
|
|
360
|
+
| `jsonLd` | `<script type="application/ld+json">` |
|
|
361
|
+
| `extra` | Arbitrary `<meta>` tags |
|
|
362
|
+
|
|
363
|
+
On SPA navigation, all `<meta>` tags and `document.title` are updated automatically — no full reload.
|
|
419
364
|
|
|
420
365
|
---
|
|
421
366
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
Deep reactive proxy of an object. All nested reads and writes are tracked automatically.
|
|
425
|
-
|
|
426
|
-
```ts
|
|
427
|
-
import { reactive } from '@netrojs/fnetro/core'
|
|
428
|
-
|
|
429
|
-
const state = reactive({
|
|
430
|
-
user: { name: 'Alice', role: 'admin' },
|
|
431
|
-
cart: { items: [] as CartItem[] },
|
|
432
|
-
})
|
|
367
|
+
## Middleware
|
|
433
368
|
|
|
434
|
-
|
|
435
|
-
state.cart.items.push(item) // array mutations tracked
|
|
436
|
-
```
|
|
369
|
+
### Server middleware
|
|
437
370
|
|
|
438
|
-
|
|
371
|
+
Hono middleware at three levels — global, group, and page.
|
|
439
372
|
|
|
440
373
|
```ts
|
|
441
|
-
|
|
442
|
-
|
|
374
|
+
import { createFNetro } from '@netrojs/fnetro/server'
|
|
375
|
+
import { cors } from 'hono/cors'
|
|
376
|
+
import { logger } from 'hono/logger'
|
|
377
|
+
import { bearerAuth } from 'hono/bearer-auth'
|
|
443
378
|
|
|
444
|
-
|
|
379
|
+
const fnetro = createFNetro({
|
|
380
|
+
// 1. Global — runs on every request
|
|
381
|
+
middleware: [logger(), cors({ origin: 'https://example.com' })],
|
|
445
382
|
|
|
446
|
-
|
|
383
|
+
routes: [
|
|
384
|
+
// 2. Group-level — runs for every route in the group
|
|
385
|
+
defineGroup({
|
|
386
|
+
prefix: '/admin',
|
|
387
|
+
middleware: [bearerAuth({ token: process.env.API_KEY! })],
|
|
388
|
+
routes: [
|
|
389
|
+
// 3. Page-level — runs for this route only
|
|
390
|
+
definePage({
|
|
391
|
+
path: '/reports',
|
|
392
|
+
middleware: [rateLimiter({ max: 10, window: '1m' })],
|
|
393
|
+
Page: Reports,
|
|
394
|
+
}),
|
|
395
|
+
],
|
|
396
|
+
}),
|
|
397
|
+
],
|
|
398
|
+
})
|
|
399
|
+
```
|
|
447
400
|
|
|
448
|
-
|
|
401
|
+
Middleware can short-circuit by returning a `Response`:
|
|
449
402
|
|
|
450
403
|
```ts
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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'
|
|
404
|
+
const requireAuth: HonoMiddleware = async (c, next) => {
|
|
405
|
+
const session = getCookie(c, 'session')
|
|
406
|
+
if (!session) return c.redirect('/login')
|
|
407
|
+
c.set('user', await verifySession(session))
|
|
408
|
+
await next()
|
|
409
|
+
}
|
|
471
410
|
```
|
|
472
411
|
|
|
473
412
|
---
|
|
474
413
|
|
|
475
|
-
###
|
|
414
|
+
### Client middleware
|
|
476
415
|
|
|
477
|
-
Runs
|
|
416
|
+
Runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** `boot()`.
|
|
478
417
|
|
|
479
418
|
```ts
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const count = ref(0)
|
|
419
|
+
// client.ts
|
|
420
|
+
import { boot, useClientMiddleware, navigate } from '@netrojs/fnetro/client'
|
|
483
421
|
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
422
|
+
// Analytics — fires after navigation completes
|
|
423
|
+
useClientMiddleware(async (url, next) => {
|
|
424
|
+
await next()
|
|
425
|
+
analytics.page({ url })
|
|
487
426
|
})
|
|
488
427
|
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
428
|
+
// Auth guard — redirects before navigation
|
|
429
|
+
useClientMiddleware(async (url, next) => {
|
|
430
|
+
if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
431
|
+
await navigate('/login?redirect=' + encodeURIComponent(url))
|
|
432
|
+
return // cancel the original navigation
|
|
433
|
+
}
|
|
434
|
+
await next()
|
|
493
435
|
})
|
|
494
436
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
437
|
+
// Loading indicator
|
|
438
|
+
useClientMiddleware(async (url, next) => {
|
|
439
|
+
NProgress.start()
|
|
440
|
+
try { await next() }
|
|
441
|
+
finally { NProgress.done() }
|
|
503
442
|
})
|
|
504
443
|
|
|
505
|
-
|
|
506
|
-
const stop = watch(count, () => { ... })
|
|
507
|
-
stop()
|
|
444
|
+
boot({ routes, layout })
|
|
508
445
|
```
|
|
509
446
|
|
|
510
|
-
|
|
447
|
+
The chain runs in registration order: `mw1 → mw2 → ... → fetch + render`. Omitting `next()` in any middleware cancels the navigation.
|
|
511
448
|
|
|
512
|
-
|
|
449
|
+
---
|
|
513
450
|
|
|
514
|
-
|
|
451
|
+
## SolidJS reactivity
|
|
515
452
|
|
|
516
|
-
|
|
517
|
-
import { ref, reactive, watchEffect } from '@netrojs/fnetro/core'
|
|
453
|
+
Use SolidJS primitives directly — no FNetro wrappers.
|
|
518
454
|
|
|
519
|
-
|
|
520
|
-
const theme = ref('dark')
|
|
455
|
+
**Module-level signals** persist across SPA navigations (they live for the lifetime of the page JS):
|
|
521
456
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
})
|
|
457
|
+
```tsx
|
|
458
|
+
import { createSignal, createMemo, createEffect, For } from 'solid-js'
|
|
459
|
+
import { definePage } from '@netrojs/fnetro'
|
|
526
460
|
|
|
527
|
-
|
|
528
|
-
|
|
461
|
+
const [count, setCount] = createSignal(0)
|
|
462
|
+
const doubled = createMemo(() => count() * 2)
|
|
529
463
|
|
|
530
|
-
|
|
464
|
+
export default definePage({
|
|
465
|
+
path: '/counter',
|
|
466
|
+
Page() {
|
|
467
|
+
createEffect(() => { document.title = `Count: ${count()}` })
|
|
468
|
+
return (
|
|
469
|
+
<div>
|
|
470
|
+
<p>{count()} × 2 = {doubled()}</p>
|
|
471
|
+
<button onClick={() => setCount(n => n + 1)}>+</button>
|
|
472
|
+
</div>
|
|
473
|
+
)
|
|
474
|
+
},
|
|
475
|
+
})
|
|
531
476
|
```
|
|
532
477
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
### `effectScope`
|
|
478
|
+
**Stores** for structured reactive state:
|
|
536
479
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
```ts
|
|
540
|
-
import { ref, watchEffect, effectScope, onScopeDispose } from '@netrojs/fnetro/core'
|
|
480
|
+
```tsx
|
|
481
|
+
import { createStore, produce } from 'solid-js/store'
|
|
541
482
|
|
|
542
|
-
|
|
483
|
+
interface Todo { id: number; text: string; done: boolean }
|
|
484
|
+
const [todos, setTodos] = createStore<{ items: Todo[] }>({ items: [] })
|
|
543
485
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
watchEffect(() => { ... })
|
|
486
|
+
function toggle(id: number) {
|
|
487
|
+
setTodos('items', t => t.id === id, produce(t => { t.done = !t.done }))
|
|
488
|
+
}
|
|
548
489
|
|
|
549
|
-
|
|
550
|
-
|
|
490
|
+
export default definePage({
|
|
491
|
+
path: '/todos',
|
|
492
|
+
Page() {
|
|
493
|
+
return (
|
|
494
|
+
<For each={todos.items}>
|
|
495
|
+
{(todo) => (
|
|
496
|
+
<li
|
|
497
|
+
style={{ 'text-decoration': todo.done ? 'line-through' : 'none' }}
|
|
498
|
+
onClick={() => toggle(todo.id)}
|
|
499
|
+
>
|
|
500
|
+
{todo.text}
|
|
501
|
+
</li>
|
|
502
|
+
)}
|
|
503
|
+
</For>
|
|
504
|
+
)
|
|
505
|
+
},
|
|
551
506
|
})
|
|
552
|
-
|
|
553
|
-
// Stops all effects in the scope + runs cleanups
|
|
554
|
-
scope.stop()
|
|
555
507
|
```
|
|
556
508
|
|
|
557
509
|
---
|
|
558
510
|
|
|
559
|
-
|
|
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
|
-
```
|
|
584
|
-
|
|
585
|
-
---
|
|
511
|
+
## Navigation
|
|
586
512
|
|
|
587
|
-
###
|
|
513
|
+
### Links — automatic interception
|
|
588
514
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
#### `use(source)` — subscribe to any Ref or getter
|
|
515
|
+
Any `<a href="...">` pointing to a registered route is intercepted automatically. No special component needed.
|
|
592
516
|
|
|
593
517
|
```tsx
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
604
|
-
|
|
605
|
-
return <span>🛒 {count} (${total.toFixed(2)})</span>
|
|
606
|
-
}
|
|
518
|
+
<a href="/about">About</a> {/* → SPA navigation */}
|
|
519
|
+
<a href="/posts/hello">Post</a> {/* → SPA navigation */}
|
|
520
|
+
<a href="/legacy" data-no-spa>Legacy</a> {/* → full page load */}
|
|
521
|
+
<a href="https://example.com" rel="external">External</a> {/* → full page load */}
|
|
607
522
|
```
|
|
608
523
|
|
|
609
|
-
|
|
524
|
+
### Programmatic navigation
|
|
610
525
|
|
|
611
|
-
```
|
|
612
|
-
import {
|
|
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
|
-
}
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
#### `useLocalReactive(init)` — component-scoped reactive object
|
|
526
|
+
```ts
|
|
527
|
+
import { navigate } from '@netrojs/fnetro/client'
|
|
629
528
|
|
|
630
|
-
|
|
631
|
-
|
|
529
|
+
await navigate('/about') // push history
|
|
530
|
+
await navigate('/login', { replace: true }) // replace history entry
|
|
531
|
+
await navigate('/modal', { scroll: false }) // skip scroll-to-top
|
|
532
|
+
```
|
|
632
533
|
|
|
633
|
-
|
|
634
|
-
const form = useLocalReactive({ email: '', password: '', loading: false })
|
|
534
|
+
### Prefetch
|
|
635
535
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
await api.login(form.email, form.password)
|
|
639
|
-
form.loading = false
|
|
640
|
-
}
|
|
536
|
+
```ts
|
|
537
|
+
import { prefetch } from '@netrojs/fnetro/client'
|
|
641
538
|
|
|
642
|
-
|
|
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
|
-
)
|
|
649
|
-
}
|
|
539
|
+
prefetch('/about') // warm the loader cache on hover / focus
|
|
650
540
|
```
|
|
651
541
|
|
|
542
|
+
Hover-based prefetching is automatic when `prefetchOnHover: true` (the default) is set in `boot()`.
|
|
543
|
+
|
|
652
544
|
---
|
|
653
545
|
|
|
654
|
-
##
|
|
546
|
+
## Asset handling
|
|
655
547
|
|
|
656
|
-
###
|
|
548
|
+
### Development
|
|
657
549
|
|
|
658
|
-
|
|
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' }` |
|
|
550
|
+
`@hono/vite-dev-server` injects Vite's dev client and HMR scripts automatically. No asset config needed.
|
|
663
551
|
|
|
664
|
-
|
|
552
|
+
### Production
|
|
665
553
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
return <article><h1>{post.title}</h1></article>
|
|
554
|
+
`vite build` produces a `manifest.json` alongside the hashed client bundle. The server reads the manifest at startup to resolve the correct filenames.
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
// app.ts
|
|
558
|
+
createFNetro({
|
|
559
|
+
routes,
|
|
560
|
+
assets: {
|
|
561
|
+
manifestDir: 'dist/assets', // directory containing manifest.json
|
|
562
|
+
manifestEntry: 'client.ts', // key in the manifest (your client entry)
|
|
676
563
|
},
|
|
677
564
|
})
|
|
678
565
|
```
|
|
679
566
|
|
|
680
|
-
|
|
567
|
+
**Manual override** (edge runtimes / CDN-hosted assets):
|
|
681
568
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
```tsx
|
|
569
|
+
```ts
|
|
685
570
|
createFNetro({
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
home,
|
|
691
|
-
posts,
|
|
692
|
-
postDetail,
|
|
693
|
-
],
|
|
571
|
+
assets: {
|
|
572
|
+
scripts: ['https://cdn.example.com/client-abc123.js'],
|
|
573
|
+
styles: ['https://cdn.example.com/style-def456.css'],
|
|
574
|
+
},
|
|
694
575
|
})
|
|
695
576
|
```
|
|
696
577
|
|
|
697
|
-
|
|
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
|
-
```
|
|
578
|
+
**Public directory** — static files in `public/` (images, fonts, `robots.txt`, `favicon.ico`) are served at `/` by the Node.js `serve()` helper automatically.
|
|
714
579
|
|
|
715
580
|
---
|
|
716
581
|
|
|
717
|
-
##
|
|
718
|
-
|
|
719
|
-
### `createFNetro`
|
|
720
|
-
|
|
721
|
-
Assembles a Hono app from your route tree. Returns a `FNetroApp` with `.app` (the raw Hono instance) and `.handler` (the fetch function).
|
|
582
|
+
## Multi-runtime serve()
|
|
722
583
|
|
|
723
584
|
```ts
|
|
724
|
-
import {
|
|
585
|
+
import { serve } from '@netrojs/fnetro/server'
|
|
725
586
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
middleware: [logger, sessionMiddleware],
|
|
729
|
-
routes: [apiRoutes, adminGroup, home, posts],
|
|
730
|
-
notFound: () => <NotFoundPage />,
|
|
731
|
-
})
|
|
587
|
+
// Auto-detects Node.js, Bun, or Deno
|
|
588
|
+
await serve({ app: fnetro })
|
|
732
589
|
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
fnetro
|
|
590
|
+
// Explicit configuration
|
|
591
|
+
await serve({
|
|
592
|
+
app: fnetro,
|
|
593
|
+
port: 3000,
|
|
594
|
+
hostname: '0.0.0.0',
|
|
595
|
+
runtime: 'node', // 'node' | 'bun' | 'deno' | 'edge'
|
|
596
|
+
staticDir: './dist', // root for /assets/* and /* static files
|
|
597
|
+
})
|
|
736
598
|
```
|
|
737
599
|
|
|
738
|
-
|
|
600
|
+
**Edge runtimes** (Cloudflare Workers, Deno Deploy, Fastly, etc.):
|
|
739
601
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
| `middleware` | `FNetroMiddleware[]` | Global middleware, applied to every request |
|
|
744
|
-
| `routes` | `(PageDef \| GroupDef \| ApiRouteDef)[]` | Route definitions |
|
|
745
|
-
| `notFound` | `() => AnyJSX` | Custom 404 page |
|
|
602
|
+
```ts
|
|
603
|
+
// server.ts
|
|
604
|
+
import { fnetro } from './app'
|
|
746
605
|
|
|
747
|
-
|
|
606
|
+
// Export the Hono fetch handler — the platform calls it directly
|
|
607
|
+
export default { fetch: fnetro.handler }
|
|
608
|
+
```
|
|
748
609
|
|
|
749
|
-
|
|
610
|
+
---
|
|
750
611
|
|
|
751
|
-
|
|
612
|
+
## Vite plugin
|
|
752
613
|
|
|
753
614
|
```ts
|
|
754
|
-
|
|
755
|
-
import {
|
|
615
|
+
// vite.config.ts
|
|
616
|
+
import { defineConfig } from 'vite'
|
|
617
|
+
import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
|
|
618
|
+
import devServer from '@hono/vite-dev-server'
|
|
756
619
|
|
|
757
|
-
|
|
758
|
-
|
|
620
|
+
export default defineConfig({
|
|
621
|
+
plugins: [
|
|
622
|
+
// Handles: SolidJS JSX transform, SSR server build, client bundle + manifest
|
|
623
|
+
fnetroVitePlugin({
|
|
624
|
+
serverEntry: 'server.ts', // default: 'server.ts'
|
|
625
|
+
clientEntry: 'client.ts', // default: 'client.ts'
|
|
626
|
+
serverOutDir: 'dist/server', // default: 'dist/server'
|
|
627
|
+
clientOutDir: 'dist/assets', // default: 'dist/assets'
|
|
628
|
+
serverExternal: ['@myorg/db'], // extra server-bundle externals
|
|
629
|
+
solidOptions: {}, // forwarded to vite-plugin-solid
|
|
630
|
+
}),
|
|
759
631
|
|
|
760
|
-
//
|
|
761
|
-
|
|
632
|
+
// Dev: serves the FNetro app through Vite with hot-reload
|
|
633
|
+
// app.ts default export must be the Hono *instance* (fnetro.app),
|
|
634
|
+
// NOT fnetro.handler (plain function, no .fetch property).
|
|
635
|
+
devServer({ entry: 'app.ts' }),
|
|
636
|
+
],
|
|
637
|
+
})
|
|
762
638
|
```
|
|
763
639
|
|
|
764
|
-
|
|
765
|
-
|
|
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) |
|
|
640
|
+
### Build output
|
|
773
641
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
642
|
+
```
|
|
643
|
+
dist/
|
|
644
|
+
├── server/
|
|
645
|
+
│ └── server.js # SSR server bundle (ESM)
|
|
646
|
+
└── assets/
|
|
647
|
+
├── manifest.json # Vite asset manifest (for hashed URL resolution)
|
|
648
|
+
├── client-[hash].js # Hydration + SPA bundle
|
|
649
|
+
└── style-[hash].css # CSS (when imported from JS)
|
|
779
650
|
```
|
|
780
651
|
|
|
781
|
-
|
|
652
|
+
---
|
|
782
653
|
|
|
783
|
-
|
|
784
|
-
import { detectRuntime } from '@netrojs/fnetro/server'
|
|
654
|
+
## Project structure
|
|
785
655
|
|
|
786
|
-
|
|
787
|
-
|
|
656
|
+
```
|
|
657
|
+
my-app/
|
|
658
|
+
│
|
|
659
|
+
├── app.ts # Shared FNetro app — used by dev server AND server.ts
|
|
660
|
+
│ # Default export must be fnetro.app (Hono instance)
|
|
661
|
+
│
|
|
662
|
+
├── server.ts # Production entry — imports app.ts, calls serve()
|
|
663
|
+
├── client.ts # Browser entry — registers middleware, calls boot()
|
|
664
|
+
│
|
|
665
|
+
├── app/
|
|
666
|
+
│ ├── layouts.tsx # defineLayout() — root shell (nav, footer)
|
|
667
|
+
│ └── routes/
|
|
668
|
+
│ ├── home.tsx # definePage({ path: '/' })
|
|
669
|
+
│ ├── about.tsx # definePage({ path: '/about' })
|
|
670
|
+
│ ├── api.ts # defineApiRoute('/api', fn)
|
|
671
|
+
│ └── posts/
|
|
672
|
+
│ ├── index.tsx # /posts
|
|
673
|
+
│ └── [slug].tsx # /posts/:slug
|
|
674
|
+
│
|
|
675
|
+
├── public/
|
|
676
|
+
│ ├── style.css # Global CSS (served at /style.css)
|
|
677
|
+
│ └── favicon.ico
|
|
678
|
+
│
|
|
679
|
+
├── vite.config.ts
|
|
680
|
+
├── tsconfig.json
|
|
681
|
+
└── package.json
|
|
788
682
|
```
|
|
789
683
|
|
|
790
|
-
|
|
684
|
+
### `app.ts` vs `server.ts`
|
|
791
685
|
|
|
792
|
-
|
|
686
|
+
| File | Purpose |
|
|
687
|
+
|---|---|
|
|
688
|
+
| `app.ts` | Creates the FNetro app. Exports `fnetro` (named) and `fnetro.app` (default). Used by `@hono/vite-dev-server` in dev and imported by `server.ts` in production. |
|
|
689
|
+
| `server.ts` | Production-only entry point. Imports from `app.ts` and calls `serve()`. Never imported by the dev server. |
|
|
793
690
|
|
|
794
|
-
|
|
691
|
+
---
|
|
795
692
|
|
|
796
|
-
|
|
693
|
+
## TypeScript
|
|
797
694
|
|
|
798
|
-
|
|
695
|
+
`tsconfig.json` for any FNetro project:
|
|
799
696
|
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
697
|
+
```json
|
|
698
|
+
{
|
|
699
|
+
"compilerOptions": {
|
|
700
|
+
"target": "ES2022",
|
|
701
|
+
"module": "ESNext",
|
|
702
|
+
"moduleResolution": "bundler",
|
|
703
|
+
"lib": ["ES2022", "DOM"],
|
|
704
|
+
"jsx": "preserve",
|
|
705
|
+
"jsxImportSource": "solid-js",
|
|
706
|
+
"strict": true,
|
|
707
|
+
"skipLibCheck": true,
|
|
708
|
+
"noEmit": true,
|
|
709
|
+
"allowImportingTsExtensions": true,
|
|
710
|
+
"resolveJsonModule": true,
|
|
711
|
+
"isolatedModules": true,
|
|
712
|
+
"verbatimModuleSyntax": true
|
|
713
|
+
}
|
|
714
|
+
}
|
|
812
715
|
```
|
|
813
716
|
|
|
814
|
-
|
|
717
|
+
> **Important:** `jsxImportSource` must be `"solid-js"` — not `"hono/jsx"`. FNetro v0.2+ uses SolidJS for all rendering.
|
|
815
718
|
|
|
816
719
|
---
|
|
817
720
|
|
|
818
|
-
|
|
721
|
+
## create-fnetro CLI
|
|
819
722
|
|
|
820
|
-
|
|
723
|
+
Scaffold a new project interactively or from CI:
|
|
821
724
|
|
|
822
|
-
```
|
|
823
|
-
|
|
725
|
+
```bash
|
|
726
|
+
npm create @netrojs/fnetro@latest [project-name] [flags]
|
|
727
|
+
```
|
|
824
728
|
|
|
825
|
-
|
|
826
|
-
await navigate('/posts/new-post')
|
|
729
|
+
### Interactive mode
|
|
827
730
|
|
|
828
|
-
|
|
829
|
-
await navigate('/dashboard', { replace: true })
|
|
731
|
+
Running without flags opens a step-by-step prompt:
|
|
830
732
|
|
|
831
|
-
// Navigate without scrolling to the top
|
|
832
|
-
await navigate('/modal-route', { scroll: false })
|
|
833
733
|
```
|
|
734
|
+
⬡ create-fnetro
|
|
735
|
+
Full-stack Hono + SolidJS — SSR · SPA · SEO · TypeScript
|
|
834
736
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
737
|
+
✔ Project name: … my-app
|
|
738
|
+
✔ Target runtime: › Node.js
|
|
739
|
+
✔ Template: › Minimal
|
|
740
|
+
✔ Package manager: › npm
|
|
741
|
+
✔ Install dependencies now? … yes
|
|
742
|
+
✔ Initialize a git repository? … yes
|
|
841
743
|
```
|
|
842
744
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
### `prefetch`
|
|
745
|
+
### CLI flags (non-interactive / CI)
|
|
846
746
|
|
|
847
|
-
|
|
747
|
+
| Flag | Values | Default |
|
|
748
|
+
|---|---|---|
|
|
749
|
+
| `--ci` | — | `false` |
|
|
750
|
+
| `--runtime` | `node` `bun` `deno` `cloudflare` `generic` | `node` |
|
|
751
|
+
| `--template` | `minimal` `full` | `minimal` |
|
|
752
|
+
| `--pkg-manager` | `npm` `pnpm` `yarn` `bun` `deno` | `npm` |
|
|
753
|
+
| `--no-install` | — | installs |
|
|
754
|
+
| `--no-git` | — | initialises |
|
|
848
755
|
|
|
849
|
-
```
|
|
850
|
-
|
|
756
|
+
```bash
|
|
757
|
+
# Non-interactive CI scaffold
|
|
758
|
+
npm create @netrojs/fnetro@latest my-app \
|
|
759
|
+
--ci \
|
|
760
|
+
--runtime node \
|
|
761
|
+
--template full \
|
|
762
|
+
--pkg-manager pnpm \
|
|
763
|
+
--no-git
|
|
764
|
+
```
|
|
851
765
|
|
|
852
|
-
|
|
853
|
-
button.addEventListener('mousedown', () => prefetch('/posts/next'))
|
|
766
|
+
### Templates
|
|
854
767
|
|
|
855
|
-
|
|
856
|
-
const likelyNextRoutes = ['/posts/hello', '/about']
|
|
857
|
-
likelyNextRoutes.forEach(prefetch)
|
|
768
|
+
**`minimal`** — production-ready starter:
|
|
858
769
|
```
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
770
|
+
app.ts server.ts client.ts
|
|
771
|
+
app/layouts.tsx
|
|
772
|
+
app/routes/home.tsx # GET /
|
|
773
|
+
app/routes/about.tsx # GET /about
|
|
774
|
+
app/routes/api.ts # GET /api/health GET /api/hello
|
|
775
|
+
public/style.css
|
|
863
776
|
```
|
|
864
777
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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()
|
|
778
|
+
**`full`** — includes SolidJS signal demo, dynamic routes, and shared store:
|
|
779
|
+
```
|
|
780
|
+
(everything in minimal, plus)
|
|
781
|
+
app/store.ts # createSignal + createStore examples
|
|
782
|
+
app/routes/counter.tsx # GET /counter — signals demo
|
|
783
|
+
app/routes/posts/index.tsx # GET /posts — SSR list
|
|
784
|
+
app/routes/posts/[slug].tsx # GET /posts/:slug — dynamic SSR + SEO
|
|
890
785
|
```
|
|
891
786
|
|
|
892
|
-
|
|
787
|
+
### Supported runtimes
|
|
893
788
|
|
|
894
|
-
|
|
789
|
+
| Runtime | Dev command | Prod server |
|
|
790
|
+
|---|---|---|
|
|
791
|
+
| `node` | `vite` (via `@hono/vite-dev-server`) | `@hono/node-server` |
|
|
792
|
+
| `bun` | `bun --bun vite` | `Bun.serve` |
|
|
793
|
+
| `deno` | `deno run -A npm:vite` | `Deno.serve` |
|
|
794
|
+
| `cloudflare` | `wrangler dev` | Cloudflare Workers |
|
|
795
|
+
| `generic` | `vite` | WinterCG `export default { fetch }` |
|
|
895
796
|
|
|
896
|
-
|
|
797
|
+
---
|
|
897
798
|
|
|
898
|
-
|
|
899
|
-
// vite.config.ts
|
|
900
|
-
import { defineConfig } from 'vite'
|
|
901
|
-
import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
|
|
902
|
-
import devServer from '@hono/vite-dev-server'
|
|
903
|
-
import bunAdapter from '@hono/vite-dev-server/bun'
|
|
799
|
+
## API reference
|
|
904
800
|
|
|
905
|
-
|
|
906
|
-
plugins: [
|
|
907
|
-
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
|
|
917
|
-
}),
|
|
918
|
-
],
|
|
919
|
-
server: {
|
|
920
|
-
watch: { ignored: ['**/dist/**'] },
|
|
921
|
-
},
|
|
922
|
-
})
|
|
923
|
-
```
|
|
801
|
+
### `@netrojs/fnetro` (core)
|
|
924
802
|
|
|
925
|
-
|
|
803
|
+
**Functions:**
|
|
926
804
|
|
|
927
|
-
|
|
|
805
|
+
| Export | Signature | Description |
|
|
928
806
|
|---|---|---|
|
|
929
|
-
| `
|
|
930
|
-
| `
|
|
931
|
-
| `
|
|
932
|
-
| `
|
|
933
|
-
| `
|
|
807
|
+
| `definePage` | `<T>(def) → PageDef<T>` | Define a page route |
|
|
808
|
+
| `defineGroup` | `(def) → GroupDef` | Group routes under a prefix |
|
|
809
|
+
| `defineLayout` | `(Component) → LayoutDef` | Wrap pages in a shared shell |
|
|
810
|
+
| `defineApiRoute` | `(path, register) → ApiRouteDef` | Mount raw Hono sub-routes |
|
|
811
|
+
| `compilePath` | `(path) → CompiledPath` | Compile a path pattern to a regex |
|
|
812
|
+
| `matchPath` | `(compiled, pathname) → params \| null` | Match a compiled path |
|
|
813
|
+
| `resolveRoutes` | `(routes, opts) → { pages, apis }` | Flatten a route tree |
|
|
934
814
|
|
|
935
|
-
**
|
|
936
|
-
```
|
|
937
|
-
dist/
|
|
938
|
-
├── server/
|
|
939
|
-
│ └── server.js # Node-compatible ESM, imports fnetro.handler and calls serve()
|
|
940
|
-
└── assets/
|
|
941
|
-
├── client.js # Browser ESM, boots the SPA
|
|
942
|
-
└── style.css # Your CSS
|
|
943
|
-
```
|
|
944
|
-
|
|
945
|
-
---
|
|
815
|
+
**Constants:** `SPA_HEADER` · `STATE_KEY` · `PARAMS_KEY` · `SEO_KEY`
|
|
946
816
|
|
|
947
|
-
|
|
817
|
+
**Types:** `AppConfig` · `PageDef<T>` · `GroupDef` · `LayoutDef` · `ApiRouteDef` · `Route` · `PageProps<T>` · `LayoutProps` · `SEOMeta` · `HonoMiddleware` · `LoaderCtx` · `ClientMiddleware` · `ResolvedRoute` · `CompiledPath`
|
|
948
818
|
|
|
949
|
-
|
|
819
|
+
---
|
|
950
820
|
|
|
951
|
-
|
|
952
|
-
# Node
|
|
953
|
-
vite
|
|
821
|
+
### `@netrojs/fnetro/server`
|
|
954
822
|
|
|
955
|
-
|
|
956
|
-
bun --bun vite --host
|
|
823
|
+
**Functions:**
|
|
957
824
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
825
|
+
| Export | Signature | Description |
|
|
826
|
+
|---|---|---|
|
|
827
|
+
| `createFNetro` | `(config: FNetroOptions) → FNetroApp` | Build the Hono app |
|
|
828
|
+
| `serve` | `(opts: ServeOptions) → Promise<void>` | Start server for Node/Bun/Deno |
|
|
829
|
+
| `detectRuntime` | `() → Runtime` | Auto-detect the current JS runtime |
|
|
830
|
+
| `fnetroVitePlugin` | `(opts?) → Plugin[]` | Vite plugin for dual build |
|
|
961
831
|
|
|
962
|
-
|
|
832
|
+
**`FNetroOptions`** (extends `AppConfig`):
|
|
963
833
|
|
|
964
834
|
```ts
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
835
|
+
interface FNetroOptions {
|
|
836
|
+
layout?: LayoutDef // default layout for all pages
|
|
837
|
+
seo?: SEOMeta // global SEO defaults
|
|
838
|
+
middleware?: HonoMiddleware[] // global Hono middleware
|
|
839
|
+
routes: Route[] // top-level routes
|
|
840
|
+
notFound?: Component // 404 component
|
|
841
|
+
htmlAttrs?: Record<string,string> // attributes on <html>
|
|
842
|
+
head?: string // raw HTML appended to <head>
|
|
843
|
+
assets?: AssetConfig // production asset config
|
|
844
|
+
}
|
|
968
845
|
```
|
|
969
846
|
|
|
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.
|
|
847
|
+
**`AssetConfig`:**
|
|
975
848
|
|
|
976
849
|
```ts
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
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))
|
|
850
|
+
interface AssetConfig {
|
|
851
|
+
scripts?: string[] // explicit script URLs
|
|
852
|
+
styles?: string[] // explicit stylesheet URLs
|
|
853
|
+
manifestDir?: string // directory containing manifest.json
|
|
854
|
+
manifestEntry?: string // manifest key for client entry (default: 'client.ts')
|
|
991
855
|
}
|
|
856
|
+
```
|
|
992
857
|
|
|
993
|
-
|
|
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
|
-
}
|
|
858
|
+
**`ServeOptions`:**
|
|
1013
859
|
|
|
1014
|
-
|
|
1015
|
-
|
|
860
|
+
```ts
|
|
861
|
+
interface ServeOptions {
|
|
862
|
+
app: FNetroApp
|
|
863
|
+
port?: number // default: process.env.PORT ?? 3000
|
|
864
|
+
hostname?: string // default: '0.0.0.0'
|
|
865
|
+
runtime?: Runtime // default: auto-detected
|
|
866
|
+
staticDir?: string // default: './dist'
|
|
1016
867
|
}
|
|
1017
868
|
```
|
|
1018
869
|
|
|
1019
|
-
|
|
870
|
+
**`FNetroPluginOptions`:**
|
|
1020
871
|
|
|
1021
|
-
```
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
)
|
|
872
|
+
```ts
|
|
873
|
+
interface FNetroPluginOptions {
|
|
874
|
+
serverEntry?: string // default: 'server.ts'
|
|
875
|
+
clientEntry?: string // default: 'client.ts'
|
|
876
|
+
serverOutDir?: string // default: 'dist/server'
|
|
877
|
+
clientOutDir?: string // default: 'dist/assets'
|
|
878
|
+
serverExternal?: string[] // extra server-bundle externals
|
|
879
|
+
solidOptions?: object // passed to vite-plugin-solid
|
|
1041
880
|
}
|
|
1042
881
|
```
|
|
1043
882
|
|
|
1044
883
|
---
|
|
1045
884
|
|
|
1046
|
-
|
|
885
|
+
### `@netrojs/fnetro/client`
|
|
886
|
+
|
|
887
|
+
**Functions:**
|
|
1047
888
|
|
|
1048
|
-
|
|
889
|
+
| Export | Signature | Description |
|
|
890
|
+
|---|---|---|
|
|
891
|
+
| `boot` | `(opts: BootOptions) → Promise<void>` | Hydrate SSR and start SPA |
|
|
892
|
+
| `navigate` | `(to, opts?) → Promise<void>` | Programmatic navigation |
|
|
893
|
+
| `prefetch` | `(url) → void` | Warm loader cache |
|
|
894
|
+
| `useClientMiddleware` | `(fn: ClientMiddleware) → void` | Register nav middleware |
|
|
895
|
+
|
|
896
|
+
**`BootOptions`** (extends `AppConfig`):
|
|
1049
897
|
|
|
1050
898
|
```ts
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
})
|
|
899
|
+
interface BootOptions extends AppConfig {
|
|
900
|
+
prefetchOnHover?: boolean // default: true
|
|
901
|
+
}
|
|
1061
902
|
```
|
|
1062
903
|
|
|
1063
|
-
|
|
904
|
+
**`NavigateOptions`:**
|
|
1064
905
|
|
|
1065
906
|
```ts
|
|
1066
|
-
interface
|
|
1067
|
-
|
|
1068
|
-
|
|
907
|
+
interface NavigateOptions {
|
|
908
|
+
replace?: boolean // replaceState instead of pushState
|
|
909
|
+
scroll?: boolean // scroll to top after navigation (default: true)
|
|
1069
910
|
}
|
|
1070
|
-
|
|
1071
|
-
definePage<PageData>({
|
|
1072
|
-
path: '/user/[id]',
|
|
1073
|
-
loader: async (c): Promise<PageData> => { ... },
|
|
1074
|
-
Page: ({ user, role }) => { ... },
|
|
1075
|
-
})
|
|
1076
911
|
```
|
|
1077
912
|
|
|
1078
|
-
**`
|
|
913
|
+
**`ClientMiddleware`:**
|
|
1079
914
|
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
"moduleResolution": "bundler",
|
|
1086
|
-
"lib": ["ESNext", "DOM"],
|
|
1087
|
-
"jsx": "react-jsx",
|
|
1088
|
-
"jsxImportSource": "hono/jsx",
|
|
1089
|
-
"strict": true,
|
|
1090
|
-
"skipLibCheck": true,
|
|
1091
|
-
"noEmit": true,
|
|
1092
|
-
"allowImportingTsExtensions": true,
|
|
1093
|
-
"resolveJsonModule": true,
|
|
1094
|
-
"isolatedModules": true,
|
|
1095
|
-
"verbatimModuleSyntax": true
|
|
1096
|
-
},
|
|
1097
|
-
"include": ["**/*.ts", "**/*.tsx"],
|
|
1098
|
-
"exclude": ["node_modules", "dist"]
|
|
1099
|
-
}
|
|
915
|
+
```ts
|
|
916
|
+
type ClientMiddleware = (
|
|
917
|
+
url: string,
|
|
918
|
+
next: () => Promise<void>,
|
|
919
|
+
) => Promise<void>
|
|
1100
920
|
```
|
|
1101
921
|
|
|
1102
922
|
---
|
|
1103
923
|
|
|
1104
|
-
##
|
|
924
|
+
## Monorepo development
|
|
925
|
+
|
|
926
|
+
```bash
|
|
927
|
+
# Clone and install
|
|
928
|
+
git clone https://github.com/netrosolutions/fnetro.git
|
|
929
|
+
cd fnetro
|
|
930
|
+
npm install # hoists all workspace deps to root node_modules
|
|
1105
931
|
|
|
1106
|
-
|
|
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 |
|
|
932
|
+
# Build both packages
|
|
933
|
+
npm run build
|
|
1113
934
|
|
|
1114
|
-
|
|
935
|
+
# Typecheck both packages
|
|
936
|
+
npm run typecheck
|
|
1115
937
|
|
|
1116
|
-
|
|
1117
|
-
|
|
938
|
+
# Clean all dist/ directories
|
|
939
|
+
npm run clean
|
|
940
|
+
|
|
941
|
+
# Watch mode (fnetro package)
|
|
942
|
+
npm run build:watch --workspace=packages/fnetro
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
### Workspace structure
|
|
946
|
+
|
|
947
|
+
```
|
|
948
|
+
fnetro/ root (private monorepo)
|
|
949
|
+
├── packages/
|
|
950
|
+
│ ├── fnetro/ @netrojs/fnetro
|
|
951
|
+
│ │ ├── core.ts Shared types, path matching, constants
|
|
952
|
+
│ │ ├── server.ts Hono factory, SSR renderer, Vite plugin, serve()
|
|
953
|
+
│ │ ├── client.ts SolidJS hydration, SPA router, client middleware
|
|
954
|
+
│ │ └── tsup.config.ts Build config (3 separate entry points)
|
|
955
|
+
│ └── create-fnetro/ @netrojs/create-fnetro
|
|
956
|
+
│ └── src/index.ts CLI scaffolding tool
|
|
957
|
+
├── .changeset/ Changeset version files
|
|
958
|
+
│ └── config.json
|
|
959
|
+
└── .github/
|
|
960
|
+
└── workflows/
|
|
961
|
+
├── ci.yml Typecheck, build, scaffold smoke tests
|
|
962
|
+
└── release.yml Changeset-driven versioning + npm publish
|
|
1118
963
|
```
|
|
1119
964
|
|
|
1120
965
|
---
|
|
1121
966
|
|
|
1122
|
-
##
|
|
967
|
+
## Publishing & releases
|
|
1123
968
|
|
|
1124
|
-
|
|
969
|
+
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
|
|
1125
970
|
|
|
1126
|
-
|
|
971
|
+
### Day-to-day workflow
|
|
1127
972
|
|
|
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 |
|
|
973
|
+
**1. Make changes** to `packages/fnetro` and/or `packages/create-fnetro`.
|
|
1160
974
|
|
|
1161
|
-
**
|
|
975
|
+
**2. Add a changeset** describing the change:
|
|
976
|
+
```bash
|
|
977
|
+
npm run changeset
|
|
978
|
+
# → prompts you to select packages and bump type (patch/minor/major)
|
|
979
|
+
# → writes a .changeset/*.md file — commit this with your changes
|
|
980
|
+
```
|
|
1162
981
|
|
|
1163
|
-
|
|
1164
|
-
|---|---|
|
|
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 |
|
|
982
|
+
**3. Open a PR.** CI runs typecheck, build, and scaffold smoke tests on Node 18 / 20 / 22 / 24.
|
|
1170
983
|
|
|
1171
|
-
|
|
984
|
+
**4. Merge to `main`.** The `release.yml` workflow runs automatically:
|
|
985
|
+
- If `.changeset/*.md` files exist → opens / updates a **"Version Packages"** PR that bumps versions and updates `CHANGELOG.md`
|
|
986
|
+
- If the "Version Packages" PR is merged → **publishes both packages to npm** with provenance attestation and creates a GitHub Release
|
|
1172
987
|
|
|
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 |
|
|
988
|
+
### Manual release
|
|
1179
989
|
|
|
1180
|
-
|
|
990
|
+
```bash
|
|
991
|
+
# Dry run — see what would be published
|
|
992
|
+
npm run release:dry
|
|
993
|
+
|
|
994
|
+
# Full release (build + changeset publish)
|
|
995
|
+
npm run release
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### Secrets required
|
|
1181
999
|
|
|
1182
|
-
|
|
|
1000
|
+
| Secret | Description |
|
|
1183
1001
|
|---|---|
|
|
1184
|
-
| `
|
|
1185
|
-
| `
|
|
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 |
|
|
1002
|
+
| `NPM_TOKEN` | npm automation token (requires publish permission for `@netrojs`) |
|
|
1003
|
+
| `GITHUB_TOKEN` | Provided automatically by GitHub Actions |
|
|
1189
1004
|
|
|
1190
1005
|
---
|
|
1191
1006
|
|
|
1192
1007
|
## License
|
|
1193
1008
|
|
|
1194
|
-
MIT © [Netro Solutions](https://
|
|
1009
|
+
MIT © [Netro Solutions](https://netrosolutions.com)
|