@netrojs/fnetro 0.2.21 → 0.3.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 +185 -878
- package/client.ts +213 -242
- package/core.ts +74 -175
- package/dist/client.d.ts +69 -60
- package/dist/client.js +170 -177
- package/dist/core.d.ts +57 -40
- package/dist/core.js +50 -28
- package/dist/server.d.ts +69 -66
- package/dist/server.js +178 -199
- package/dist/types.d.ts +99 -0
- package/package.json +21 -20
- package/server.ts +263 -350
- package/types.ts +125 -0
package/README.md
CHANGED
|
@@ -1,50 +1,29 @@
|
|
|
1
|
-
# FNetro
|
|
1
|
+
# ◈ FNetro
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> SSR · SPA · SEO · server & client middleware · multi-runtime · TypeScript-first.
|
|
3
|
+
**Full-stack Hono + Vue 3 framework — Streaming SSR · SPA · Code Splitting · SEO · TypeScript**
|
|
5
4
|
|
|
6
|
-
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
8
|
-
[](https://www.npmjs.com/package/@netrojs/create-fnetro)
|
|
5
|
+
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
9
6
|
[](./LICENSE)
|
|
10
7
|
|
|
11
8
|
---
|
|
12
9
|
|
|
13
|
-
##
|
|
14
|
-
|
|
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)
|
|
10
|
+
## What is FNetro?
|
|
39
11
|
|
|
40
|
-
|
|
12
|
+
FNetro connects [Hono](https://hono.dev) (server) and [Vue 3](https://vuejs.org) (UI) into a
|
|
13
|
+
single full-stack framework. You define routes once; FNetro renders them on the server with
|
|
14
|
+
streaming SSR and hydrates them in the browser as a SPA — with zero boilerplate.
|
|
41
15
|
|
|
42
|
-
|
|
16
|
+
### Key features
|
|
43
17
|
|
|
44
|
-
|
|
|
18
|
+
| Feature | Detail |
|
|
45
19
|
|---|---|
|
|
46
|
-
|
|
|
47
|
-
| [
|
|
20
|
+
| **Streaming SSR** | Uses Vue's `renderToWebStream` — `<head>` is flushed immediately while the body streams, giving the browser a head start on CSS and scripts |
|
|
21
|
+
| **SPA navigation** | Client-side routing via [Vue Router](https://router.vuejs.org); page data is fetched as JSON from the same Hono handler |
|
|
22
|
+
| **Code splitting** | Pass `() => import('./Page.vue')` as `component` — FNetro resolves it before SSR and wraps it in `defineAsyncComponent` on the client |
|
|
23
|
+
| **Type-safe loaders** | Loader return types flow through to `usePageData<T>()` with full inference |
|
|
24
|
+
| **Full SEO** | Per-page title, description, Open Graph, Twitter Cards, JSON-LD — synced client-side on every navigation |
|
|
25
|
+
| **Middleware** | Server (Hono) middleware per-app, per-group, and per-route; client middleware for auth guards, analytics, etc. |
|
|
26
|
+
| **Multi-runtime** | Node.js, Bun, Deno, Cloudflare Workers (edge) — same code |
|
|
48
27
|
|
|
49
28
|
---
|
|
50
29
|
|
|
@@ -52,958 +31,286 @@
|
|
|
52
31
|
|
|
53
32
|
```bash
|
|
54
33
|
npm create @netrojs/fnetro@latest my-app
|
|
55
|
-
cd my-app
|
|
56
|
-
npm install
|
|
57
|
-
npm run dev
|
|
34
|
+
cd my-app && npm install && npm run dev
|
|
58
35
|
```
|
|
59
36
|
|
|
60
|
-
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Installation (manual)
|
|
61
40
|
|
|
62
41
|
```bash
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
deno run -A npm:create-fnetro my-app
|
|
42
|
+
npm i @netrojs/fnetro vue vue-router @vue/server-renderer hono
|
|
43
|
+
npm i -D vite @vitejs/plugin-vue @hono/vite-dev-server vue-tsc typescript
|
|
66
44
|
```
|
|
67
45
|
|
|
68
46
|
---
|
|
69
47
|
|
|
70
|
-
##
|
|
48
|
+
## File structure
|
|
71
49
|
|
|
72
50
|
```
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
|
51
|
+
my-app/
|
|
52
|
+
├── app.ts ← Hono app (default export for dev server)
|
|
53
|
+
├── server.ts ← Production server entry
|
|
54
|
+
├── client.ts ← Browser hydration entry
|
|
55
|
+
├── vite.config.ts
|
|
56
|
+
├── tsconfig.json
|
|
57
|
+
└── app/
|
|
58
|
+
├── routes.ts ← Route definitions
|
|
59
|
+
├── layouts/
|
|
60
|
+
│ └── RootLayout.vue
|
|
61
|
+
├── pages/
|
|
62
|
+
│ ├── home.vue
|
|
63
|
+
│ └── about.vue
|
|
64
|
+
└── style.css
|
|
97
65
|
```
|
|
98
66
|
|
|
99
67
|
---
|
|
100
68
|
|
|
101
|
-
##
|
|
102
|
-
|
|
103
|
-
### `definePage`
|
|
104
|
-
|
|
105
|
-
Define a route with an optional SSR loader, SEO config, and a SolidJS component.
|
|
106
|
-
|
|
107
|
-
```tsx
|
|
108
|
-
// app/routes/post.tsx
|
|
109
|
-
import { definePage } from '@netrojs/fnetro'
|
|
110
|
-
|
|
111
|
-
export default definePage({
|
|
112
|
-
path: '/posts/[slug]',
|
|
69
|
+
## Routes
|
|
113
70
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
71
|
+
```ts
|
|
72
|
+
// app/routes.ts
|
|
73
|
+
import { definePage, defineLayout } from '@netrojs/fnetro'
|
|
74
|
+
import RootLayout from './layouts/RootLayout.vue'
|
|
75
|
+
|
|
76
|
+
export const rootLayout = defineLayout(RootLayout)
|
|
77
|
+
|
|
78
|
+
export const routes = [
|
|
79
|
+
definePage({
|
|
80
|
+
path: '/posts/[slug]',
|
|
81
|
+
layout: rootLayout,
|
|
82
|
+
seo: (data, params) => ({ title: `${data.post.title} — My Blog` }),
|
|
83
|
+
|
|
84
|
+
loader: async (c) => {
|
|
85
|
+
const slug = c.req.param('slug')
|
|
86
|
+
const post = await db.getPost(slug)
|
|
87
|
+
return { post }
|
|
88
|
+
},
|
|
120
89
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
description: data.post.excerpt,
|
|
124
|
-
ogImage: data.post.coverUrl,
|
|
125
|
-
twitterCard: 'summary_large_image',
|
|
90
|
+
// () => import() = separate JS chunk (loaded only when needed)
|
|
91
|
+
component: () => import('./pages/post.vue'),
|
|
126
92
|
}),
|
|
127
|
-
|
|
128
|
-
Page({ post, url, params }) {
|
|
129
|
-
return <article>{post.title}</article>
|
|
130
|
-
},
|
|
131
|
-
})
|
|
93
|
+
]
|
|
132
94
|
```
|
|
133
95
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
| Pattern | Matches | `params` |
|
|
137
|
-
|---|---|---|
|
|
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)* |
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
### `defineGroup`
|
|
145
|
-
|
|
146
|
-
Group routes under a shared URL prefix, layout, and middleware.
|
|
96
|
+
### Route groups
|
|
147
97
|
|
|
148
98
|
```ts
|
|
149
99
|
import { defineGroup } from '@netrojs/fnetro'
|
|
150
100
|
|
|
151
|
-
export const adminGroup = defineGroup({
|
|
152
|
-
prefix: '/admin',
|
|
153
|
-
layout: AdminLayout, // optional — overrides app default
|
|
154
|
-
middleware: [requireAuth, auditLog],
|
|
155
|
-
routes: [dashboard, users, settings],
|
|
156
|
-
})
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
Groups nest arbitrarily:
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
101
|
defineGroup({
|
|
163
|
-
prefix: '/
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
],
|
|
102
|
+
prefix: '/admin',
|
|
103
|
+
middleware: [authMiddleware],
|
|
104
|
+
layout: adminLayout,
|
|
105
|
+
routes: [dashboardRoute, usersRoute],
|
|
168
106
|
})
|
|
169
107
|
```
|
|
170
108
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
### `defineLayout`
|
|
174
|
-
|
|
175
|
-
Wrap every page with a shared shell (nav, footer, providers).
|
|
176
|
-
|
|
177
|
-
```tsx
|
|
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
|
-
))
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
**Per-page override:**
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
// Use a different layout
|
|
200
|
-
definePage({ path: '/landing', layout: LandingLayout, Page: ... })
|
|
201
|
-
|
|
202
|
-
// Disable layout entirely
|
|
203
|
-
definePage({ path: '/embed', layout: false, Page: ... })
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
|
-
### `defineApiRoute`
|
|
209
|
-
|
|
210
|
-
Mount raw Hono sub-routes. Full Hono API — REST, RPC, WebSocket, streaming.
|
|
109
|
+
### API routes
|
|
211
110
|
|
|
212
111
|
```ts
|
|
213
112
|
import { defineApiRoute } from '@netrojs/fnetro'
|
|
214
|
-
import { zValidator } from '@hono/zod-validator'
|
|
215
|
-
import { z } from 'zod'
|
|
216
|
-
|
|
217
|
-
export const api = defineApiRoute('/api', (app) => {
|
|
218
|
-
app.get('/health', (c) =>
|
|
219
|
-
c.json({ status: 'ok', ts: Date.now() }),
|
|
220
|
-
)
|
|
221
|
-
|
|
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)
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
app.post(
|
|
228
|
-
'/items',
|
|
229
|
-
zValidator('json', z.object({ name: z.string().min(1) })),
|
|
230
|
-
async (c) => {
|
|
231
|
-
const item = await db.items.create(c.req.valid('json'))
|
|
232
|
-
return c.json(item, 201)
|
|
233
|
-
},
|
|
234
|
-
)
|
|
235
|
-
})
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
---
|
|
239
|
-
|
|
240
|
-
## Loaders
|
|
241
|
-
|
|
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.
|
|
243
|
-
|
|
244
|
-
```ts
|
|
245
|
-
definePage({
|
|
246
|
-
path: '/dashboard',
|
|
247
|
-
|
|
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')
|
|
252
|
-
|
|
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 */ },
|
|
259
|
-
})
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
**Type-safe loaders:**
|
|
263
|
-
|
|
264
|
-
```ts
|
|
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 } */ },
|
|
273
|
-
})
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
---
|
|
277
|
-
|
|
278
|
-
## SEO
|
|
279
|
-
|
|
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.
|
|
282
|
-
|
|
283
|
-
```ts
|
|
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
|
-
})
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
```ts
|
|
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
113
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 }) { ... },
|
|
114
|
+
export const apiRoutes = defineApiRoute('/api', (app) => {
|
|
115
|
+
app.get('/health', (c) => c.json({ ok: true }))
|
|
116
|
+
app.post('/items', createItem)
|
|
329
117
|
})
|
|
330
118
|
```
|
|
331
119
|
|
|
332
|
-
### All SEO fields
|
|
333
|
-
|
|
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.
|
|
364
|
-
|
|
365
120
|
---
|
|
366
121
|
|
|
367
|
-
##
|
|
368
|
-
|
|
369
|
-
### Server middleware
|
|
122
|
+
## Page components
|
|
370
123
|
|
|
371
|
-
|
|
124
|
+
```vue
|
|
125
|
+
<!-- app/pages/post.vue -->
|
|
126
|
+
<script setup lang="ts">
|
|
127
|
+
import { usePageData } from '@netrojs/fnetro/client'
|
|
372
128
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
import { cors } from 'hono/cors'
|
|
376
|
-
import { logger } from 'hono/logger'
|
|
377
|
-
import { bearerAuth } from 'hono/bearer-auth'
|
|
378
|
-
|
|
379
|
-
const fnetro = createFNetro({
|
|
380
|
-
// 1. Global — runs on every request
|
|
381
|
-
middleware: [logger(), cors({ origin: 'https://example.com' })],
|
|
382
|
-
|
|
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
|
-
```
|
|
400
|
-
|
|
401
|
-
Middleware can short-circuit by returning a `Response`:
|
|
402
|
-
|
|
403
|
-
```ts
|
|
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()
|
|
129
|
+
interface PostData {
|
|
130
|
+
post: { title: string; body: string }
|
|
409
131
|
}
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
---
|
|
413
|
-
|
|
414
|
-
### Client middleware
|
|
415
132
|
|
|
416
|
-
|
|
133
|
+
const data = usePageData<PostData>() // reactive, updates on navigation
|
|
134
|
+
</script>
|
|
417
135
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
await next()
|
|
425
|
-
analytics.page({ url })
|
|
426
|
-
})
|
|
427
|
-
|
|
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()
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
// Loading indicator
|
|
438
|
-
useClientMiddleware(async (url, next) => {
|
|
439
|
-
NProgress.start()
|
|
440
|
-
try { await next() }
|
|
441
|
-
finally { NProgress.done() }
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
boot({ routes, layout })
|
|
136
|
+
<template>
|
|
137
|
+
<article>
|
|
138
|
+
<h1>{{ data.post.title }}</h1>
|
|
139
|
+
<p>{{ data.post.body }}</p>
|
|
140
|
+
</article>
|
|
141
|
+
</template>
|
|
445
142
|
```
|
|
446
143
|
|
|
447
|
-
|
|
144
|
+
`usePageData<T>()` injects the current page's loader data. The object is reactive — it
|
|
145
|
+
updates automatically when navigating to another page of the same component type.
|
|
448
146
|
|
|
449
147
|
---
|
|
450
148
|
|
|
451
|
-
##
|
|
452
|
-
|
|
453
|
-
Use SolidJS primitives directly — no FNetro wrappers.
|
|
454
|
-
|
|
455
|
-
**Module-level signals** persist across SPA navigations (they live for the lifetime of the page JS):
|
|
149
|
+
## Layout components
|
|
456
150
|
|
|
457
|
-
|
|
458
|
-
import { createSignal, createMemo, createEffect, For } from 'solid-js'
|
|
459
|
-
import { definePage } from '@netrojs/fnetro'
|
|
151
|
+
A layout component must render `<slot />` for the page content:
|
|
460
152
|
|
|
461
|
-
|
|
462
|
-
|
|
153
|
+
```vue
|
|
154
|
+
<!-- app/layouts/RootLayout.vue -->
|
|
155
|
+
<script setup lang="ts">
|
|
156
|
+
import { RouterLink, useRoute } from 'vue-router'
|
|
157
|
+
const route = useRoute()
|
|
158
|
+
</script>
|
|
463
159
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
)
|
|
474
|
-
},
|
|
475
|
-
})
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
**Stores** for structured reactive state:
|
|
479
|
-
|
|
480
|
-
```tsx
|
|
481
|
-
import { createStore, produce } from 'solid-js/store'
|
|
482
|
-
|
|
483
|
-
interface Todo { id: number; text: string; done: boolean }
|
|
484
|
-
const [todos, setTodos] = createStore<{ items: Todo[] }>({ items: [] })
|
|
485
|
-
|
|
486
|
-
function toggle(id: number) {
|
|
487
|
-
setTodos('items', t => t.id === id, produce(t => { t.done = !t.done }))
|
|
488
|
-
}
|
|
489
|
-
|
|
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
|
-
},
|
|
506
|
-
})
|
|
160
|
+
<template>
|
|
161
|
+
<div>
|
|
162
|
+
<nav>
|
|
163
|
+
<RouterLink to="/">Home</RouterLink>
|
|
164
|
+
<RouterLink to="/about">About</RouterLink>
|
|
165
|
+
</nav>
|
|
166
|
+
<main><slot /></main>
|
|
167
|
+
</div>
|
|
168
|
+
</template>
|
|
507
169
|
```
|
|
508
170
|
|
|
509
171
|
---
|
|
510
172
|
|
|
511
|
-
##
|
|
512
|
-
|
|
513
|
-
### Links — automatic interception
|
|
514
|
-
|
|
515
|
-
Any `<a href="...">` pointing to a registered route is intercepted automatically. No special component needed.
|
|
516
|
-
|
|
517
|
-
```tsx
|
|
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 */}
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
### Programmatic navigation
|
|
525
|
-
|
|
526
|
-
```ts
|
|
527
|
-
import { navigate } from '@netrojs/fnetro/client'
|
|
528
|
-
|
|
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
|
-
```
|
|
533
|
-
|
|
534
|
-
### Prefetch
|
|
173
|
+
## App + entry files
|
|
535
174
|
|
|
536
175
|
```ts
|
|
537
|
-
|
|
176
|
+
// app.ts — Hono instance (used by dev server and production)
|
|
177
|
+
import { createFNetro } from '@netrojs/fnetro/server'
|
|
178
|
+
import { routes } from './app/routes'
|
|
538
179
|
|
|
539
|
-
|
|
180
|
+
export const fnetro = createFNetro({ routes })
|
|
181
|
+
export default fnetro.app // @hono/vite-dev-server needs the Hono instance
|
|
540
182
|
```
|
|
541
183
|
|
|
542
|
-
Hover-based prefetching is automatic when `prefetchOnHover: true` (the default) is set in `boot()`.
|
|
543
|
-
|
|
544
|
-
---
|
|
545
|
-
|
|
546
|
-
## Asset handling
|
|
547
|
-
|
|
548
|
-
### Development
|
|
549
|
-
|
|
550
|
-
`@hono/vite-dev-server` injects Vite's dev client and HMR scripts automatically. No asset config needed.
|
|
551
|
-
|
|
552
|
-
### Production
|
|
553
|
-
|
|
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
184
|
```ts
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
manifestDir: 'dist/assets', // directory containing manifest.json
|
|
562
|
-
manifestEntry: 'client.ts', // key in the manifest (your client entry)
|
|
563
|
-
},
|
|
564
|
-
})
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
**Manual override** (edge runtimes / CDN-hosted assets):
|
|
185
|
+
// client.ts — browser entry (hydration + SPA routing)
|
|
186
|
+
import { boot } from '@netrojs/fnetro/client'
|
|
187
|
+
import { routes } from './app/routes'
|
|
188
|
+
import './app/style.css'
|
|
568
189
|
|
|
569
|
-
|
|
570
|
-
createFNetro({
|
|
571
|
-
assets: {
|
|
572
|
-
scripts: ['https://cdn.example.com/client-abc123.js'],
|
|
573
|
-
styles: ['https://cdn.example.com/style-def456.css'],
|
|
574
|
-
},
|
|
575
|
-
})
|
|
190
|
+
boot({ routes })
|
|
576
191
|
```
|
|
577
192
|
|
|
578
|
-
**Public directory** — static files in `public/` (images, fonts, `robots.txt`, `favicon.ico`) are served at `/` by the Node.js `serve()` helper automatically.
|
|
579
|
-
|
|
580
|
-
---
|
|
581
|
-
|
|
582
|
-
## Multi-runtime serve()
|
|
583
|
-
|
|
584
193
|
```ts
|
|
194
|
+
// server.ts — production server
|
|
585
195
|
import { serve } from '@netrojs/fnetro/server'
|
|
586
|
-
|
|
587
|
-
// Auto-detects Node.js, Bun, or Deno
|
|
588
|
-
await serve({ app: fnetro })
|
|
589
|
-
|
|
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
|
-
})
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
**Edge runtimes** (Cloudflare Workers, Deno Deploy, Fastly, etc.):
|
|
601
|
-
|
|
602
|
-
```ts
|
|
603
|
-
// server.ts
|
|
604
196
|
import { fnetro } from './app'
|
|
605
197
|
|
|
606
|
-
|
|
607
|
-
export default { fetch: fnetro.handler }
|
|
198
|
+
await serve({ app: fnetro, port: 3000, runtime: 'node' })
|
|
608
199
|
```
|
|
609
200
|
|
|
610
201
|
---
|
|
611
202
|
|
|
612
|
-
## Vite
|
|
203
|
+
## Vite config
|
|
613
204
|
|
|
614
205
|
```ts
|
|
615
206
|
// vite.config.ts
|
|
616
|
-
import { defineConfig }
|
|
207
|
+
import { defineConfig } from 'vite'
|
|
208
|
+
import vue from '@vitejs/plugin-vue'
|
|
617
209
|
import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
|
|
618
|
-
import devServer
|
|
210
|
+
import devServer from '@hono/vite-dev-server'
|
|
619
211
|
|
|
620
212
|
export default defineConfig({
|
|
621
213
|
plugins: [
|
|
622
|
-
//
|
|
623
|
-
fnetroVitePlugin(
|
|
624
|
-
|
|
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
|
-
}),
|
|
631
|
-
|
|
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' }),
|
|
214
|
+
vue(), // handles .vue transforms
|
|
215
|
+
fnetroVitePlugin(), // dual-bundle build orchestration
|
|
216
|
+
devServer({ entry: 'app.ts' }), // dev server wires requests to Hono
|
|
636
217
|
],
|
|
637
218
|
})
|
|
638
219
|
```
|
|
639
220
|
|
|
640
|
-
|
|
641
|
-
|
|
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)
|
|
650
|
-
```
|
|
221
|
+
Running `vite build` produces two bundles:
|
|
222
|
+
- `dist/server/server.js` — SSR server bundle (ESM)
|
|
223
|
+
- `dist/assets/` — client SPA bundle with hashed filenames + `.vite/manifest.json`
|
|
651
224
|
|
|
652
225
|
---
|
|
653
226
|
|
|
654
|
-
##
|
|
655
|
-
|
|
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
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
### `app.ts` vs `server.ts`
|
|
227
|
+
## Client middleware
|
|
685
228
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
---
|
|
229
|
+
```ts
|
|
230
|
+
// client.ts
|
|
231
|
+
import { boot, useClientMiddleware } from '@netrojs/fnetro/client'
|
|
232
|
+
import { routes } from './app/routes'
|
|
692
233
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
234
|
+
// Runs before every SPA navigation (must be registered before boot())
|
|
235
|
+
useClientMiddleware(async (url, next) => {
|
|
236
|
+
if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
237
|
+
location.href = '/login'
|
|
238
|
+
return // cancel navigation
|
|
713
239
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
> **Important:** `jsxImportSource` must be `"solid-js"` — not `"hono/jsx"`. FNetro v0.2+ uses SolidJS for all rendering.
|
|
718
|
-
|
|
719
|
-
---
|
|
720
|
-
|
|
721
|
-
## create-fnetro CLI
|
|
722
|
-
|
|
723
|
-
Scaffold a new project interactively or from CI:
|
|
724
|
-
|
|
725
|
-
```bash
|
|
726
|
-
npm create @netrojs/fnetro@latest [project-name] [flags]
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
### Interactive mode
|
|
730
|
-
|
|
731
|
-
Running without flags opens a step-by-step prompt:
|
|
732
|
-
|
|
733
|
-
```
|
|
734
|
-
⬡ create-fnetro
|
|
735
|
-
Full-stack Hono + SolidJS — SSR · SPA · SEO · TypeScript
|
|
736
|
-
|
|
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
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
### CLI flags (non-interactive / CI)
|
|
746
|
-
|
|
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 |
|
|
755
|
-
|
|
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
|
-
```
|
|
765
|
-
|
|
766
|
-
### Templates
|
|
767
|
-
|
|
768
|
-
**`minimal`** — production-ready starter:
|
|
769
|
-
```
|
|
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
|
|
776
|
-
```
|
|
240
|
+
await next()
|
|
241
|
+
})
|
|
777
242
|
|
|
778
|
-
|
|
243
|
+
boot({ routes })
|
|
779
244
|
```
|
|
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
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
### Supported runtimes
|
|
788
|
-
|
|
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 }` |
|
|
796
|
-
|
|
797
|
-
---
|
|
798
|
-
|
|
799
|
-
## API reference
|
|
800
|
-
|
|
801
|
-
### `@netrojs/fnetro` (core)
|
|
802
|
-
|
|
803
|
-
**Functions:**
|
|
804
|
-
|
|
805
|
-
| Export | Signature | Description |
|
|
806
|
-
|---|---|---|
|
|
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 |
|
|
814
|
-
|
|
815
|
-
**Constants:** `SPA_HEADER` · `STATE_KEY` · `PARAMS_KEY` · `SEO_KEY`
|
|
816
|
-
|
|
817
|
-
**Types:** `AppConfig` · `PageDef<T>` · `GroupDef` · `LayoutDef` · `ApiRouteDef` · `Route` · `PageProps<T>` · `LayoutProps` · `SEOMeta` · `HonoMiddleware` · `LoaderCtx` · `ClientMiddleware` · `ResolvedRoute` · `CompiledPath`
|
|
818
245
|
|
|
819
246
|
---
|
|
820
247
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
**Functions:**
|
|
824
|
-
|
|
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 |
|
|
831
|
-
|
|
832
|
-
**`FNetroOptions`** (extends `AppConfig`):
|
|
833
|
-
|
|
834
|
-
```ts
|
|
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
|
-
}
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
**`AssetConfig`:**
|
|
848
|
-
|
|
849
|
-
```ts
|
|
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')
|
|
855
|
-
}
|
|
856
|
-
```
|
|
857
|
-
|
|
858
|
-
**`ServeOptions`:**
|
|
859
|
-
|
|
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'
|
|
867
|
-
}
|
|
868
|
-
```
|
|
869
|
-
|
|
870
|
-
**`FNetroPluginOptions`:**
|
|
248
|
+
## SEO
|
|
871
249
|
|
|
872
250
|
```ts
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
251
|
+
definePage({
|
|
252
|
+
path: '/blog/[slug]',
|
|
253
|
+
seo: (data, params) => ({
|
|
254
|
+
title: `${data.post.title} — My Blog`,
|
|
255
|
+
description: data.post.excerpt,
|
|
256
|
+
ogTitle: data.post.title,
|
|
257
|
+
ogImage: data.post.coverImage,
|
|
258
|
+
twitterCard: 'summary_large_image',
|
|
259
|
+
jsonLd: {
|
|
260
|
+
'@context': 'https://schema.org',
|
|
261
|
+
'@type': 'Article',
|
|
262
|
+
headline: data.post.title,
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
component: () => import('./pages/blog-post.vue'),
|
|
266
|
+
})
|
|
881
267
|
```
|
|
882
268
|
|
|
883
269
|
---
|
|
884
270
|
|
|
885
|
-
|
|
271
|
+
## Exports
|
|
886
272
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
|
890
|
-
|
|
891
|
-
| `
|
|
892
|
-
| `
|
|
893
|
-
| `prefetch` | `(url) → void` | Warm loader cache |
|
|
894
|
-
| `useClientMiddleware` | `(fn: ClientMiddleware) → void` | Register nav middleware |
|
|
895
|
-
|
|
896
|
-
**`BootOptions`** (extends `AppConfig`):
|
|
897
|
-
|
|
898
|
-
```ts
|
|
899
|
-
interface BootOptions extends AppConfig {
|
|
900
|
-
prefetchOnHover?: boolean // default: true
|
|
901
|
-
}
|
|
902
|
-
```
|
|
903
|
-
|
|
904
|
-
**`NavigateOptions`:**
|
|
905
|
-
|
|
906
|
-
```ts
|
|
907
|
-
interface NavigateOptions {
|
|
908
|
-
replace?: boolean // replaceState instead of pushState
|
|
909
|
-
scroll?: boolean // scroll to top after navigation (default: true)
|
|
910
|
-
}
|
|
911
|
-
```
|
|
912
|
-
|
|
913
|
-
**`ClientMiddleware`:**
|
|
914
|
-
|
|
915
|
-
```ts
|
|
916
|
-
type ClientMiddleware = (
|
|
917
|
-
url: string,
|
|
918
|
-
next: () => Promise<void>,
|
|
919
|
-
) => Promise<void>
|
|
920
|
-
```
|
|
273
|
+
| Import path | Contents |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `@netrojs/fnetro` | Core builders + types (`definePage`, `defineGroup`, …) |
|
|
276
|
+
| `@netrojs/fnetro/server` | `createFNetro`, `serve`, `fnetroVitePlugin` |
|
|
277
|
+
| `@netrojs/fnetro/client` | `boot`, `usePageData`, `useClientMiddleware`, `syncSEO`, Vue Router re-exports |
|
|
278
|
+
| `@netrojs/fnetro/vite` | Alias for server — Vite plugin only |
|
|
921
279
|
|
|
922
280
|
---
|
|
923
281
|
|
|
924
|
-
##
|
|
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
|
|
931
|
-
|
|
932
|
-
# Build both packages
|
|
933
|
-
npm run build
|
|
934
|
-
|
|
935
|
-
# Typecheck both packages
|
|
936
|
-
npm run typecheck
|
|
937
|
-
|
|
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
|
|
282
|
+
## How streaming SSR works
|
|
946
283
|
|
|
947
284
|
```
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
└── .github/
|
|
960
|
-
└── workflows/
|
|
961
|
-
├── ci.yml Typecheck, build, scaffold smoke tests
|
|
962
|
-
└── release.yml Changeset-driven versioning + npm publish
|
|
963
|
-
```
|
|
964
|
-
|
|
965
|
-
---
|
|
966
|
-
|
|
967
|
-
## Publishing & releases
|
|
968
|
-
|
|
969
|
-
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
|
|
970
|
-
|
|
971
|
-
### Day-to-day workflow
|
|
972
|
-
|
|
973
|
-
**1. Make changes** to `packages/fnetro` and/or `packages/create-fnetro`.
|
|
974
|
-
|
|
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
|
|
285
|
+
Request arrives at Hono
|
|
286
|
+
↓
|
|
287
|
+
Route matched → middleware chain → loader() runs
|
|
288
|
+
↓
|
|
289
|
+
SEO meta computed → <head> HTML built
|
|
290
|
+
↓
|
|
291
|
+
Response stream opened:
|
|
292
|
+
chunk 1: <!DOCTYPE html><html><head>…</head><body><div id="fnetro-app">
|
|
293
|
+
(browser starts loading CSS + scripts immediately)
|
|
294
|
+
chunk 2..N: Vue body HTML chunks from renderToWebStream()
|
|
295
|
+
chunk last: </div><script>window.__STATE__…</script><script src="client.js">
|
|
980
296
|
```
|
|
981
297
|
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|
987
|
-
|
|
988
|
-
### Manual release
|
|
989
|
-
|
|
990
|
-
```bash
|
|
991
|
-
# Dry run — see what would be published
|
|
992
|
-
npm run release:dry
|
|
298
|
+
The browser receives and processes `<head>` (CSS, fonts) while Vue is still rendering
|
|
299
|
+
the body tree — lower TTFB and better LCP vs buffered `renderToString`.
|
|
993
300
|
|
|
994
|
-
|
|
995
|
-
npm run release
|
|
996
|
-
```
|
|
301
|
+
---
|
|
997
302
|
|
|
998
|
-
|
|
303
|
+
## Supported runtimes
|
|
999
304
|
|
|
1000
|
-
|
|
|
1001
|
-
|
|
1002
|
-
|
|
|
1003
|
-
|
|
|
305
|
+
| Runtime | `serve()` option | Notes |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| Node.js | `runtime: 'node'` | Uses `@hono/node-server` |
|
|
308
|
+
| Bun | `runtime: 'bun'` | Uses `Bun.serve()` |
|
|
309
|
+
| Deno | `runtime: 'deno'` | Uses `Deno.serve()` |
|
|
310
|
+
| Edge (CF Workers, etc.) | — | Export `fnetro.handler` as the fetch handler |
|
|
1004
311
|
|
|
1005
312
|
---
|
|
1006
313
|
|
|
1007
314
|
## License
|
|
1008
315
|
|
|
1009
|
-
MIT © [Netro Solutions](https://
|
|
316
|
+
MIT © [Netro Solutions](https://github.com/netrosolutions)
|