@netrojs/fnetro 0.2.0 → 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 +536 -324
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FNetro
|
|
2
2
|
|
|
3
|
-
> Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+** —
|
|
3
|
+
> Full-stack [Hono](https://hono.dev) framework powered by **SolidJS v1.9+** —
|
|
4
|
+
> SSR · SPA · SEO · server & client middleware · multi-runtime · TypeScript-first.
|
|
4
5
|
|
|
5
|
-
[](https://github.com/netrosolutions/fnetro/actions/workflows/ci.yml)
|
|
7
|
+
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
8
|
+
[](https://www.npmjs.com/package/@netrojs/create-fnetro)
|
|
6
9
|
[](./LICENSE)
|
|
7
10
|
|
|
8
11
|
---
|
|
9
12
|
|
|
10
13
|
## Table of contents
|
|
11
14
|
|
|
12
|
-
1. [
|
|
13
|
-
2. [
|
|
14
|
-
3. [
|
|
15
|
-
4. [
|
|
16
|
-
5. [Routing](#routing)
|
|
15
|
+
1. [Packages](#packages)
|
|
16
|
+
2. [Quick start](#quick-start)
|
|
17
|
+
3. [How it works](#how-it-works)
|
|
18
|
+
4. [Routing](#routing)
|
|
17
19
|
- [definePage](#definepage)
|
|
18
20
|
- [defineGroup](#definegroup)
|
|
19
21
|
- [defineLayout](#definelayout)
|
|
20
22
|
- [defineApiRoute](#defineapiroute)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
5. [Loaders](#loaders)
|
|
24
|
+
6. [SEO](#seo)
|
|
25
|
+
7. [Middleware](#middleware)
|
|
24
26
|
- [Server middleware](#server-middleware)
|
|
25
27
|
- [Client middleware](#client-middleware)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
|
31
34
|
14. [TypeScript](#typescript)
|
|
32
|
-
15. [
|
|
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)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Packages
|
|
43
|
+
|
|
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 |
|
|
33
48
|
|
|
34
49
|
---
|
|
35
50
|
|
|
@@ -42,76 +57,43 @@ npm install
|
|
|
42
57
|
npm run dev
|
|
43
58
|
```
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
## Installation
|
|
60
|
+
Or with other package managers:
|
|
48
61
|
|
|
49
62
|
```bash
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Dev deps (build toolchain)
|
|
54
|
-
npm install -D vite vite-plugin-solid @hono/vite-dev-server typescript
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
For Node.js runtime add:
|
|
58
|
-
```bash
|
|
59
|
-
npm install -D @hono/node-server
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Peer dependencies
|
|
63
|
-
|
|
64
|
-
| Package | Version | Required? |
|
|
65
|
-
|---|---|---|
|
|
66
|
-
| `solid-js` | `>=1.9.11` | ✅ Always |
|
|
67
|
-
| `hono` | `>=4.0.0` | ✅ Always |
|
|
68
|
-
| `vite` | `>=5.0.0` | Build only |
|
|
69
|
-
| `vite-plugin-solid` | `>=2.11.11` | Build only |
|
|
70
|
-
|
|
71
|
-
---
|
|
72
|
-
|
|
73
|
-
## Project structure
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
my-app/
|
|
77
|
-
├── app.ts # Shared FNetro app — used by dev server and server.ts
|
|
78
|
-
├── server.ts # Production server entry — calls serve()
|
|
79
|
-
├── client.ts # Browser entry — calls boot()
|
|
80
|
-
├── app/
|
|
81
|
-
│ ├── layouts.tsx # defineLayout() — shared nav/footer shell
|
|
82
|
-
│ └── routes/
|
|
83
|
-
│ ├── home.tsx # definePage({ path: '/' })
|
|
84
|
-
│ ├── about.tsx # definePage({ path: '/about' })
|
|
85
|
-
│ └── api.ts # defineApiRoute('/api', ...)
|
|
86
|
-
├── public/
|
|
87
|
-
│ └── style.css # Static assets served at /
|
|
88
|
-
├── vite.config.ts
|
|
89
|
-
└── tsconfig.json
|
|
63
|
+
pnpm create @netrojs/fnetro@latest my-app
|
|
64
|
+
bun create @netrojs/fnetro my-app
|
|
65
|
+
deno run -A npm:create-fnetro my-app
|
|
90
66
|
```
|
|
91
67
|
|
|
92
68
|
---
|
|
93
69
|
|
|
94
|
-
##
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
70
|
+
## How it works
|
|
71
|
+
|
|
72
|
+
```
|
|
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)
|
|
115
97
|
```
|
|
116
98
|
|
|
117
99
|
---
|
|
@@ -120,77 +102,68 @@ Request
|
|
|
120
102
|
|
|
121
103
|
### `definePage`
|
|
122
104
|
|
|
123
|
-
Define a
|
|
105
|
+
Define a route with an optional SSR loader, SEO config, and a SolidJS component.
|
|
124
106
|
|
|
125
107
|
```tsx
|
|
126
|
-
// app/routes/
|
|
108
|
+
// app/routes/post.tsx
|
|
127
109
|
import { definePage } from '@netrojs/fnetro'
|
|
128
110
|
|
|
129
111
|
export default definePage({
|
|
130
|
-
path: '/',
|
|
112
|
+
path: '/posts/[slug]',
|
|
131
113
|
|
|
132
|
-
// Optional server-side data loader
|
|
133
114
|
loader: async (c) => {
|
|
134
|
-
const
|
|
135
|
-
|
|
115
|
+
const slug = c.req.param('slug')
|
|
116
|
+
const post = await db.posts.findBySlug(slug)
|
|
117
|
+
if (!post) return c.notFound()
|
|
118
|
+
return { post }
|
|
136
119
|
},
|
|
137
120
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}),
|
|
143
127
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
<ul>
|
|
148
|
-
{items.map(item => <li>{item.name}</li>)}
|
|
149
|
-
</ul>
|
|
150
|
-
)
|
|
128
|
+
Page({ post, url, params }) {
|
|
129
|
+
return <article>{post.title}</article>
|
|
151
130
|
},
|
|
152
131
|
})
|
|
153
132
|
```
|
|
154
133
|
|
|
155
|
-
**
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
// matches /posts/hello-world → params.slug = 'hello-world'
|
|
159
|
-
definePage({ path: '/posts/[slug]', ... })
|
|
134
|
+
**Path patterns:**
|
|
160
135
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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)* |
|
|
164
141
|
|
|
165
142
|
---
|
|
166
143
|
|
|
167
144
|
### `defineGroup`
|
|
168
145
|
|
|
169
|
-
Group routes under a shared prefix, layout, and middleware.
|
|
146
|
+
Group routes under a shared URL prefix, layout, and middleware.
|
|
170
147
|
|
|
171
148
|
```ts
|
|
172
149
|
import { defineGroup } from '@netrojs/fnetro'
|
|
173
|
-
import { requireAuth } from './middleware/auth'
|
|
174
|
-
import { AdminLayout } from './layouts'
|
|
175
|
-
import dashboard from './routes/admin/dashboard'
|
|
176
|
-
import users from './routes/admin/users'
|
|
177
150
|
|
|
178
151
|
export const adminGroup = defineGroup({
|
|
179
152
|
prefix: '/admin',
|
|
180
|
-
layout: AdminLayout,
|
|
181
|
-
middleware: [requireAuth],
|
|
182
|
-
routes: [dashboard, users],
|
|
153
|
+
layout: AdminLayout, // optional — overrides app default
|
|
154
|
+
middleware: [requireAuth, auditLog],
|
|
155
|
+
routes: [dashboard, users, settings],
|
|
183
156
|
})
|
|
184
157
|
```
|
|
185
158
|
|
|
186
|
-
Groups
|
|
159
|
+
Groups nest arbitrarily:
|
|
187
160
|
|
|
188
161
|
```ts
|
|
189
162
|
defineGroup({
|
|
190
163
|
prefix: '/api',
|
|
191
164
|
routes: [
|
|
192
|
-
defineGroup({ prefix: '/v1', routes: [
|
|
193
|
-
defineGroup({ prefix: '/v2', routes: [
|
|
165
|
+
defineGroup({ prefix: '/v1', routes: [v1] }),
|
|
166
|
+
defineGroup({ prefix: '/v2', routes: [v2] }),
|
|
194
167
|
],
|
|
195
168
|
})
|
|
196
169
|
```
|
|
@@ -199,18 +172,18 @@ defineGroup({
|
|
|
199
172
|
|
|
200
173
|
### `defineLayout`
|
|
201
174
|
|
|
202
|
-
|
|
175
|
+
Wrap every page with a shared shell (nav, footer, providers).
|
|
203
176
|
|
|
204
177
|
```tsx
|
|
205
178
|
import { defineLayout } from '@netrojs/fnetro'
|
|
206
179
|
import { createSignal } from 'solid-js'
|
|
207
180
|
|
|
208
|
-
|
|
181
|
+
// Module-level signal — persists across SPA navigations
|
|
182
|
+
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
|
209
183
|
|
|
210
184
|
export const RootLayout = defineLayout(({ children, url, params }) => (
|
|
211
185
|
<div class="app">
|
|
212
|
-
<nav
|
|
213
|
-
<a href="/" class="logo">My App</a>
|
|
186
|
+
<nav>
|
|
214
187
|
<a href="/" class={url === '/' ? 'active' : ''}>Home</a>
|
|
215
188
|
<a href="/about" class={url === '/about' ? 'active' : ''}>About</a>
|
|
216
189
|
</nav>
|
|
@@ -220,21 +193,21 @@ export const RootLayout = defineLayout(({ children, url, params }) => (
|
|
|
220
193
|
))
|
|
221
194
|
```
|
|
222
195
|
|
|
223
|
-
**Per-page
|
|
196
|
+
**Per-page override:**
|
|
224
197
|
|
|
225
198
|
```ts
|
|
226
|
-
// Use a different layout
|
|
199
|
+
// Use a different layout
|
|
227
200
|
definePage({ path: '/landing', layout: LandingLayout, Page: ... })
|
|
228
201
|
|
|
229
|
-
//
|
|
230
|
-
definePage({ path: '/embed',
|
|
202
|
+
// Disable layout entirely
|
|
203
|
+
definePage({ path: '/embed', layout: false, Page: ... })
|
|
231
204
|
```
|
|
232
205
|
|
|
233
206
|
---
|
|
234
207
|
|
|
235
208
|
### `defineApiRoute`
|
|
236
209
|
|
|
237
|
-
Mount raw Hono routes
|
|
210
|
+
Mount raw Hono sub-routes. Full Hono API — REST, RPC, WebSocket, streaming.
|
|
238
211
|
|
|
239
212
|
```ts
|
|
240
213
|
import { defineApiRoute } from '@netrojs/fnetro'
|
|
@@ -242,28 +215,23 @@ import { zValidator } from '@hono/zod-validator'
|
|
|
242
215
|
import { z } from 'zod'
|
|
243
216
|
|
|
244
217
|
export const api = defineApiRoute('/api', (app) => {
|
|
245
|
-
app.get('/health', (c) =>
|
|
218
|
+
app.get('/health', (c) =>
|
|
219
|
+
c.json({ status: 'ok', ts: Date.now() }),
|
|
220
|
+
)
|
|
246
221
|
|
|
247
222
|
app.get('/users/:id', async (c) => {
|
|
248
223
|
const user = await db.users.find(c.req.param('id'))
|
|
249
|
-
|
|
250
|
-
return c.json(user)
|
|
224
|
+
return user ? c.json(user) : c.json({ error: 'not found' }, 404)
|
|
251
225
|
})
|
|
252
226
|
|
|
253
227
|
app.post(
|
|
254
228
|
'/items',
|
|
255
229
|
zValidator('json', z.object({ name: z.string().min(1) })),
|
|
256
230
|
async (c) => {
|
|
257
|
-
const
|
|
258
|
-
const item = await db.items.create(body)
|
|
231
|
+
const item = await db.items.create(c.req.valid('json'))
|
|
259
232
|
return c.json(item, 201)
|
|
260
233
|
},
|
|
261
234
|
)
|
|
262
|
-
|
|
263
|
-
// WebSocket example
|
|
264
|
-
app.get('/ws', upgradeWebSocket(() => ({
|
|
265
|
-
onMessage(e, ws) { ws.send(`Echo: ${e.data}`) },
|
|
266
|
-
})))
|
|
267
235
|
})
|
|
268
236
|
```
|
|
269
237
|
|
|
@@ -271,45 +239,37 @@ export const api = defineApiRoute('/api', (app) => {
|
|
|
271
239
|
|
|
272
240
|
## Loaders
|
|
273
241
|
|
|
274
|
-
Loaders run **server
|
|
275
|
-
The return value is serialized to JSON and passed to the Page component as props.
|
|
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.
|
|
276
243
|
|
|
277
244
|
```ts
|
|
278
245
|
definePage({
|
|
279
|
-
path: '/
|
|
246
|
+
path: '/dashboard',
|
|
280
247
|
|
|
281
248
|
loader: async (c) => {
|
|
282
|
-
//
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
const post = await db.posts.findBySlug(slug)
|
|
249
|
+
// Full Hono Context — headers, cookies, query params, env bindings
|
|
250
|
+
const session = getCookie(c, 'session')
|
|
251
|
+
if (!session) return c.redirect('/login')
|
|
286
252
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return { post }
|
|
253
|
+
const user = await auth.verify(session)
|
|
254
|
+
const stats = await db.stats.forUser(user.id)
|
|
255
|
+
return { user, stats }
|
|
293
256
|
},
|
|
294
257
|
|
|
295
|
-
Page({
|
|
258
|
+
Page({ user, stats }) { /* typed */ },
|
|
296
259
|
})
|
|
297
260
|
```
|
|
298
261
|
|
|
299
262
|
**Type-safe loaders:**
|
|
300
263
|
|
|
301
264
|
```ts
|
|
302
|
-
interface
|
|
303
|
-
post: Post
|
|
304
|
-
author: User
|
|
305
|
-
}
|
|
265
|
+
interface DashboardData { user: User; stats: Stats }
|
|
306
266
|
|
|
307
|
-
definePage<
|
|
308
|
-
loader: async (c): Promise<
|
|
309
|
-
|
|
310
|
-
|
|
267
|
+
definePage<DashboardData>({
|
|
268
|
+
loader: async (c): Promise<DashboardData> => ({
|
|
269
|
+
user: await getUser(c),
|
|
270
|
+
stats: await getStats(c),
|
|
311
271
|
}),
|
|
312
|
-
Page({
|
|
272
|
+
Page({ user, stats }) { /* DashboardData & { url, params } */ },
|
|
313
273
|
})
|
|
314
274
|
```
|
|
315
275
|
|
|
@@ -318,10 +278,10 @@ definePage<PostData>({
|
|
|
318
278
|
## SEO
|
|
319
279
|
|
|
320
280
|
Every page can declare `seo` as a **static object** or a **function of loader data**.
|
|
321
|
-
App-level `seo` provides defaults; page-level values override them.
|
|
281
|
+
App-level `seo` provides global defaults; page-level values override them.
|
|
322
282
|
|
|
323
283
|
```ts
|
|
324
|
-
// app.ts — global defaults
|
|
284
|
+
// app.ts — global defaults applied to every page
|
|
325
285
|
createFNetro({
|
|
326
286
|
seo: {
|
|
327
287
|
ogType: 'website',
|
|
@@ -329,42 +289,49 @@ createFNetro({
|
|
|
329
289
|
twitterCard: 'summary_large_image',
|
|
330
290
|
twitterSite: '@myapp',
|
|
331
291
|
robots: 'index, follow',
|
|
292
|
+
themeColor: '#0d0f14',
|
|
332
293
|
},
|
|
333
294
|
routes: [...],
|
|
334
295
|
})
|
|
296
|
+
```
|
|
335
297
|
|
|
336
|
-
|
|
298
|
+
```ts
|
|
299
|
+
// app/routes/post.tsx — page overrides (merged with app defaults)
|
|
337
300
|
definePage({
|
|
338
301
|
path: '/posts/[slug]',
|
|
339
|
-
loader:
|
|
302
|
+
loader: (c) => ({ post: await getPost(c.req.param('slug')) }),
|
|
340
303
|
|
|
341
|
-
// Function form — receives loader data and params
|
|
342
304
|
seo: (data, params) => ({
|
|
343
305
|
title: `${data.post.title} — My Blog`,
|
|
344
306
|
description: data.post.excerpt,
|
|
345
|
-
canonical: `https://
|
|
307
|
+
canonical: `https://example.com/posts/${params.slug}`,
|
|
346
308
|
ogTitle: data.post.title,
|
|
347
309
|
ogDescription: data.post.excerpt,
|
|
348
|
-
ogImage: data.post.
|
|
310
|
+
ogImage: data.post.coverUrl,
|
|
349
311
|
ogImageWidth: '1200',
|
|
350
312
|
ogImageHeight: '630',
|
|
351
313
|
twitterTitle: data.post.title,
|
|
352
|
-
twitterImage: data.post.
|
|
314
|
+
twitterImage: data.post.coverUrl,
|
|
353
315
|
jsonLd: {
|
|
354
|
-
'@context':
|
|
355
|
-
'@type':
|
|
356
|
-
headline:
|
|
357
|
-
author:
|
|
316
|
+
'@context': 'https://schema.org',
|
|
317
|
+
'@type': 'Article',
|
|
318
|
+
headline: data.post.title,
|
|
319
|
+
author: { '@type': 'Person', name: data.post.authorName },
|
|
358
320
|
datePublished: data.post.publishedAt,
|
|
321
|
+
image: data.post.coverUrl,
|
|
359
322
|
},
|
|
323
|
+
extra: [
|
|
324
|
+
{ name: 'article:author', content: data.post.authorName },
|
|
325
|
+
],
|
|
360
326
|
}),
|
|
327
|
+
|
|
361
328
|
Page({ post }) { ... },
|
|
362
329
|
})
|
|
363
330
|
```
|
|
364
331
|
|
|
365
332
|
### All SEO fields
|
|
366
333
|
|
|
367
|
-
| Field |
|
|
334
|
+
| Field | `<head>` output |
|
|
368
335
|
|---|---|
|
|
369
336
|
| `title` | `<title>` |
|
|
370
337
|
| `description` | `<meta name="description">` |
|
|
@@ -389,10 +356,11 @@ definePage({
|
|
|
389
356
|
| `twitterTitle` | `<meta name="twitter:title">` |
|
|
390
357
|
| `twitterDescription` | `<meta name="twitter:description">` |
|
|
391
358
|
| `twitterImage` | `<meta name="twitter:image">` |
|
|
359
|
+
| `twitterImageAlt` | `<meta name="twitter:image:alt">` |
|
|
392
360
|
| `jsonLd` | `<script type="application/ld+json">` |
|
|
393
|
-
| `extra` |
|
|
361
|
+
| `extra` | Arbitrary `<meta>` tags |
|
|
394
362
|
|
|
395
|
-
|
|
363
|
+
On SPA navigation, all `<meta>` tags and `document.title` are updated automatically — no full reload.
|
|
396
364
|
|
|
397
365
|
---
|
|
398
366
|
|
|
@@ -400,29 +368,29 @@ definePage({
|
|
|
400
368
|
|
|
401
369
|
### Server middleware
|
|
402
370
|
|
|
403
|
-
Hono middleware
|
|
371
|
+
Hono middleware at three levels — global, group, and page.
|
|
404
372
|
|
|
405
373
|
```ts
|
|
406
374
|
import { createFNetro } from '@netrojs/fnetro/server'
|
|
407
|
-
import { cors }
|
|
408
|
-
import { logger }
|
|
409
|
-
import { bearerAuth }
|
|
375
|
+
import { cors } from 'hono/cors'
|
|
376
|
+
import { logger } from 'hono/logger'
|
|
377
|
+
import { bearerAuth } from 'hono/bearer-auth'
|
|
410
378
|
|
|
411
|
-
// 1. Global — runs before every route
|
|
412
379
|
const fnetro = createFNetro({
|
|
413
|
-
|
|
380
|
+
// 1. Global — runs on every request
|
|
381
|
+
middleware: [logger(), cors({ origin: 'https://example.com' })],
|
|
414
382
|
|
|
415
383
|
routes: [
|
|
416
|
-
// 2. Group-level — runs for
|
|
384
|
+
// 2. Group-level — runs for every route in the group
|
|
417
385
|
defineGroup({
|
|
418
|
-
prefix: '/
|
|
386
|
+
prefix: '/admin',
|
|
419
387
|
middleware: [bearerAuth({ token: process.env.API_KEY! })],
|
|
420
388
|
routes: [
|
|
421
389
|
// 3. Page-level — runs for this route only
|
|
422
390
|
definePage({
|
|
423
|
-
path: '/
|
|
391
|
+
path: '/reports',
|
|
424
392
|
middleware: [rateLimiter({ max: 10, window: '1m' })],
|
|
425
|
-
Page:
|
|
393
|
+
Page: Reports,
|
|
426
394
|
}),
|
|
427
395
|
],
|
|
428
396
|
}),
|
|
@@ -445,73 +413,61 @@ const requireAuth: HonoMiddleware = async (c, next) => {
|
|
|
445
413
|
|
|
446
414
|
### Client middleware
|
|
447
415
|
|
|
448
|
-
|
|
416
|
+
Runs before every **SPA navigation**. Register with `useClientMiddleware()` **before** `boot()`.
|
|
449
417
|
|
|
450
418
|
```ts
|
|
451
419
|
// client.ts
|
|
452
420
|
import { boot, useClientMiddleware, navigate } from '@netrojs/fnetro/client'
|
|
453
421
|
|
|
454
|
-
// Analytics
|
|
422
|
+
// Analytics — fires after navigation completes
|
|
455
423
|
useClientMiddleware(async (url, next) => {
|
|
456
424
|
await next()
|
|
457
|
-
analytics.
|
|
425
|
+
analytics.page({ url })
|
|
458
426
|
})
|
|
459
427
|
|
|
460
|
-
// Auth guard
|
|
428
|
+
// Auth guard — redirects before navigation
|
|
461
429
|
useClientMiddleware(async (url, next) => {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (isProtected && !isAuthenticated()) {
|
|
466
|
-
await navigate(`/login?redirect=${encodeURIComponent(url)}`)
|
|
467
|
-
return // cancel original navigation
|
|
430
|
+
if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
431
|
+
await navigate('/login?redirect=' + encodeURIComponent(url))
|
|
432
|
+
return // cancel the original navigation
|
|
468
433
|
}
|
|
469
|
-
|
|
470
434
|
await next()
|
|
471
435
|
})
|
|
472
436
|
|
|
473
437
|
// Loading indicator
|
|
474
438
|
useClientMiddleware(async (url, next) => {
|
|
475
|
-
|
|
476
|
-
try
|
|
477
|
-
|
|
478
|
-
} finally {
|
|
479
|
-
hideLoadingBar()
|
|
480
|
-
}
|
|
439
|
+
NProgress.start()
|
|
440
|
+
try { await next() }
|
|
441
|
+
finally { NProgress.done() }
|
|
481
442
|
})
|
|
482
443
|
|
|
483
444
|
boot({ routes, layout })
|
|
484
445
|
```
|
|
485
446
|
|
|
486
|
-
|
|
447
|
+
The chain runs in registration order: `mw1 → mw2 → ... → fetch + render`. Omitting `next()` in any middleware cancels the navigation.
|
|
487
448
|
|
|
488
449
|
---
|
|
489
450
|
|
|
490
451
|
## SolidJS reactivity
|
|
491
452
|
|
|
492
|
-
Use SolidJS primitives directly — no FNetro wrappers
|
|
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):
|
|
493
456
|
|
|
494
457
|
```tsx
|
|
495
|
-
import { createSignal, createMemo, createEffect, For
|
|
496
|
-
import { createStore, produce } from 'solid-js/store'
|
|
458
|
+
import { createSignal, createMemo, createEffect, For } from 'solid-js'
|
|
497
459
|
import { definePage } from '@netrojs/fnetro'
|
|
498
460
|
|
|
499
|
-
// Module-level signals persist across SPA navigations
|
|
500
461
|
const [count, setCount] = createSignal(0)
|
|
501
462
|
const doubled = createMemo(() => count() * 2)
|
|
502
463
|
|
|
503
464
|
export default definePage({
|
|
504
465
|
path: '/counter',
|
|
505
466
|
Page() {
|
|
506
|
-
|
|
507
|
-
createEffect(() => {
|
|
508
|
-
document.title = `Count: ${count()}`
|
|
509
|
-
})
|
|
510
|
-
|
|
467
|
+
createEffect(() => { document.title = `Count: ${count()}` })
|
|
511
468
|
return (
|
|
512
469
|
<div>
|
|
513
|
-
<p>
|
|
514
|
-
<p>Doubled: {doubled()}</p>
|
|
470
|
+
<p>{count()} × 2 = {doubled()}</p>
|
|
515
471
|
<button onClick={() => setCount(n => n + 1)}>+</button>
|
|
516
472
|
</div>
|
|
517
473
|
)
|
|
@@ -519,20 +475,15 @@ export default definePage({
|
|
|
519
475
|
})
|
|
520
476
|
```
|
|
521
477
|
|
|
522
|
-
**
|
|
478
|
+
**Stores** for structured reactive state:
|
|
523
479
|
|
|
524
480
|
```tsx
|
|
525
481
|
import { createStore, produce } from 'solid-js/store'
|
|
526
482
|
|
|
527
483
|
interface Todo { id: number; text: string; done: boolean }
|
|
528
|
-
|
|
529
484
|
const [todos, setTodos] = createStore<{ items: Todo[] }>({ items: [] })
|
|
530
485
|
|
|
531
|
-
function
|
|
532
|
-
setTodos('items', l => [...l, { id: Date.now(), text, done: false }])
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function toggleTodo(id: number) {
|
|
486
|
+
function toggle(id: number) {
|
|
536
487
|
setTodos('items', t => t.id === id, produce(t => { t.done = !t.done }))
|
|
537
488
|
}
|
|
538
489
|
|
|
@@ -540,18 +491,16 @@ export default definePage({
|
|
|
540
491
|
path: '/todos',
|
|
541
492
|
Page() {
|
|
542
493
|
return (
|
|
543
|
-
<
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
</For>
|
|
554
|
-
</ul>
|
|
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>
|
|
555
504
|
)
|
|
556
505
|
},
|
|
557
506
|
})
|
|
@@ -561,18 +510,15 @@ export default definePage({
|
|
|
561
510
|
|
|
562
511
|
## Navigation
|
|
563
512
|
|
|
564
|
-
###
|
|
513
|
+
### Links — automatic interception
|
|
565
514
|
|
|
566
|
-
Any `<a href="...">` pointing to a registered route is intercepted automatically
|
|
515
|
+
Any `<a href="...">` pointing to a registered route is intercepted automatically. No special component needed.
|
|
567
516
|
|
|
568
517
|
```tsx
|
|
569
|
-
|
|
570
|
-
<a href="/
|
|
571
|
-
<a href="/
|
|
572
|
-
|
|
573
|
-
// Opt out with data-no-spa or rel="external"
|
|
574
|
-
<a href="/legacy" data-no-spa>Legacy page</a>
|
|
575
|
-
<a href="https://external.com" rel="external">External</a>
|
|
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 */}
|
|
576
522
|
```
|
|
577
523
|
|
|
578
524
|
### Programmatic navigation
|
|
@@ -580,14 +526,9 @@ Any `<a href="...">` pointing to a registered route is intercepted automatically
|
|
|
580
526
|
```ts
|
|
581
527
|
import { navigate } from '@netrojs/fnetro/client'
|
|
582
528
|
|
|
583
|
-
//
|
|
584
|
-
await navigate('/
|
|
585
|
-
|
|
586
|
-
// Replace current history entry
|
|
587
|
-
await navigate('/login', { replace: true })
|
|
588
|
-
|
|
589
|
-
// Prevent scroll-to-top
|
|
590
|
-
await navigate('/modal', { scroll: false })
|
|
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
|
|
591
532
|
```
|
|
592
533
|
|
|
593
534
|
### Prefetch
|
|
@@ -595,18 +536,10 @@ await navigate('/modal', { scroll: false })
|
|
|
595
536
|
```ts
|
|
596
537
|
import { prefetch } from '@netrojs/fnetro/client'
|
|
597
538
|
|
|
598
|
-
//
|
|
599
|
-
prefetch('/about')
|
|
539
|
+
prefetch('/about') // warm the loader cache on hover / focus
|
|
600
540
|
```
|
|
601
541
|
|
|
602
|
-
Hover-based prefetching is
|
|
603
|
-
|
|
604
|
-
```ts
|
|
605
|
-
boot({
|
|
606
|
-
prefetchOnHover: true, // default: true
|
|
607
|
-
routes,
|
|
608
|
-
})
|
|
609
|
-
```
|
|
542
|
+
Hover-based prefetching is automatic when `prefetchOnHover: true` (the default) is set in `boot()`.
|
|
610
543
|
|
|
611
544
|
---
|
|
612
545
|
|
|
@@ -614,46 +547,44 @@ boot({
|
|
|
614
547
|
|
|
615
548
|
### Development
|
|
616
549
|
|
|
617
|
-
`@hono/vite-dev-server` injects Vite's dev client automatically. No asset
|
|
550
|
+
`@hono/vite-dev-server` injects Vite's dev client and HMR scripts automatically. No asset config needed.
|
|
618
551
|
|
|
619
552
|
### Production
|
|
620
553
|
|
|
621
|
-
|
|
554
|
+
`vite build` produces a `manifest.json` alongside the hashed client bundle. The server reads the manifest at startup to resolve the correct filenames.
|
|
622
555
|
|
|
623
556
|
```ts
|
|
624
|
-
// app.ts
|
|
557
|
+
// app.ts
|
|
625
558
|
createFNetro({
|
|
626
559
|
routes,
|
|
627
560
|
assets: {
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
// Key in the manifest (usually the entry filename)
|
|
631
|
-
manifestEntry: 'client.ts',
|
|
561
|
+
manifestDir: 'dist/assets', // directory containing manifest.json
|
|
562
|
+
manifestEntry: 'client.ts', // key in the manifest (your client entry)
|
|
632
563
|
},
|
|
633
564
|
})
|
|
634
565
|
```
|
|
635
566
|
|
|
636
|
-
**Manual
|
|
567
|
+
**Manual override** (edge runtimes / CDN-hosted assets):
|
|
637
568
|
|
|
638
569
|
```ts
|
|
639
570
|
createFNetro({
|
|
640
571
|
assets: {
|
|
641
|
-
scripts: ['/
|
|
642
|
-
styles: ['/
|
|
572
|
+
scripts: ['https://cdn.example.com/client-abc123.js'],
|
|
573
|
+
styles: ['https://cdn.example.com/style-def456.css'],
|
|
643
574
|
},
|
|
644
575
|
})
|
|
645
576
|
```
|
|
646
577
|
|
|
647
|
-
**Public directory
|
|
578
|
+
**Public directory** — static files in `public/` (images, fonts, `robots.txt`, `favicon.ico`) are served at `/` by the Node.js `serve()` helper automatically.
|
|
648
579
|
|
|
649
580
|
---
|
|
650
581
|
|
|
651
|
-
## Multi-runtime
|
|
582
|
+
## Multi-runtime serve()
|
|
652
583
|
|
|
653
584
|
```ts
|
|
654
585
|
import { serve } from '@netrojs/fnetro/server'
|
|
655
586
|
|
|
656
|
-
// Auto-detects
|
|
587
|
+
// Auto-detects Node.js, Bun, or Deno
|
|
657
588
|
await serve({ app: fnetro })
|
|
658
589
|
|
|
659
590
|
// Explicit configuration
|
|
@@ -661,16 +592,18 @@ await serve({
|
|
|
661
592
|
app: fnetro,
|
|
662
593
|
port: 3000,
|
|
663
594
|
hostname: '0.0.0.0',
|
|
664
|
-
runtime: 'node', // 'node' | 'bun' | 'deno'
|
|
665
|
-
staticDir: './dist', //
|
|
595
|
+
runtime: 'node', // 'node' | 'bun' | 'deno' | 'edge'
|
|
596
|
+
staticDir: './dist', // root for /assets/* and /* static files
|
|
666
597
|
})
|
|
667
598
|
```
|
|
668
599
|
|
|
669
|
-
**Edge runtimes** (Cloudflare Workers, Deno Deploy, etc.)
|
|
600
|
+
**Edge runtimes** (Cloudflare Workers, Deno Deploy, Fastly, etc.):
|
|
670
601
|
|
|
671
602
|
```ts
|
|
672
603
|
// server.ts
|
|
673
604
|
import { fnetro } from './app'
|
|
605
|
+
|
|
606
|
+
// Export the Hono fetch handler — the platform calls it directly
|
|
674
607
|
export default { fetch: fnetro.handler }
|
|
675
608
|
```
|
|
676
609
|
|
|
@@ -680,25 +613,25 @@ export default { fetch: fnetro.handler }
|
|
|
680
613
|
|
|
681
614
|
```ts
|
|
682
615
|
// vite.config.ts
|
|
683
|
-
import { defineConfig }
|
|
616
|
+
import { defineConfig } from 'vite'
|
|
684
617
|
import { fnetroVitePlugin } from '@netrojs/fnetro/vite'
|
|
685
|
-
import devServer
|
|
618
|
+
import devServer from '@hono/vite-dev-server'
|
|
686
619
|
|
|
687
620
|
export default defineConfig({
|
|
688
621
|
plugins: [
|
|
689
|
-
// Handles JSX transform
|
|
622
|
+
// Handles: SolidJS JSX transform, SSR server build, client bundle + manifest
|
|
690
623
|
fnetroVitePlugin({
|
|
691
|
-
serverEntry:
|
|
692
|
-
clientEntry:
|
|
693
|
-
serverOutDir:
|
|
694
|
-
clientOutDir:
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
// Options forwarded to vite-plugin-solid
|
|
698
|
-
solidOptions: { extensions: ['.mdx'] },
|
|
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
|
|
699
630
|
}),
|
|
700
631
|
|
|
701
|
-
// Dev
|
|
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).
|
|
702
635
|
devServer({ entry: 'app.ts' }),
|
|
703
636
|
],
|
|
704
637
|
})
|
|
@@ -709,26 +642,65 @@ export default defineConfig({
|
|
|
709
642
|
```
|
|
710
643
|
dist/
|
|
711
644
|
├── server/
|
|
712
|
-
│ └── server.js
|
|
645
|
+
│ └── server.js # SSR server bundle (ESM)
|
|
713
646
|
└── assets/
|
|
714
|
-
├── manifest.json
|
|
715
|
-
├── client-
|
|
716
|
-
└── style-
|
|
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)
|
|
717
650
|
```
|
|
718
651
|
|
|
719
652
|
---
|
|
720
653
|
|
|
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`
|
|
685
|
+
|
|
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
|
+
---
|
|
692
|
+
|
|
721
693
|
## TypeScript
|
|
722
694
|
|
|
723
|
-
`tsconfig.json` for
|
|
695
|
+
`tsconfig.json` for any FNetro project:
|
|
724
696
|
|
|
725
697
|
```json
|
|
726
698
|
{
|
|
727
699
|
"compilerOptions": {
|
|
728
|
-
"target": "
|
|
700
|
+
"target": "ES2022",
|
|
729
701
|
"module": "ESNext",
|
|
730
702
|
"moduleResolution": "bundler",
|
|
731
|
-
"lib": ["
|
|
703
|
+
"lib": ["ES2022", "DOM"],
|
|
732
704
|
"jsx": "preserve",
|
|
733
705
|
"jsxImportSource": "solid-js",
|
|
734
706
|
"strict": true,
|
|
@@ -742,53 +714,293 @@ dist/
|
|
|
742
714
|
}
|
|
743
715
|
```
|
|
744
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
|
+
```
|
|
777
|
+
|
|
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
|
|
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
|
+
|
|
745
797
|
---
|
|
746
798
|
|
|
747
799
|
## API reference
|
|
748
800
|
|
|
749
801
|
### `@netrojs/fnetro` (core)
|
|
750
802
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
|
754
|
-
|
|
755
|
-
| `
|
|
756
|
-
| `
|
|
757
|
-
| `
|
|
758
|
-
| `
|
|
759
|
-
| `
|
|
760
|
-
| `
|
|
761
|
-
| `
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
**Types:** `AppConfig
|
|
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`
|
|
766
818
|
|
|
767
819
|
---
|
|
768
820
|
|
|
769
821
|
### `@netrojs/fnetro/server`
|
|
770
822
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
|
774
|
-
|
|
775
|
-
| `
|
|
776
|
-
| `
|
|
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`):
|
|
777
833
|
|
|
778
|
-
|
|
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`:**
|
|
871
|
+
|
|
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
|
|
880
|
+
}
|
|
881
|
+
```
|
|
779
882
|
|
|
780
883
|
---
|
|
781
884
|
|
|
782
885
|
### `@netrojs/fnetro/client`
|
|
783
886
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
|
787
|
-
|
|
788
|
-
| `
|
|
789
|
-
| `
|
|
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
|
+
```
|
|
921
|
+
|
|
922
|
+
---
|
|
923
|
+
|
|
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
|
|
790
940
|
|
|
791
|
-
|
|
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
|
|
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
|
|
980
|
+
```
|
|
981
|
+
|
|
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
|
|
993
|
+
|
|
994
|
+
# Full release (build + changeset publish)
|
|
995
|
+
npm run release
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### Secrets required
|
|
999
|
+
|
|
1000
|
+
| Secret | Description |
|
|
1001
|
+
|---|---|
|
|
1002
|
+
| `NPM_TOKEN` | npm automation token (requires publish permission for `@netrojs`) |
|
|
1003
|
+
| `GITHUB_TOKEN` | Provided automatically by GitHub Actions |
|
|
792
1004
|
|
|
793
1005
|
---
|
|
794
1006
|
|