@netrojs/fnetro 0.2.20 → 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 CHANGED
@@ -1,50 +1,29 @@
1
- # FNetro
1
+ # FNetro
2
2
 
3
- > Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+**
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
- [![CI](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml/badge.svg)](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml)
7
- [![npm @netrojs/fnetro](https://img.shields.io/npm/v/@netrojs/fnetro?label=%40netrojs%2Ffnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
8
- [![npm create-fnetro](https://img.shields.io/npm/v/@netrojs/create-fnetro?label=%40netrojs%2Fcreate-fnetro)](https://www.npmjs.com/package/@netrojs/create-fnetro)
5
+ [![npm](https://img.shields.io/npm/v/@netrojs/fnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
9
6
  [![license](https://img.shields.io/npm/l/@netrojs/fnetro)](./LICENSE)
10
7
 
11
8
  ---
12
9
 
13
- ## Table of contents
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
- ## Packages
16
+ ### Key features
43
17
 
44
- | Package | Description |
18
+ | Feature | Detail |
45
19
  |---|---|
46
- | [`@netrojs/fnetro`](./packages/fnetro) | Core frameworkSSR renderer, SPA routing, SEO, middleware, Vite plugin |
47
- | [`@netrojs/create-fnetro`](./packages/create-fnetro) | Interactive project scaffolding CLI |
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
- Or with other package managers:
37
+ ---
38
+
39
+ ## Installation (manual)
61
40
 
62
41
  ```bash
63
- pnpm create @netrojs/fnetro@latest my-app
64
- bun create @netrojs/fnetro my-app
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
- ## How it works
48
+ ## File structure
71
49
 
72
50
  ```
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)
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
- ## Routing
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
- loader: async (c) => {
115
- const slug = c.req.param('slug')
116
- const post = await db.posts.findBySlug(slug)
117
- if (!post) return c.notFound()
118
- return { post }
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
- seo: (data) => ({
122
- title: `${data.post.title} My Blog`,
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
- **Path patterns:**
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: '/api',
164
- routes: [
165
- defineGroup({ prefix: '/v1', routes: [v1] }),
166
- defineGroup({ prefix: '/v2', routes: [v2] }),
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
- 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 }) { ... },
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
- ## Middleware
368
-
369
- ### Server middleware
122
+ ## Page components
370
123
 
371
- Hono middleware at three levels — global, group, and page.
124
+ ```vue
125
+ <!-- app/pages/post.vue -->
126
+ <script setup lang="ts">
127
+ import { usePageData } from '@netrojs/fnetro/client'
372
128
 
373
- ```ts
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'
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
- Runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** `boot()`.
133
+ const data = usePageData<PostData>() // reactive, updates on navigation
134
+ </script>
417
135
 
418
- ```ts
419
- // client.ts
420
- import { boot, useClientMiddleware, navigate } from '@netrojs/fnetro/client'
421
-
422
- // Analytics — fires after navigation completes
423
- useClientMiddleware(async (url, next) => {
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
- The chain runs in registration order: `mw1 mw2 ... fetch + render`. Omitting `next()` in any middleware cancels the navigation.
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
- ## SolidJS reactivity
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
- ```tsx
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
- const [count, setCount] = createSignal(0)
462
- const doubled = createMemo(() => count() * 2)
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
- 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
- })
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
- ## Navigation
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
- import { prefetch } from '@netrojs/fnetro/client'
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
- prefetch('/about') // warm the loader cache on hover / focus
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
- // 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)
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
- ```ts
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
- // Export the Hono fetch handler the platform calls it directly
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 plugin
203
+ ## Vite config
613
204
 
614
205
  ```ts
615
206
  // vite.config.ts
616
- import { defineConfig } from 'vite'
207
+ import { defineConfig } from 'vite'
208
+ import vue from '@vitejs/plugin-vue'
617
209
  import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
618
- import devServer from '@hono/vite-dev-server'
210
+ import devServer from '@hono/vite-dev-server'
619
211
 
620
212
  export default defineConfig({
621
213
  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
- }),
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
- ### Build output
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
- ## Project structure
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
- | 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. |
690
-
691
- ---
229
+ ```ts
230
+ // client.ts
231
+ import { boot, useClientMiddleware } from '@netrojs/fnetro/client'
232
+ import { routes } from './app/routes'
692
233
 
693
- ## TypeScript
694
-
695
- `tsconfig.json` for any FNetro project:
696
-
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
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
- **`full`** — includes SolidJS signal demo, dynamic routes, and shared store:
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
- ### `@netrojs/fnetro/server`
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
- 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
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
- ### `@netrojs/fnetro/client`
271
+ ## Exports
886
272
 
887
- **Functions:**
888
-
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`):
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
- ## 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
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
- 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
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
- **3. Open a PR.** CI runs typecheck, build, and scaffold smoke tests on Node 18 / 20 / 22 / 24.
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
- # Full release (build + changeset publish)
995
- npm run release
996
- ```
301
+ ---
997
302
 
998
- ### Secrets required
303
+ ## Supported runtimes
999
304
 
1000
- | Secret | Description |
1001
- |---|---|
1002
- | `NPM_TOKEN` | npm automation token (requires publish permission for `@netrojs`) |
1003
- | `GITHUB_TOKEN` | Provided automatically by GitHub Actions |
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://netrosolutions.com)
316
+ MIT © [Netro Solutions](https://github.com/netrosolutions)