@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 CHANGED
@@ -1,257 +1,169 @@
1
- # FNetro
1
+ # FNetro
2
2
 
3
- > Full-stack [Hono](https://hono.dev) framework SSR, SPA, Vue-like reactivity, route groups, middleware and raw API routes in **3 files**.
3
+ > Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+**
4
+ > SSR · SPA · SEO · server & client middleware · multi-runtime · TypeScript-first.
4
5
 
5
- [![npm](https://img.shields.io/npm/v/@netrojs/fnetro?color=6b8cff&label=fnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
6
- [![npm](https://img.shields.io/npm/v/@netrojs/create-fnetro?color=3ecf8e&label=create-fnetro)](https://www.npmjs.com/package/@netrojs/create-fnetro)
7
- [![CI](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml/badge.svg)](https://github.com/netrosolutions/fnetro/actions)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
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)
9
+ [![license](https://img.shields.io/npm/l/@netrojs/fnetro)](./LICENSE)
9
10
 
10
11
  ---
11
12
 
12
13
  ## Table of contents
13
14
 
14
- - [Quick start](#quick-start)
15
- - [How it works](#how-it-works)
16
- - [Project structure](#project-structure)
17
- - [Core concepts](#core-concepts)
18
- - [definePage](#definepage)
19
- - [defineGroup](#definegroup)
20
- - [defineLayout](#definelayout)
21
- - [defineApiRoute](#defineapiroute)
22
- - [defineMiddleware](#definemiddleware)
23
- - [Reactivity](#reactivity)
24
- - [ref](#ref)
25
- - [reactive](#reactive)
26
- - [computed](#computed)
27
- - [watch](#watch)
28
- - [watchEffect](#watcheffect)
29
- - [effectScope](#effectscope)
30
- - [Helpers](#helpers)
31
- - [Component hooks](#component-hooks)
32
- - [Routing](#routing)
33
- - [Dynamic segments](#dynamic-segments)
34
- - [Route groups](#route-groups)
35
- - [Layout overrides](#layout-overrides)
36
- - [Server](#server)
37
- - [createFNetro](#createfnetro)
38
- - [serve](#serve)
39
- - [Runtime detection](#runtime-detection)
40
- - [Client](#client)
41
- - [boot](#boot)
42
- - [navigate](#navigate)
43
- - [prefetch](#prefetch)
44
- - [Lifecycle hooks](#lifecycle-hooks)
45
- - [Vite plugin](#vite-plugin)
46
- - [Dev server](#dev-server)
47
- - [Global store pattern](#global-store-pattern)
48
- - [TypeScript](#typescript)
49
- - [Runtime support](#runtime-support)
50
- - [API reference](#api-reference)
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
- ## Quick start
55
-
56
- ```bash
57
- # npm
58
- npm create @netrojs/fnetro@latest
42
+ ## Packages
59
43
 
60
- # bun
61
- bun x @netrojs/create-fnetro
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
- # pnpm
64
- pnpm create @netrojs/fnetro@latest
65
- ```
49
+ ---
66
50
 
67
- The CLI will prompt for your project name, runtime (Node / Bun / Deno / Cloudflare / generic), template, and package manager, then scaffold a ready-to-run app.
51
+ ## Quick start
68
52
 
69
53
  ```bash
54
+ npm create @netrojs/fnetro@latest my-app
70
55
  cd my-app
71
- bun install
72
- bun run dev # dev server — no build step required
56
+ npm install
57
+ npm run dev
73
58
  ```
74
59
 
75
- **Manual install:**
60
+ Or with other package managers:
76
61
 
77
62
  ```bash
78
- npm install @netrojs/fnetro hono
79
- npm install -D vite typescript @hono/vite-dev-server
80
- # Node.js only:
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
- User clicks <a href="/posts/world">
111
- Client intercepts click
112
- Client → fetch('/posts/world', { 'x-fnetro-spa': '1' })
113
- Server → runs loader() → returns JSON { html, state, params, url }
114
- Client → render(new page tree) → swaps DOM in place
115
- Client → history.pushState() → URL updates, scroll resets
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
- ## Project structure
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
- A page is a path + optional server loader + JSX component. Everything in one file — no "use client" directives, no separate API routes, no split files.
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, ref, use } from '@netrojs/fnetro/core'
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
- // Runs on the server. Return value becomes Page props.
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.findPost(slug)
190
- if (!post) throw new Error('Not found')
116
+ const post = await db.posts.findBySlug(slug)
117
+ if (!post) return c.notFound()
191
118
  return { post }
192
119
  },
193
120
 
194
- // Rendered server-side on first load, client-side on SPA navigation.
195
- // Same JSX source two runtimes (hono/jsx on server, hono/jsx/dom on client).
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
- const views = use(viewCount) // reactive subscription
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
- Props available in every `Page`:
134
+ **Path patterns:**
210
135
 
211
- | Prop | Type | Description |
136
+ | Pattern | Matches | `params` |
212
137
  |---|---|---|
213
- | `url` | `string` | Current pathname, e.g. `/posts/hello` |
214
- | `params` | `Record<string, string>` | Parsed path params, e.g. `{ slug: 'hello' }` |
215
- | `...loaderData` | inferred | Every key returned by `loader()` |
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
- Groups nest routes under a prefix, sharing a layout and middleware chain.
146
+ Group routes under a shared URL prefix, layout, and middleware.
222
147
 
223
- ```tsx
224
- import { defineGroup, definePage } from '@netrojs/fnetro/core'
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: '/admin',
234
- layout: AdminLayout, // overrides app-level layout
235
- middleware: [requireAuth, auditLog], // applied to every route in group
236
- routes: [dashboard, users, settings],
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
- ```tsx
161
+ ```ts
243
162
  defineGroup({
244
- prefix: '/org/[orgId]',
245
- middleware: [loadOrg],
163
+ prefix: '/api',
246
164
  routes: [
247
- definePage({ path: '', ... }),
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
- A layout wraps pages with shared chrome nav, footer, theme, auth state.
175
+ Wrap every page with a shared shell (nav, footer, providers).
264
176
 
265
177
  ```tsx
266
- // app/layouts.tsx
267
- import { defineLayout, use, ref } from '@netrojs/fnetro/core'
268
- import { theme, toggleTheme } from './store'
269
-
270
- export const RootLayout = defineLayout(function Layout({ children, url, params }) {
271
- const t = use(theme) // reactive — re-renders when theme changes
272
-
273
- return (
274
- <div class={`app theme-${t}`}>
275
- <nav>
276
- <a href="/">Home</a>
277
- <a href="/posts">Posts</a>
278
- <button onClick={toggleTheme}>
279
- {t === 'dark' ? '☀️' : '🌙'}
280
- </button>
281
- </nav>
282
- <main>{children}</main>
283
- <footer>Built with ⬡ FNetro</footer>
284
- </div>
285
- )
286
- })
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
- **Override or remove the layout per page:**
196
+ **Per-page override:**
290
197
 
291
- ```tsx
292
- // Use a custom layout just for this page
293
- definePage({ path: '/landing', layout: FullscreenLayout, Page: ... })
198
+ ```ts
199
+ // Use a different layout
200
+ definePage({ path: '/landing', layout: LandingLayout, Page: ... })
294
201
 
295
- // Render without any layout (bare HTML)
296
- definePage({ path: '/embed', layout: false, Page: ... })
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 at any path. Full Hono API: routing, middleware, validators, streaming, WebSockets. API routes are registered before the page handler so `/api/*` is never caught by SPA navigation.
210
+ Mount raw Hono sub-routes. Full Hono API — REST, RPC, WebSocket, streaming.
304
211
 
305
- ```tsx
306
- // app/routes/api.ts
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 apiRoutes = defineApiRoute('/api', (app) => {
312
- // GET /api/health
313
- app.get('/health', (c) => c.json({ status: 'ok', ts: Date.now() }))
217
+ export const api = defineApiRoute('/api', (app) => {
218
+ app.get('/health', (c) =>
219
+ c.json({ status: 'ok', ts: Date.now() }),
220
+ )
314
221
 
315
- // GET /api/posts
316
- app.get('/posts', async (c) => {
317
- const posts = await db.posts.findAll()
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
- '/posts',
324
- zValidator('json', z.object({ title: z.string().min(1), body: z.string() })),
228
+ '/items',
229
+ zValidator('json', z.object({ name: z.string().min(1) })),
325
230
  async (c) => {
326
- const data = c.req.valid('json')
327
- const post = await db.posts.create(data)
328
- return c.json(post, 201)
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
- ### `defineMiddleware`
240
+ ## Loaders
340
241
 
341
- Works at app, group, or page level. Receives the Hono `Context` and a `next` function.
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
- // app/middleware/auth.ts
345
- import { defineMiddleware } from '@netrojs/fnetro/core'
346
-
347
- export const requireAuth = defineMiddleware(async (c, next) => {
348
- const token = c.req.header('Authorization')?.replace('Bearer ', '')
349
- const user = token ? verifyJwt(token) : null
350
- if (!user) return c.json({ error: 'Unauthorized' }, 401)
351
- c.set('user', user)
352
- await next()
353
- })
245
+ definePage({
246
+ path: '/dashboard',
354
247
 
355
- export const rateLimit = defineMiddleware(async (c, next) => {
356
- const ip = c.req.header('cf-connecting-ip') ?? 'unknown'
357
- if (await limiter.isLimited(ip)) return c.json({ error: 'Rate limited' }, 429)
358
- await next()
359
- })
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
- export const logger = defineMiddleware(async (c, next) => {
362
- const start = Date.now()
363
- await next()
364
- console.log(`${c.req.method} ${c.req.url} ${c.res.status} ${Date.now() - start}ms`)
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
- **Apply at every level:**
262
+ **Type-safe loaders:**
369
263
 
370
264
  ```ts
371
- createFNetro({
372
- middleware: [logger], // every request
373
- routes: [
374
- defineGroup({
375
- middleware: [requireAuth], // every route in group
376
- routes: [
377
- definePage({
378
- middleware: [rateLimit], // this page only
379
- Page: ...,
380
- }),
381
- ],
382
- }),
383
- ],
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
- ## Reactivity
390
-
391
- FNetro implements the complete Vue Reactivity API from scratch (~500 lines, no external dependencies). All functions work identically on server (SSR, no DOM) and client (live re-renders).
278
+ ## SEO
392
279
 
393
- ### `ref`
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
- import { ref } from '@netrojs/fnetro/core'
399
-
400
- const count = ref(0)
401
- count.value++ // triggers all watchers
402
- console.log(count.value) // 1
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
- const list = shallowRef<string[]>([])
409
- list.value.push('a') // won't trigger — shallow
410
- list.value = [...list.value, 'a'] // triggers — new reference
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
- **`triggerRef`** manually force-trigger a shallow ref after an internal mutation:
332
+ ### All SEO fields
414
333
 
415
- ```ts
416
- list.value.push('a')
417
- triggerRef(list) // force subscribers to re-run
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
- ### `reactive`
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
- state.user.name = 'Bob' // triggers any watcher that read state.user.name
435
- state.cart.items.push(item) // array mutations tracked
436
- ```
369
+ ### Server middleware
437
370
 
438
- **`shallowReactive`** tracks only top-level keys, not nested objects:
371
+ Hono middleware at three levels — global, group, and page.
439
372
 
440
373
  ```ts
441
- const form = shallowReactive({ name: '', email: '' })
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
- ### `computed`
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
- A lazily-evaluated, cached derived value. Re-evaluates only when its dependencies change.
401
+ Middleware can short-circuit by returning a `Response`:
449
402
 
450
403
  ```ts
451
- import { ref, computed } from '@netrojs/fnetro/core'
452
-
453
- const firstName = ref('Alice')
454
- const lastName = ref('Smith')
455
-
456
- // Read-only
457
- const fullName = computed(() => `${firstName.value} ${lastName.value}`)
458
- console.log(fullName.value) // 'Alice Smith'
459
-
460
- // Writable
461
- const name = computed({
462
- get: () => `${firstName.value} ${lastName.value}`,
463
- set: (v) => {
464
- const [f, l] = v.split(' ')
465
- firstName.value = f
466
- lastName.value = l
467
- },
468
- })
469
- name.value = 'Bob Jones'
470
- console.log(firstName.value) // 'Bob'
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
- ### `watch`
414
+ ### Client middleware
476
415
 
477
- Runs a callback when a source changes. Not immediate by default.
416
+ Runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** `boot()`.
478
417
 
479
418
  ```ts
480
- import { ref, watch } from '@netrojs/fnetro/core'
481
-
482
- const count = ref(0)
419
+ // client.ts
420
+ import { boot, useClientMiddleware, navigate } from '@netrojs/fnetro/client'
483
421
 
484
- // Single source
485
- watch(count, (newVal, oldVal) => {
486
- console.log(`${oldVal} → ${newVal}`)
422
+ // Analytics — fires after navigation completes
423
+ useClientMiddleware(async (url, next) => {
424
+ await next()
425
+ analytics.page({ url })
487
426
  })
488
427
 
489
- // Multiple sources
490
- const a = ref(1), b = ref(2)
491
- watch([a, b], ([newA, newB], [oldA, oldB]) => {
492
- console.log(newA, newB)
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
- // Options
496
- watch(count, (n, o, cleanup) => {
497
- const timer = setTimeout(() => sync(n), 500)
498
- cleanup(() => clearTimeout(timer)) // runs before the next invocation
499
- }, {
500
- immediate: true, // fire immediately with the current value
501
- deep: true, // deep equality check (objects)
502
- once: true, // auto-stop after first invocation
437
+ // Loading indicator
438
+ useClientMiddleware(async (url, next) => {
439
+ NProgress.start()
440
+ try { await next() }
441
+ finally { NProgress.done() }
503
442
  })
504
443
 
505
- // Stop watching
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
- ### `watchEffect`
449
+ ---
513
450
 
514
- Like `watch` but auto-tracks every reactive value read inside the function body. Runs immediately.
451
+ ## SolidJS reactivity
515
452
 
516
- ```ts
517
- import { ref, reactive, watchEffect } from '@netrojs/fnetro/core'
453
+ Use SolidJS primitives directly — no FNetro wrappers.
518
454
 
519
- const user = reactive({ name: 'Alice' })
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
- // Automatically tracks user.name and theme.value
523
- const stop = watchEffect(() => {
524
- document.title = `${user.name} ${theme.value} mode`
525
- })
457
+ ```tsx
458
+ import { createSignal, createMemo, createEffect, For } from 'solid-js'
459
+ import { definePage } from '@netrojs/fnetro'
526
460
 
527
- user.name = 'Bob' // re-runs
528
- theme.value = 'light' // re-runs
461
+ const [count, setCount] = createSignal(0)
462
+ const doubled = createMemo(() => count() * 2)
529
463
 
530
- stop() // remove the effect
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
- Groups effects together so they can all be stopped at once. Useful for feature-level cleanup (e.g. when a modal closes, stop all effects created inside it).
538
-
539
- ```ts
540
- import { ref, watchEffect, effectScope, onScopeDispose } from '@netrojs/fnetro/core'
480
+ ```tsx
481
+ import { createStore, produce } from 'solid-js/store'
541
482
 
542
- const scope = effectScope()
483
+ interface Todo { id: number; text: string; done: boolean }
484
+ const [todos, setTodos] = createStore<{ items: Todo[] }>({ items: [] })
543
485
 
544
- scope.run(() => {
545
- // These effects are tied to `scope`
546
- watchEffect(() => { ... })
547
- watchEffect(() => { ... })
486
+ function toggle(id: number) {
487
+ setTodos('items', t => t.id === id, produce(t => { t.done = !t.done }))
488
+ }
548
489
 
549
- // Runs when scope.stop() is called
550
- onScopeDispose(() => cleanup())
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
- ### Helpers
560
-
561
- ```ts
562
- import {
563
- isRef, // (v) → v is Ref<unknown>
564
- isReactive, // (v) → boolean
565
- isReadonly, // (v) → boolean
566
- unref, // (r) → unwraps a Ref or returns the value as-is
567
- toRef, // (object, key) → a Ref linked to object[key]
568
- toRefs, // (object) → { [key]: Ref } — reactive-safe destructure
569
- markRaw, // (object) → never proxied (e.g. third-party class instances)
570
- toRaw, // (proxy) → the original unwrapped object
571
- readonly, // (object) → readonly proxy — mutations warn in dev
572
- } from '@netrojs/fnetro/core'
573
-
574
- // toRefs — destructure a reactive object without losing reactivity
575
- const pos = reactive({ x: 0, y: 0 })
576
- const { x, y } = toRefs(pos)
577
- x.value = 10 // mutates pos.x
578
- pos.x = 20 // x.value reads 20
579
-
580
- // markRaw — prevent third-party instances from being proxied
581
- const chart = markRaw(new Chart(canvas, config))
582
- state.chart = chart // stored as-is, not wrapped in a Proxy
583
- ```
584
-
585
- ---
511
+ ## Navigation
586
512
 
587
- ### Component hooks
513
+ ### Links — automatic interception
588
514
 
589
- These are the bridge between signals and JSX components. On the server they return plain values (no reactivity needed). On the client they're wired to `hono/jsx/dom` to schedule re-renders.
590
-
591
- #### `use(source)` — subscribe to any Ref or getter
515
+ Any `<a href="...">` pointing to a registered route is intercepted automatically. No special component needed.
592
516
 
593
517
  ```tsx
594
- import { ref, computed, use } from '@netrojs/fnetro/core'
595
-
596
- // Module-level shared across all components and page navigations
597
- const cartCount = ref(0)
598
- const doubled = computed(() => cartCount.value * 2)
599
-
600
- function CartIcon() {
601
- const count = use(cartCount) // re-renders when cartCount changes
602
- const dbl = use(doubled) // re-renders when doubled changes
603
- const total = use(() => cartCount.value * 9.99) // getter — auto-computed
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
- #### `useLocalRef(init)` — component-scoped Ref
524
+ ### Programmatic navigation
610
525
 
611
- ```tsx
612
- import { useLocalRef, use } from '@netrojs/fnetro/core'
613
-
614
- function Toggle() {
615
- const open = useLocalRef(false) // created once, lost on unmount
616
- const isOpen = use(open)
617
- return (
618
- <div>
619
- <button onClick={() => open.value = !isOpen}>
620
- {isOpen ? 'Close' : 'Open'}
621
- </button>
622
- {isOpen && <div class="panel">...</div>}
623
- </div>
624
- )
625
- }
626
- ```
627
-
628
- #### `useLocalReactive(init)` — component-scoped reactive object
526
+ ```ts
527
+ import { navigate } from '@netrojs/fnetro/client'
629
528
 
630
- ```tsx
631
- import { useLocalReactive } from '@netrojs/fnetro/core'
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
- function LoginForm() {
634
- const form = useLocalReactive({ email: '', password: '', loading: false })
534
+ ### Prefetch
635
535
 
636
- async function submit() {
637
- form.loading = true
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
- return (
643
- <form onSubmit={submit}>
644
- <input value={form.email} onInput={(e: any) => form.email = e.target.value} />
645
- <input type="password" value={form.password} onInput={(e: any) => form.password = e.target.value} />
646
- <button disabled={form.loading}>{form.loading ? 'Signing in…' : 'Sign in'}</button>
647
- </form>
648
- )
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
- ## Routing
546
+ ## Asset handling
655
547
 
656
- ### Dynamic segments
548
+ ### Development
657
549
 
658
- | Pattern | Example URL | `params` |
659
- |---|---|---|
660
- | `/posts/[slug]` | `/posts/hello-world` | `{ slug: 'hello-world' }` |
661
- | `/files/[...path]` | `/files/a/b/c.pdf` | `{ path: 'a/b/c.pdf' }` |
662
- | `/[org]/[repo]` | `/acme/backend` | `{ org: 'acme', repo: 'backend' }` |
550
+ `@hono/vite-dev-server` injects Vite's dev client and HMR scripts automatically. No asset config needed.
663
551
 
664
- Params are available in `loader` via `c.req.param('key')` and in `Page` via the `params` prop.
552
+ ### Production
665
553
 
666
- ```tsx
667
- definePage({
668
- path: '/posts/[slug]',
669
- loader: (c) => {
670
- const slug = c.req.param('slug')
671
- return { post: db.findBySlug(slug) }
672
- },
673
- Page({ post, params }) {
674
- // params.slug is also available here
675
- return <article><h1>{post.title}</h1></article>
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
- ### Route groups
567
+ **Manual override** (edge runtimes / CDN-hosted assets):
681
568
 
682
- Prefix, layout, and middleware are inherited by all routes in the group:
683
-
684
- ```tsx
569
+ ```ts
685
570
  createFNetro({
686
- layout: RootLayout,
687
- routes: [
688
- apiRoutes, // defineApiRoute — registered before the page handler
689
- adminGroup, // defineGroup — layout + middleware override
690
- home,
691
- posts,
692
- postDetail,
693
- ],
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
- ### Layout overrides
698
-
699
- Priority order (highest wins): **page-level** → **group-level** → **app-level**
700
-
701
- ```tsx
702
- const adminGroup = defineGroup({
703
- prefix: '/admin',
704
- layout: AdminLayout, // overrides RootLayout for all /admin/* routes
705
- routes: [
706
- definePage({
707
- path: '/secret',
708
- layout: false, // no layout at all — bare HTML response
709
- Page: () => <div>secret</div>,
710
- }),
711
- ],
712
- })
713
- ```
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
- ## Server
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 { createFNetro } from '@netrojs/fnetro/server'
585
+ import { serve } from '@netrojs/fnetro/server'
725
586
 
726
- const fnetro = createFNetro({
727
- layout: RootLayout,
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
- // Access the raw Hono instance for anything not covered by createFNetro
734
- fnetro.app.onError((err, c) => c.json({ error: err.message }, 500))
735
- fnetro.app.use('/healthz', (c) => c.text('ok'))
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
- **`AppConfig` options:**
600
+ **Edge runtimes** (Cloudflare Workers, Deno Deploy, Fastly, etc.):
739
601
 
740
- | Option | Type | Description |
741
- |---|---|---|
742
- | `layout` | `LayoutDef` | Default layout for all pages |
743
- | `middleware` | `FNetroMiddleware[]` | Global middleware, applied to every request |
744
- | `routes` | `(PageDef \| GroupDef \| ApiRouteDef)[]` | Route definitions |
745
- | `notFound` | `() => AnyJSX` | Custom 404 page |
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
- ### `serve`
610
+ ---
750
611
 
751
- Starts the HTTP server. Auto-detects the runtime unless `runtime` is specified.
612
+ ## Vite plugin
752
613
 
753
614
  ```ts
754
- import { serve } from '@netrojs/fnetro/server'
755
- import { fnetro } from './app'
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
- // Auto-detect (works for Node, Bun, Deno)
758
- await serve({ app: fnetro, port: 3000 })
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
- // Explicit
761
- await serve({ app: fnetro, port: 8080, runtime: 'bun', hostname: '127.0.0.1' })
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
- **`ServeOptions`:**
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
- **Edge runtimes** — don't call `serve()`, just export the handler:
775
-
776
- ```ts
777
- // Cloudflare Workers / generic WinterCG
778
- export default { fetch: fnetro.handler }
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
- ### Runtime detection
652
+ ---
782
653
 
783
- ```ts
784
- import { detectRuntime } from '@netrojs/fnetro/server'
654
+ ## Project structure
785
655
 
786
- const runtime = detectRuntime()
787
- // → 'bun' | 'deno' | 'node' | 'edge' | 'unknown'
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
- Detection order: `Bun` global `Deno` global → `process.versions.node` → `'edge'`.
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
- ## Client
691
+ ---
795
692
 
796
- ### `boot`
693
+ ## TypeScript
797
694
 
798
- Call once in `client.ts`. Reads `window.__FNETRO_STATE__` injected by the server and hydrates the page — no extra network request.
695
+ `tsconfig.json` for any FNetro project:
799
696
 
800
- ```ts
801
- // client.ts
802
- import { boot } from '@netrojs/fnetro/client'
803
- import { RootLayout } from './app/layouts'
804
- import home from './app/routes/home'
805
- import posts from './app/routes/posts'
806
-
807
- boot({
808
- layout: RootLayout,
809
- routes: [home, posts],
810
- prefetchOnHover: true, // default: true
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
- Routes in `boot()` must match the routes in `createFNetro()` exactly (same array, same order). The client uses them for path matching during SPA navigation.
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
- ### `navigate`
721
+ ## create-fnetro CLI
819
722
 
820
- Programmatic SPA navigation.
723
+ Scaffold a new project interactively or from CI:
821
724
 
822
- ```ts
823
- import { navigate } from '@netrojs/fnetro/client'
725
+ ```bash
726
+ npm create @netrojs/fnetro@latest [project-name] [flags]
727
+ ```
824
728
 
825
- // Push a new history entry and navigate
826
- await navigate('/posts/new-post')
729
+ ### Interactive mode
827
730
 
828
- // Replace current entry (no back button entry)
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
- Plain `<a>` tags are intercepted automatically — no `<Link>` component required:
836
-
837
- ```html
838
- <a href="/posts/hello">Normal anchor SPA handled</a>
839
- <a href="/download.zip" data-no-spa>Force full navigation</a>
840
- <a href="https://external.com" rel="external">External link</a>
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
- Warms the SPA fetch cache for a URL. By default called automatically on `mouseover`. Call manually for more aggressive prefetching (e.g. on `mousedown` or when an item enters the viewport).
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
- ```ts
850
- import { prefetch } from '@netrojs/fnetro/client'
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
- // Prefetch on mousedown — faster than waiting for click
853
- button.addEventListener('mousedown', () => prefetch('/posts/next'))
766
+ ### Templates
854
767
 
855
- // Prefetch a list of likely-next pages on page load
856
- const likelyNextRoutes = ['/posts/hello', '/about']
857
- likelyNextRoutes.forEach(prefetch)
768
+ **`minimal`** production-ready starter:
858
769
  ```
859
-
860
- Disable automatic hover prefetch:
861
- ```ts
862
- boot({ prefetchOnHover: false, ... })
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
- ### Lifecycle hooks
868
-
869
- ```ts
870
- import { onBeforeNavigate, onAfterNavigate } from '@netrojs/fnetro/client'
871
-
872
- // Runs before every SPA navigation — async, awaited
873
- // Throw any error to cancel the navigation
874
- const stopBefore = onBeforeNavigate(async (url) => {
875
- if (formHasUnsavedChanges) {
876
- const confirmed = await showConfirmDialog('Leave page?')
877
- if (!confirmed) throw new Error('navigation cancelled')
878
- }
879
- })
880
-
881
- // Runs after navigation completes — including the initial boot
882
- const stopAfter = onAfterNavigate((url) => {
883
- analytics.page(url)
884
- window.posthog?.capture('$pageview', { url })
885
- })
886
-
887
- // Remove a listener
888
- stopBefore()
889
- stopAfter()
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
- ## Vite plugin
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
- `fnetroVitePlugin()` produces both bundles from a single `vite build` command.
797
+ ---
897
798
 
898
- ```ts
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
- export default defineConfig({
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
- **`FNetroPluginOptions`:**
803
+ **Functions:**
926
804
 
927
- | Option | Default | Description |
805
+ | Export | Signature | Description |
928
806
  |---|---|---|
929
- | `serverEntry` | `'server.ts'` | Production server entry |
930
- | `clientEntry` | `'client.ts'` | Browser SPA entry |
931
- | `serverOutDir` | `'dist/server'` | Output dir for server bundle |
932
- | `clientOutDir` | `'dist/assets'` | Output dir for client bundle |
933
- | `serverExternal` | `[]` | Packages excluded from the server bundle (always excludes `node:*` and `@hono/node-server`) |
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
- **Build output:**
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
- ## Dev server
817
+ **Types:** `AppConfig` · `PageDef<T>` · `GroupDef` · `LayoutDef` · `ApiRouteDef` · `Route` · `PageProps<T>` · `LayoutProps` · `SEOMeta` · `HonoMiddleware` · `LoaderCtx` · `ClientMiddleware` · `ResolvedRoute` · `CompiledPath`
948
818
 
949
- `@hono/vite-dev-server` routes HTTP requests directly through your FNetro app inside the Vite process. No `dist/` directory needed — changes to `.ts` and `.tsx` files are reflected instantly.
819
+ ---
950
820
 
951
- ```bash
952
- # Node
953
- vite
821
+ ### `@netrojs/fnetro/server`
954
822
 
955
- # Bun (uses Bun's runtime instead of Node for Vite internals)
956
- bun --bun vite --host
823
+ **Functions:**
957
824
 
958
- # Deno
959
- deno run -A npm:vite
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
- The `entry` option must point to a file that exports the Hono fetch handler as its default export:
832
+ **`FNetroOptions`** (extends `AppConfig`):
963
833
 
964
834
  ```ts
965
- // app.ts
966
- export const fnetro = createFNetro({ ... })
967
- export default fnetro.handler // this is what @hono/vite-dev-server imports
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
- // app/store.ts
978
- import { ref, reactive, computed, watch } from '@netrojs/fnetro/core'
979
-
980
- // ── Theme ────────────────────────────────────────────────────────────────────
981
- export const theme = ref<'dark' | 'light'>('dark')
982
- export const toggleTheme = () => {
983
- theme.value = theme.value === 'dark' ? 'light' : 'dark'
984
- }
985
-
986
- // Persist to localStorage on the client
987
- if (typeof window !== 'undefined') {
988
- const saved = localStorage.getItem('theme') as 'dark' | 'light' | null
989
- if (saved) theme.value = saved
990
- watch(theme, (t) => localStorage.setItem('theme', t))
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
- // ── Auth ─────────────────────────────────────────────────────────────────────
994
- export const user = reactive({
995
- id: null as string | null,
996
- name: '',
997
- role: 'guest' as 'guest' | 'user' | 'admin',
998
- })
999
- export const isLoggedIn = computed(() => user.id !== null)
1000
- export const isAdmin = computed(() => user.role === 'admin')
1001
-
1002
- // ── Cart ─────────────────────────────────────────────────────────────────────
1003
- export interface CartItem { id: string; name: string; qty: number; price: number }
1004
- export const cart = reactive<{ items: CartItem[] }>({ items: [] })
1005
- export const cartCount = computed(() => cart.items.reduce((s, i) => s + i.qty, 0))
1006
- export const cartTotal = computed(() => cart.items.reduce((s, i) => s + i.qty * i.price, 0))
1007
-
1008
- export function addToCart(item: Omit<CartItem, 'qty'>) {
1009
- const existing = cart.items.find((i) => i.id === item.id)
1010
- if (existing) { existing.qty++; return }
1011
- cart.items.push({ ...item, qty: 1 })
1012
- }
858
+ **`ServeOptions`:**
1013
859
 
1014
- export function removeFromCart(id: string) {
1015
- cart.items = cart.items.filter((i) => i.id !== id)
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
- Using the store in a layout or page:
870
+ **`FNetroPluginOptions`:**
1020
871
 
1021
- ```tsx
1022
- import { use } from '@netrojs/fnetro/core'
1023
- import { cartCount, isLoggedIn, user, theme } from '../store'
1024
-
1025
- function NavBar({ url }: { url: string }) {
1026
- const count = use(cartCount)
1027
- const loggedIn = use(isLoggedIn)
1028
- const name = use(() => user.name)
1029
- const t = use(theme)
1030
-
1031
- return (
1032
- <nav class={`nav theme-${t}`}>
1033
- <a href="/">Home</a>
1034
- {loggedIn
1035
- ? <span>👤 {name}</span>
1036
- : <a href="/login">Sign in</a>
1037
- }
1038
- <a href="/cart">🛒 {count > 0 && <span class="badge">{count}</span>}</a>
1039
- </nav>
1040
- )
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
- ## TypeScript
885
+ ### `@netrojs/fnetro/client`
886
+
887
+ **Functions:**
1047
888
 
1048
- Page props are inferred directly from the loader return type — no annotation needed:
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
- definePage({
1052
- path: '/user/[id]',
1053
- async loader(c) {
1054
- const user = await getUser(c.req.param('id'))
1055
- return { user, role: 'admin' as const }
1056
- },
1057
- Page({ user, role, url, params }) {
1058
- // ^^^^ User ^^^^ 'admin' — fully inferred
1059
- },
1060
- })
899
+ interface BootOptions extends AppConfig {
900
+ prefetchOnHover?: boolean // default: true
901
+ }
1061
902
  ```
1062
903
 
1063
- Explicit typing when the loader is defined separately:
904
+ **`NavigateOptions`:**
1064
905
 
1065
906
  ```ts
1066
- interface PageData {
1067
- user: User
1068
- role: 'admin' | 'member'
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
- **`tsconfig.json` for a FNetro project:**
913
+ **`ClientMiddleware`:**
1079
914
 
1080
- ```json
1081
- {
1082
- "compilerOptions": {
1083
- "target": "ESNext",
1084
- "module": "ESNext",
1085
- "moduleResolution": "bundler",
1086
- "lib": ["ESNext", "DOM"],
1087
- "jsx": "react-jsx",
1088
- "jsxImportSource": "hono/jsx",
1089
- "strict": true,
1090
- "skipLibCheck": true,
1091
- "noEmit": true,
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
- ## Runtime support
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
- | Runtime | `dev` | `build` | `start` | Notes |
1107
- |---|---|---|---|---|
1108
- | **Node.js 18+** | `vite` | `vite build` | `node dist/server/server.js` | `@hono/node-server` required |
1109
- | **Bun** | `bun --bun vite --host` | `bun --bun vite build` | `bun dist/server/server.js` | Native Bun adapter for dev server |
1110
- | **Deno** | `deno run -A npm:vite` | `deno run -A npm:vite build` | `deno run -A dist/server/server.js` | |
1111
- | **Cloudflare Workers** | `wrangler dev` | `vite build` | `wrangler deploy` | Export `fnetro.handler` as default |
1112
- | **Generic WinterCG** | `vite` | `vite build` | — | Export `fnetro.handler` as default |
932
+ # Build both packages
933
+ npm run build
1113
934
 
1114
- `serve()` auto-detects the runtime at startup. Pass `runtime` explicitly if auto-detection fails (e.g. when a bundler strips runtime globals):
935
+ # Typecheck both packages
936
+ npm run typecheck
1115
937
 
1116
- ```ts
1117
- await serve({ app: fnetro, port: 3000, runtime: 'bun' })
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
- ## API reference
967
+ ## Publishing & releases
1123
968
 
1124
- ### `@netrojs/fnetro/core`
969
+ This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
1125
970
 
1126
- **Reactivity**
971
+ ### Day-to-day workflow
1127
972
 
1128
- | Symbol | Signature | Description |
1129
- |---|---|---|
1130
- | `ref` | `<T>(value: T) → Ref<T>` | Reactive primitive |
1131
- | `shallowRef` | `<T>(value: T) → Ref<T>` | Reactive at top level only |
1132
- | `triggerRef` | `(r: Ref) → void` | Force-trigger a shallow ref |
1133
- | `isRef` | `(v) → v is Ref` | Type guard |
1134
- | `unref` | `<T>(r: T \| Ref<T>) → T` | Unwrap a ref |
1135
- | `reactive` | `<T extends object>(t: T) → T` | Deep reactive proxy |
1136
- | `shallowReactive` | `<T extends object>(t: T) → T` | Shallow reactive proxy |
1137
- | `readonly` | `<T extends object>(t: T) → Readonly<T>` | Readonly proxy |
1138
- | `computed` | `<T>(getter) → ComputedRef<T>` | Derived cached value |
1139
- | `computed` | `<T>({ get, set }) → WritableComputedRef<T>` | Writable computed |
1140
- | `watch` | `(source, cb, opts?) → StopHandle` | Reactive watcher |
1141
- | `watchEffect` | `(fn, opts?) → StopHandle` | Auto-tracked side effect |
1142
- | `effect` | `(fn) → StopHandle` | Raw reactive effect |
1143
- | `effectScope` | `() → EffectScope` | Grouped effect lifecycle |
1144
- | `getCurrentScope` | `() → EffectScope \| undefined` | Current active scope |
1145
- | `onScopeDispose` | `(fn) → void` | Register scope cleanup |
1146
- | `toRef` | `(obj, key) → Ref` | Ref linked to object key |
1147
- | `toRefs` | `(obj) → { [k]: Ref }` | Reactive-safe destructure |
1148
- | `markRaw` | `<T>(v: T) → T` | Opt out of reactivity |
1149
- | `toRaw` | `<T>(proxy: T) → T` | Unwrap proxy to original |
1150
- | `isReactive` | `(v) → boolean` | |
1151
- | `isReadonly` | `(v) → boolean` | |
1152
-
1153
- **Component hooks**
1154
-
1155
- | Symbol | Signature | Description |
1156
- |---|---|---|
1157
- | `use` | `<T>(source: Ref<T> \| (() => T)) → T` | Subscribe in JSX component |
1158
- | `useLocalRef` | `<T>(init: T) → Ref<T>` | Component-scoped ref |
1159
- | `useLocalReactive` | `<T>(init: T) → T` | Component-scoped reactive object |
973
+ **1. Make changes** to `packages/fnetro` and/or `packages/create-fnetro`.
1160
974
 
1161
- **Route definitions**
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
- | Symbol | Description |
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
- ### `@netrojs/fnetro/server`
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
- | Symbol | Description |
1174
- |---|---|
1175
- | `createFNetro(config)` | Assemble the Hono app → `FNetroApp` |
1176
- | `serve(opts)` | Start the HTTP server (auto-detects runtime) |
1177
- | `detectRuntime()` | Returns `'node' \| 'bun' \| 'deno' \| 'edge' \| 'unknown'` |
1178
- | `fnetroVitePlugin(opts?)` | Vite plugin — produces server + client bundles |
988
+ ### Manual release
1179
989
 
1180
- ### `@netrojs/fnetro/client`
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
- | Symbol | Description |
1000
+ | Secret | Description |
1183
1001
  |---|---|
1184
- | `boot(options)` | Mount the SPA reads `__FNETRO_STATE__`, no refetch |
1185
- | `navigate(to, opts?)` | Programmatic SPA navigation |
1186
- | `prefetch(url)` | Warm the fetch cache for a URL |
1187
- | `onBeforeNavigate(fn)` | Hook — runs before each navigation, can cancel |
1188
- | `onAfterNavigate(fn)` | Hook — runs after each navigation + initial boot |
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://github.com/netrosolutions)
1009
+ MIT © [Netro Solutions](https://netrosolutions.com)