@netrojs/vono 0.0.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/LICENSE +21 -0
- package/README.md +768 -0
- package/client.ts +309 -0
- package/core.ts +151 -0
- package/dist/client.d.ts +199 -0
- package/dist/client.js +287 -0
- package/dist/core.d.ts +167 -0
- package/dist/core.js +96 -0
- package/dist/server.d.ts +212 -0
- package/dist/server.js +451 -0
- package/dist/types.d.ts +120 -0
- package/package.json +103 -0
- package/server.ts +590 -0
- package/types.ts +149 -0
package/README.md
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
# ◈ Vono
|
|
2
|
+
|
|
3
|
+
**Full-stack Hono + Vue 3 framework — Streaming SSR · SPA · Code Splitting · Type-safe Loaders · SEO · TypeScript**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@netrojs/vono)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of contents
|
|
11
|
+
|
|
12
|
+
- [What is Vono?](#what-is-vono)
|
|
13
|
+
- [How it works](#how-it-works)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [Manual installation](#manual-installation)
|
|
16
|
+
- [File structure](#file-structure)
|
|
17
|
+
- [Routes](#routes)
|
|
18
|
+
- [definePage()](#definepage)
|
|
19
|
+
- [defineGroup()](#definegroup)
|
|
20
|
+
- [defineLayout()](#definelayout)
|
|
21
|
+
- [defineApiRoute()](#defineapiroute)
|
|
22
|
+
- [Type-safe loaders](#type-safe-loaders)
|
|
23
|
+
- [InferPageData\<T\>](#inferpagedata)
|
|
24
|
+
- [usePageData()](#usepagedata)
|
|
25
|
+
- [State hydration & lifecycle hooks](#state-hydration--lifecycle-hooks)
|
|
26
|
+
- [SEO](#seo)
|
|
27
|
+
- [Middleware](#middleware)
|
|
28
|
+
- [Server middleware](#server-middleware)
|
|
29
|
+
- [Client middleware](#client-middleware)
|
|
30
|
+
- [Layouts](#layouts)
|
|
31
|
+
- [Dynamic params](#dynamic-params)
|
|
32
|
+
- [Code splitting](#code-splitting)
|
|
33
|
+
- [SPA navigation & prefetch](#spa-navigation--prefetch)
|
|
34
|
+
- [API routes](#api-routes)
|
|
35
|
+
- [Production build](#production-build)
|
|
36
|
+
- [Multi-runtime deployment](#multi-runtime-deployment)
|
|
37
|
+
- [Vite plugin reference](#vite-plugin-reference)
|
|
38
|
+
- [API reference](#api-reference)
|
|
39
|
+
- [How SSR hydration works internally](#how-ssr-hydration-works-internally)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## What is Vono?
|
|
44
|
+
|
|
45
|
+
Vono is a **file-free, config-driven full-stack framework** that glues [Hono](https://hono.dev) (server) to [Vue 3](https://vuejs.org) (UI). You define your routes once in a plain TypeScript array. Vono:
|
|
46
|
+
|
|
47
|
+
1. **Renders them on the server** using Vue's streaming `renderToWebStream` — the browser gets `<head>` (CSS, scripts) immediately while the body streams in.
|
|
48
|
+
2. **Hydrates them in the browser** as a Vue 3 SPA — subsequent navigations fetch only a small JSON payload and swap the reactive data in-place, no full reload.
|
|
49
|
+
3. **Infers types** from your loader all the way through to the component — one definition, zero duplication.
|
|
50
|
+
|
|
51
|
+
### Feature matrix
|
|
52
|
+
|
|
53
|
+
| Feature | Detail |
|
|
54
|
+
|---|---|
|
|
55
|
+
| **Streaming SSR** | `renderToWebStream` — `<head>` is flushed before the body starts, so the browser can parse CSS and begin JS evaluation while Vue is still rendering. Lower TTFB than buffered SSR. |
|
|
56
|
+
| **SPA navigation** | Vue Router 4 on the client. Navigations send `x-vono-spa: 1` and receive a small JSON `{ state, seo, params }` payload — no HTML re-render. |
|
|
57
|
+
| **Code splitting** | Pass `() => import('./Page.vue')` as `component`. Vono resolves the import before SSR and wraps it in `defineAsyncComponent` on the client for lazy loading. |
|
|
58
|
+
| **Type-safe loaders** | `definePage<TData>()` infers `TData` from your loader. `InferPageData<typeof page>` extracts it for use in components. `usePageData<T>()` returns it fully typed and reactive. |
|
|
59
|
+
| **Full SEO** | Per-page title, description, Open Graph, Twitter Cards, JSON-LD structured data — injected into `<head>` on SSR and synced via the DOM on SPA navigation. |
|
|
60
|
+
| **Server middleware** | Hono `MiddlewareHandler` — applied per-app, per-group (`defineGroup`), or per-route. Ideal for auth, rate limiting, logging. |
|
|
61
|
+
| **Client middleware** | `useClientMiddleware()` — runs on SPA navigation before the data fetch. Ideal for auth guards, analytics, scroll restoration. |
|
|
62
|
+
| **Route groups** | `defineGroup()` shares a URL prefix, layout, and middleware stack across multiple pages. |
|
|
63
|
+
| **API routes** | `defineApiRoute()` co-locates Hono JSON endpoints alongside your page routes — same file, same middleware. |
|
|
64
|
+
| **Multi-runtime** | `serve()` auto-detects Node.js, Bun, Deno. Edge runtimes (Cloudflare Workers, Vercel Edge) use `vono.handler` directly. |
|
|
65
|
+
| **Zero config** | One Vite plugin (`vonoVitePlugin`) orchestrates both the SSR server bundle and the client SPA bundle. |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Browser request
|
|
73
|
+
│
|
|
74
|
+
▼
|
|
75
|
+
Hono (server.ts)
|
|
76
|
+
│ matches route
|
|
77
|
+
▼
|
|
78
|
+
loader(ctx) ──────────────────────────► typed TData object
|
|
79
|
+
│
|
|
80
|
+
▼
|
|
81
|
+
renderToWebStream(Vue SSR app)
|
|
82
|
+
│
|
|
83
|
+
├──► streams <head> immediately ──► browser parses CSS + scripts
|
|
84
|
+
│
|
|
85
|
+
└──► streams <body> … ──► browser renders progressive HTML
|
|
86
|
+
│
|
|
87
|
+
client.ts boots
|
|
88
|
+
│
|
|
89
|
+
createSSRApp() hydrates DOM
|
|
90
|
+
│
|
|
91
|
+
window.__VONO_STATE__ seeds
|
|
92
|
+
reactive page data (zero fetch)
|
|
93
|
+
│
|
|
94
|
+
Vue Router takes over navigation
|
|
95
|
+
│
|
|
96
|
+
SPA nav ──► fetch JSON ──► update reactive data
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm create @netrojs/vono@latest my-app
|
|
105
|
+
cd my-app
|
|
106
|
+
npm install
|
|
107
|
+
npm run dev
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or with Bun:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
bun create @netrojs/vono@latest my-app
|
|
114
|
+
cd my-app
|
|
115
|
+
bun install
|
|
116
|
+
bun run dev
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Manual installation
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm i @netrojs/vono vue vue-router @vue/server-renderer hono
|
|
125
|
+
npm i -D vite @vitejs/plugin-vue @hono/vite-dev-server @hono/node-server vue-tsc typescript
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## File structure
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
my-app/
|
|
134
|
+
├── app.ts ← createVono() + default export for dev server
|
|
135
|
+
├── server.ts ← Production server entry (await serve(...))
|
|
136
|
+
├── client.ts ← Browser hydration entry (boot(...))
|
|
137
|
+
├── vite.config.ts
|
|
138
|
+
├── tsconfig.json
|
|
139
|
+
├── global.d.ts ← Window augmentation for SSR-injected keys
|
|
140
|
+
└── app/
|
|
141
|
+
├── routes.ts ← All route definitions (pages, groups, APIs)
|
|
142
|
+
├── layouts/
|
|
143
|
+
│ └── RootLayout.vue
|
|
144
|
+
├── pages/
|
|
145
|
+
│ ├── home.vue
|
|
146
|
+
│ ├── blog/
|
|
147
|
+
│ │ ├── index.vue
|
|
148
|
+
│ │ └── [slug].vue
|
|
149
|
+
│ └── dashboard/
|
|
150
|
+
│ └── index.vue
|
|
151
|
+
└── style.css
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Routes
|
|
157
|
+
|
|
158
|
+
All routes are defined in a plain TypeScript array and passed to `createVono()` and `boot()`.
|
|
159
|
+
|
|
160
|
+
### `definePage()`
|
|
161
|
+
|
|
162
|
+
The core building block. Every page is a `definePage()` call.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { definePage } from '@netrojs/vono'
|
|
166
|
+
|
|
167
|
+
export const homePage = definePage({
|
|
168
|
+
// URL path — supports [param] and [...catchAll] syntax
|
|
169
|
+
path: '/',
|
|
170
|
+
|
|
171
|
+
// Hono middleware applied only to this route (runs before the loader)
|
|
172
|
+
middleware: [logRequest],
|
|
173
|
+
|
|
174
|
+
// Server-side data fetcher — return value is typed and passed to usePageData()
|
|
175
|
+
loader: async (c) => ({
|
|
176
|
+
posts: await db.posts.findMany(),
|
|
177
|
+
user: c.get('user'), // access Hono context variables
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
// Static SEO object OR a function that receives (loaderData, params)
|
|
181
|
+
seo: (data, params) => ({
|
|
182
|
+
title: `${data.posts.length} posts — My Blog`,
|
|
183
|
+
description: 'The latest posts from our blog.',
|
|
184
|
+
ogType: 'website',
|
|
185
|
+
}),
|
|
186
|
+
|
|
187
|
+
// Layout override for this specific page
|
|
188
|
+
layout: myLayout, // or `false` to disable the app-level layout
|
|
189
|
+
|
|
190
|
+
// Vue component — use () => import() for automatic code splitting
|
|
191
|
+
component: () => import('./pages/home.vue'),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Export the inferred type for use in components
|
|
195
|
+
export type HomeData = InferPageData<typeof homePage>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Loader context** (`LoaderCtx`) is the full Hono `Context` object — you have access to `c.req`, `c.env`, `c.get()` / `c.set()`, `c.redirect()`, response helpers, and anything set by upstream middleware.
|
|
199
|
+
|
|
200
|
+
### `defineGroup()`
|
|
201
|
+
|
|
202
|
+
Groups share a URL prefix, a layout, and a middleware stack.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { defineGroup } from '@netrojs/vono'
|
|
206
|
+
|
|
207
|
+
export const dashboardGroup = defineGroup({
|
|
208
|
+
prefix: '/dashboard',
|
|
209
|
+
layout: dashboardLayout,
|
|
210
|
+
middleware: [requireAuth], // applied to every child route
|
|
211
|
+
routes: [
|
|
212
|
+
definePage({ path: '', component: () => import('./pages/dashboard/index.vue') }),
|
|
213
|
+
definePage({ path: '/posts', component: () => import('./pages/dashboard/posts.vue') }),
|
|
214
|
+
definePage({ path: '/users', component: () => import('./pages/dashboard/users.vue') }),
|
|
215
|
+
],
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- Child paths are concatenated: prefix `/dashboard` + path `/posts` → `/dashboard/posts`.
|
|
220
|
+
- Use `path: ''` (empty string) for the index route of a group (`/dashboard`).
|
|
221
|
+
- Groups can be nested.
|
|
222
|
+
|
|
223
|
+
### `defineLayout()`
|
|
224
|
+
|
|
225
|
+
Wraps a Vue component as a Vono layout. The component must render `<slot />` where the page content goes.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { defineLayout } from '@netrojs/vono'
|
|
229
|
+
import RootLayout from './layouts/RootLayout.vue'
|
|
230
|
+
|
|
231
|
+
export const rootLayout = defineLayout(RootLayout)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Pass it to `createVono({ layout: rootLayout })` for an app-wide default, to `defineGroup({ layout })` for a section, or directly to `definePage({ layout })` for a single page. Set `layout: false` on a page to opt out of any inherited layout.
|
|
235
|
+
|
|
236
|
+
### `defineApiRoute()`
|
|
237
|
+
|
|
238
|
+
Co-locate a Hono JSON API alongside your page routes. The callback receives a Hono sub-app mounted at `path`.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { defineApiRoute } from '@netrojs/vono'
|
|
242
|
+
|
|
243
|
+
export const postsApi = defineApiRoute('/api/posts', (app, globalMiddleware) => {
|
|
244
|
+
app.get('/', (c) => c.json({ posts: await db.posts.findMany() }))
|
|
245
|
+
app.get('/:slug', (c) => c.json(await db.posts.findBySlug(c.req.param('slug'))))
|
|
246
|
+
app.post('/', requireAuth, async (c) => {
|
|
247
|
+
const body = await c.req.json()
|
|
248
|
+
return c.json(await db.posts.create(body), 201)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
API routes are registered on the Hono app **before** the catch-all page handler, so they always take priority.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Type-safe loaders
|
|
258
|
+
|
|
259
|
+
The `loader` function's return type is inferred automatically:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
export const postPage = definePage({
|
|
263
|
+
path: '/blog/[slug]',
|
|
264
|
+
loader: async (c) => {
|
|
265
|
+
const post = await db.findPost(c.req.param('slug'))
|
|
266
|
+
return { post, related: await db.relatedPosts(post.id) }
|
|
267
|
+
},
|
|
268
|
+
component: () => import('./pages/blog/[slug].vue'),
|
|
269
|
+
})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
TypeScript infers `TData = { post: Post; related: Post[] }` from the loader automatically. The full chain is type-safe: server loader → SSR render → `window.__VONO_STATE__` → `usePageData<T>()` in the component.
|
|
273
|
+
|
|
274
|
+
### `InferPageData<T>`
|
|
275
|
+
|
|
276
|
+
Extract the loader type from an exported page definition — your **single source of truth**:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
// routes.ts
|
|
280
|
+
export const postPage = definePage({ loader: async () => ({ post: ... }), ... })
|
|
281
|
+
export type PostData = InferPageData<typeof postPage>
|
|
282
|
+
// ^ { post: Post } — derived from the loader, never written twice
|
|
283
|
+
|
|
284
|
+
// pages/blog/[slug].vue
|
|
285
|
+
import type { PostData } from '../routes'
|
|
286
|
+
const data = usePageData<PostData>()
|
|
287
|
+
// ^ fully typed reactive object
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
This pattern means you never manually maintain a parallel type — change the loader and TypeScript propagates the error to every component immediately.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## `usePageData()`
|
|
295
|
+
|
|
296
|
+
Available inside any component rendered inside a Vono route:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
300
|
+
import type { PostData } from '../routes'
|
|
301
|
+
|
|
302
|
+
const data = usePageData<PostData>()
|
|
303
|
+
// data.post → typed Post
|
|
304
|
+
// data.related → typed Post[]
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The returned object is **reactive** — when Vue Router performs a SPA navigation, Vono fetches the new JSON payload and updates the reactive store in-place. Components re-render automatically without being unmounted, preserving scroll position and any local state.
|
|
308
|
+
|
|
309
|
+
Calling `usePageData()` outside of a component `setup()` throws a clear error.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## State hydration & lifecycle hooks
|
|
314
|
+
|
|
315
|
+
Vono performs **full SSR hydration**. This means:
|
|
316
|
+
|
|
317
|
+
1. The server renders the complete HTML string and injects loader data as `window.__VONO_STATE__`.
|
|
318
|
+
2. `boot()` calls `createSSRApp()` (not `createApp()`), which tells Vue to hydrate the existing DOM rather than re-render from scratch.
|
|
319
|
+
3. Vue's reactivity system is activated on the existing DOM nodes — no flicker, no double render.
|
|
320
|
+
4. All Vue lifecycle hooks work exactly as expected after hydration:
|
|
321
|
+
|
|
322
|
+
```vue
|
|
323
|
+
<script setup lang="ts">
|
|
324
|
+
import { ref, computed, watch, onMounted, onUnmounted, onBeforeMount } from 'vue'
|
|
325
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
326
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
327
|
+
|
|
328
|
+
const data = usePageData<MyData>()
|
|
329
|
+
const route = useRoute()
|
|
330
|
+
const router = useRouter()
|
|
331
|
+
|
|
332
|
+
// ref / computed / watch — all work as normal
|
|
333
|
+
const count = ref(0)
|
|
334
|
+
const doubled = computed(() => count.value * 2)
|
|
335
|
+
watch(() => data.title, (t) => document.title = t)
|
|
336
|
+
|
|
337
|
+
// onMounted fires after hydration on the client (not on the server)
|
|
338
|
+
// Safe to access DOM APIs, start timers, attach event listeners
|
|
339
|
+
onMounted(() => {
|
|
340
|
+
console.log('Hydrated!', document.title)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
onUnmounted(() => {
|
|
344
|
+
// Clean up subscriptions, timers, etc.
|
|
345
|
+
})
|
|
346
|
+
</script>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Key rules:**
|
|
350
|
+
- `onMounted` and DOM APIs are **client-only** — they are never called during SSR. This is standard Vue SSR behaviour.
|
|
351
|
+
- `ref`, `computed`, `watch`, `provide/inject` all work in both SSR and client contexts.
|
|
352
|
+
- `useRoute()` and `useRouter()` work after hydration because `boot()` installs the Vue Router instance into the app before mounting.
|
|
353
|
+
- Do not access `window`, `document`, or `localStorage` outside of `onMounted` (or `if (import.meta.env.SSR)` guards) — they are undefined on the server.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## SEO
|
|
358
|
+
|
|
359
|
+
Define SEO per page, either as a static object or as a function that receives the loader data and URL params:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
// Static
|
|
363
|
+
definePage({
|
|
364
|
+
seo: {
|
|
365
|
+
title: 'Home — My Site',
|
|
366
|
+
description: 'Welcome to my site.',
|
|
367
|
+
ogTitle: 'Home',
|
|
368
|
+
ogImage: 'https://my-site.com/og/home.png',
|
|
369
|
+
twitterCard: 'summary_large_image',
|
|
370
|
+
jsonLd: {
|
|
371
|
+
'@context': 'https://schema.org',
|
|
372
|
+
'@type': 'WebSite',
|
|
373
|
+
name: 'My Site',
|
|
374
|
+
url: 'https://my-site.com',
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
...
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Dynamic — function receives (loaderData, params)
|
|
381
|
+
definePage({
|
|
382
|
+
seo: (data, params) => ({
|
|
383
|
+
title: `${data.post.title} — My Blog`,
|
|
384
|
+
description: data.post.excerpt,
|
|
385
|
+
ogType: 'article',
|
|
386
|
+
ogImage: `https://my-site.com/og/${params.slug}.png`,
|
|
387
|
+
canonical: `https://my-site.com/blog/${params.slug}`,
|
|
388
|
+
}),
|
|
389
|
+
...
|
|
390
|
+
})
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Global defaults are set in `createVono({ seo: { ... } })` and merged with per-page values (page wins on any key they both define).
|
|
394
|
+
|
|
395
|
+
On SPA navigation, `syncSEO()` is called automatically to update `document.title` and all `<meta>` tags in-place.
|
|
396
|
+
|
|
397
|
+
**Supported fields:**
|
|
398
|
+
|
|
399
|
+
| Field | HTML output |
|
|
400
|
+
|---|---|
|
|
401
|
+
| `title` | `<title>` |
|
|
402
|
+
| `description` | `<meta name="description">` |
|
|
403
|
+
| `keywords` | `<meta name="keywords">` |
|
|
404
|
+
| `author` | `<meta name="author">` |
|
|
405
|
+
| `robots` | `<meta name="robots">` |
|
|
406
|
+
| `canonical` | `<link rel="canonical">` |
|
|
407
|
+
| `themeColor` | `<meta name="theme-color">` |
|
|
408
|
+
| `ogTitle`, `ogDescription`, `ogImage`, `ogUrl`, `ogType`, `ogSiteName`, `ogImageAlt` | `<meta property="og:…">` |
|
|
409
|
+
| `twitterCard`, `twitterSite`, `twitterTitle`, `twitterDescription`, `twitterImage` | `<meta name="twitter:…">` |
|
|
410
|
+
| `jsonLd` | `<script type="application/ld+json">` |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Middleware
|
|
415
|
+
|
|
416
|
+
### Server middleware
|
|
417
|
+
|
|
418
|
+
Vono server middleware is a standard Hono `MiddlewareHandler`:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
import type { HonoMiddleware } from '@netrojs/vono'
|
|
422
|
+
|
|
423
|
+
const requireAuth: HonoMiddleware = async (c, next) => {
|
|
424
|
+
const token = c.req.header('Authorization')?.replace('Bearer ', '')
|
|
425
|
+
if (!token || !verifyToken(token)) {
|
|
426
|
+
return c.json({ error: 'Unauthorized' }, 401)
|
|
427
|
+
}
|
|
428
|
+
// Pass user to downstream handlers via Hono context
|
|
429
|
+
c.set('user', decodeToken(token))
|
|
430
|
+
await next()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const logRequest: HonoMiddleware = async (c, next) => {
|
|
434
|
+
const start = Date.now()
|
|
435
|
+
await next()
|
|
436
|
+
console.log(`${c.req.method} ${c.req.path} → ${Date.now() - start}ms`)
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Three levels of application:**
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
// 1. App-wide (runs before every page and API route)
|
|
444
|
+
createVono({ middleware: [logRequest], routes })
|
|
445
|
+
|
|
446
|
+
// 2. Per group (runs for every route inside the group)
|
|
447
|
+
defineGroup({ middleware: [requireAuth], prefix: '/dashboard', routes: [...] })
|
|
448
|
+
|
|
449
|
+
// 3. Per page (runs only for that specific route)
|
|
450
|
+
definePage({ middleware: [rateLimit], path: '/api/expensive', ... })
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Middleware is executed in order: app → group → route. Return early (without calling `next()`) to short-circuit the chain.
|
|
454
|
+
|
|
455
|
+
### Client middleware
|
|
456
|
+
|
|
457
|
+
Runs on every SPA navigation **before** the JSON data fetch:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
import { useClientMiddleware } from '@netrojs/vono/client'
|
|
461
|
+
|
|
462
|
+
// Call before boot() — typically in client.ts
|
|
463
|
+
useClientMiddleware(async (url, next) => {
|
|
464
|
+
// Auth guard — redirect to login if session expired
|
|
465
|
+
if (url.startsWith('/dashboard') && !isLoggedIn()) {
|
|
466
|
+
await navigate('/login')
|
|
467
|
+
return // don't call next() — cancels the navigation
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Analytics
|
|
471
|
+
analytics.track('pageview', { url })
|
|
472
|
+
|
|
473
|
+
await next() // proceed with the navigation
|
|
474
|
+
})
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Layouts
|
|
480
|
+
|
|
481
|
+
A layout is a Vue component that wraps page content via `<slot />`:
|
|
482
|
+
|
|
483
|
+
```vue
|
|
484
|
+
<!-- layouts/RootLayout.vue -->
|
|
485
|
+
<script setup lang="ts">
|
|
486
|
+
import { RouterLink } from 'vue-router'
|
|
487
|
+
</script>
|
|
488
|
+
|
|
489
|
+
<template>
|
|
490
|
+
<div class="app">
|
|
491
|
+
<nav>
|
|
492
|
+
<RouterLink to="/">Home</RouterLink>
|
|
493
|
+
<RouterLink to="/blog">Blog</RouterLink>
|
|
494
|
+
</nav>
|
|
495
|
+
<main>
|
|
496
|
+
<slot /> <!-- page content renders here -->
|
|
497
|
+
</main>
|
|
498
|
+
<footer>© 2025</footer>
|
|
499
|
+
</div>
|
|
500
|
+
</template>
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Register and apply it:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
// routes.ts
|
|
507
|
+
export const rootLayout = defineLayout(RootLayout)
|
|
508
|
+
|
|
509
|
+
// App-wide default
|
|
510
|
+
createVono({ layout: rootLayout, routes })
|
|
511
|
+
|
|
512
|
+
// Per-section override
|
|
513
|
+
defineGroup({ layout: dashboardLayout, prefix: '/dashboard', routes: [...] })
|
|
514
|
+
|
|
515
|
+
// Per-page override
|
|
516
|
+
definePage({ layout: false, ... }) // disables layout for this page
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Dynamic params
|
|
522
|
+
|
|
523
|
+
Use bracket syntax in paths. Params are available in `loader`, `seo`, and components via `useRoute()`:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
// Single param
|
|
527
|
+
definePage({ path: '/blog/[slug]', loader: (c) => ({ post: db.findPost(c.req.param('slug')) }) })
|
|
528
|
+
|
|
529
|
+
// Multiple params
|
|
530
|
+
definePage({ path: '/user/[id]/post/[postId]', loader: (c) => ({
|
|
531
|
+
user: db.findUser(c.req.param('id')),
|
|
532
|
+
post: db.findPost(c.req.param('postId')),
|
|
533
|
+
}) })
|
|
534
|
+
|
|
535
|
+
// Catch-all (matches /files/a/b/c → params.path = 'a/b/c')
|
|
536
|
+
definePage({ path: '/files/[...path]', loader: (c) => ({ path: c.req.param('path') }) })
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Inside a component:
|
|
540
|
+
|
|
541
|
+
```vue
|
|
542
|
+
<script setup lang="ts">
|
|
543
|
+
import { useRoute } from 'vue-router' // re-exported from @netrojs/vono/client
|
|
544
|
+
const route = useRoute()
|
|
545
|
+
// route.params.slug — string
|
|
546
|
+
</script>
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Code splitting
|
|
552
|
+
|
|
553
|
+
Every page with `component: () => import('./pages/X.vue')` generates a separate JS chunk. Vono handles the split correctly in both environments:
|
|
554
|
+
|
|
555
|
+
- **Server (SSR):** `isAsyncLoader()` detects the factory, awaits the import, and renders the resolved component synchronously.
|
|
556
|
+
- **Client (hydration):** The current route's chunk is pre-loaded before `app.mount()` to guarantee the client VDOM matches the SSR HTML. All other route chunks are lazy-loaded on demand via `defineAsyncComponent`.
|
|
557
|
+
|
|
558
|
+
No configuration needed — just use dynamic imports.
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## SPA navigation & prefetch
|
|
563
|
+
|
|
564
|
+
After hydration, Vue Router handles all same-origin navigation. Vono's `router.beforeEach` hook intercepts every navigation and:
|
|
565
|
+
|
|
566
|
+
1. Sends `GET <url>` with `x-vono-spa: 1` header.
|
|
567
|
+
2. The server recognises the header and returns `{ state, seo, params }` JSON (skipping SSR entirely).
|
|
568
|
+
3. The reactive page data store is updated in-place — components re-render reactively.
|
|
569
|
+
4. `syncSEO()` updates all meta tags.
|
|
570
|
+
|
|
571
|
+
**Prefetch on hover** (enabled by default) warms the fetch cache before the user clicks:
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
boot({ routes, prefetchOnHover: true })
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**Manual prefetch:**
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
import { prefetch } from '@netrojs/vono/client'
|
|
581
|
+
prefetch('/blog/my-post')
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## API routes
|
|
587
|
+
|
|
588
|
+
API routes are standard Hono apps mounted at the given path:
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
export const usersApi = defineApiRoute('/api/users', (app) => {
|
|
592
|
+
app.get('/', async (c) => c.json(await db.users.findMany()))
|
|
593
|
+
app.post('/', requireAuth, async (c) => {
|
|
594
|
+
const body = await c.req.json<{ name: string; email: string }>()
|
|
595
|
+
return c.json(await db.users.create(body), 201)
|
|
596
|
+
})
|
|
597
|
+
app.delete('/:id', requireAuth, async (c) => {
|
|
598
|
+
await db.users.delete(c.req.param('id'))
|
|
599
|
+
return c.body(null, 204)
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
The Hono sub-app is mounted **before** the page handler catch-all, so API routes always win. You can call your own API from `loader()` or from the client using `fetch()`.
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
## Production build
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
npm run build
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
This runs `vite build` which triggers `vonoVitePlugin`:
|
|
615
|
+
|
|
616
|
+
1. **SSR bundle** — `dist/server/server.js` (ES module, `target: node18`, top-level await enabled, all dependencies externalised).
|
|
617
|
+
2. **Client bundle** — `dist/assets/` (ES module chunks + `.vite/manifest.json` for asset fingerprinting).
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
npm run start
|
|
621
|
+
# node dist/server/server.js
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
The production server reads the manifest, injects the correct hashed script and CSS URLs, and serves static assets from `dist/assets/`.
|
|
625
|
+
|
|
626
|
+
### Why `target: 'node18'` matters
|
|
627
|
+
|
|
628
|
+
The SSR bundle uses `await serve(...)` at the top level. esbuild's default browser targets (`chrome87`, `es2020`, etc.) do not support top-level await, causing the build to fail with:
|
|
629
|
+
|
|
630
|
+
```
|
|
631
|
+
Top-level await is not available in the configured target environment
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
`vonoVitePlugin` explicitly sets `target: 'node18'` for the SSR build, which tells esbuild to emit ES2022+ syntax — including top-level await — in the output.
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## Multi-runtime deployment
|
|
639
|
+
|
|
640
|
+
### Node.js
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
// server.ts
|
|
644
|
+
import { serve } from '@netrojs/vono/server'
|
|
645
|
+
import { vono } from './app'
|
|
646
|
+
|
|
647
|
+
await serve({ app: vono, port: 3000, runtime: 'node' })
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### Bun
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
await serve({ app: vono, port: 3000, runtime: 'bun' })
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### Deno
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
await serve({ app: vono, port: 3000, runtime: 'deno' })
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Cloudflare Workers / Edge
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
// worker.ts — export the handler; no serve() call
|
|
666
|
+
import { vono } from './app'
|
|
667
|
+
export default { fetch: vono.handler }
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### Vercel Edge
|
|
671
|
+
|
|
672
|
+
```ts
|
|
673
|
+
// api/index.ts
|
|
674
|
+
import { vono } from '../../app'
|
|
675
|
+
export const config = { runtime: 'edge' }
|
|
676
|
+
export default vono.handler
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## Vite plugin reference
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
// vite.config.ts
|
|
685
|
+
import { vonoVitePlugin } from '@netrojs/vono/vite'
|
|
686
|
+
|
|
687
|
+
vonoVitePlugin({
|
|
688
|
+
serverEntry: 'server.ts', // default
|
|
689
|
+
clientEntry: 'client.ts', // default
|
|
690
|
+
serverOutDir: 'dist/server', // default
|
|
691
|
+
clientOutDir: 'dist/assets', // default
|
|
692
|
+
serverExternal: ['pg', 'ioredis'], // extra packages kept external in SSR bundle
|
|
693
|
+
vueOptions: { /* @vitejs/plugin-vue options for the client build */ },
|
|
694
|
+
})
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
The plugin:
|
|
698
|
+
- On `vite build`: configures the SSR server bundle (target `node18`, externals, ESM output).
|
|
699
|
+
- In `closeBundle`: triggers a separate `build()` call for the client SPA bundle with manifest enabled.
|
|
700
|
+
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
## API reference
|
|
704
|
+
|
|
705
|
+
### `@netrojs/vono` (core, isomorphic)
|
|
706
|
+
|
|
707
|
+
| Export | Description |
|
|
708
|
+
|---|---|
|
|
709
|
+
| `definePage(def)` | Define a page route |
|
|
710
|
+
| `defineGroup(def)` | Define a route group |
|
|
711
|
+
| `defineLayout(component)` | Wrap a Vue component as a layout |
|
|
712
|
+
| `defineApiRoute(path, register)` | Define a Hono API sub-app |
|
|
713
|
+
| `compilePath(path)` | Compile a Vono path to a RegExp + keys |
|
|
714
|
+
| `matchPath(compiled, pathname)` | Match a pathname against a compiled path |
|
|
715
|
+
| `toVueRouterPath(path)` | Convert `[param]` syntax to `:param` syntax |
|
|
716
|
+
| `isAsyncLoader(fn)` | Detect an async component loader |
|
|
717
|
+
| `InferPageData<T>` | Extract loader data type from a `PageDef` |
|
|
718
|
+
| `SPA_HEADER`, `STATE_KEY`, `PARAMS_KEY`, `SEO_KEY`, `DATA_KEY` | Shared constants |
|
|
719
|
+
|
|
720
|
+
### `@netrojs/vono/server`
|
|
721
|
+
|
|
722
|
+
| Export | Description |
|
|
723
|
+
|---|---|
|
|
724
|
+
| `createVono(options)` | Create the Hono app + streaming SSR handler |
|
|
725
|
+
| `serve(options)` | Start the server on Node / Bun / Deno |
|
|
726
|
+
| `detectRuntime()` | Auto-detect the current JS runtime |
|
|
727
|
+
| `vonoVitePlugin(options)` | Vite plugin for dual-bundle production builds |
|
|
728
|
+
|
|
729
|
+
### `@netrojs/vono/client`
|
|
730
|
+
|
|
731
|
+
| Export | Description |
|
|
732
|
+
|---|---|
|
|
733
|
+
| `boot(options)` | Hydrate the SSR HTML and mount the Vue SPA |
|
|
734
|
+
| `usePageData<T>()` | Access the current page's loader data (reactive) |
|
|
735
|
+
| `useClientMiddleware(fn)` | Register a client-side navigation middleware |
|
|
736
|
+
| `prefetch(url)` | Warm the SPA data cache for a URL |
|
|
737
|
+
| `syncSEO(seo)` | Imperatively sync SEO meta tags |
|
|
738
|
+
| `useRoute()` | Vue Router's `useRoute` (re-exported) |
|
|
739
|
+
| `useRouter()` | Vue Router's `useRouter` (re-exported) |
|
|
740
|
+
| `RouterLink` | Vue Router's `RouterLink` (re-exported) |
|
|
741
|
+
|
|
742
|
+
### `@netrojs/vono/vite`
|
|
743
|
+
|
|
744
|
+
| Export | Description |
|
|
745
|
+
|---|---|
|
|
746
|
+
| `vonoVitePlugin(options)` | Same as the server export — convenience alias |
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## How SSR hydration works internally
|
|
751
|
+
|
|
752
|
+
Understanding this prevents subtle bugs:
|
|
753
|
+
|
|
754
|
+
**On the server**, for each request Vono:
|
|
755
|
+
1. Matches the URL against compiled route patterns.
|
|
756
|
+
2. Runs server middleware, then the loader.
|
|
757
|
+
3. Creates a **fresh** `createSSRApp()` + `createRouter()` per request — no shared state between requests (critical for correctness in concurrent environments).
|
|
758
|
+
4. Initialises `createMemoryHistory()` at the **request URL** before constructing the router. This prevents Vue Router from emitting `[Vue Router warn]: No match found for location with path "/"` — the warning fires when the router performs its startup navigation to the history's initial location (`/`) before any routes match.
|
|
759
|
+
5. Awaits `router.isReady()`, then calls `renderToWebStream()` to stream HTML.
|
|
760
|
+
6. Injects `window.__VONO_STATE__`, `__VONO_PARAMS__`, and `__VONO_SEO__` as inline `<script>` tags in the `<body>`.
|
|
761
|
+
|
|
762
|
+
**On the client**, `boot()`:
|
|
763
|
+
1. Reads the injected `window.__VONO_STATE__[pathname]` and seeds a module-level reactive store — no network request on first load.
|
|
764
|
+
2. Calls `createSSRApp()` (not `createApp()`), which tells Vue to hydrate (adopt) the existing server-rendered DOM.
|
|
765
|
+
3. Installs `readonly(reactiveStore)` as `DATA_KEY` into the Vue app via `provide()` — `usePageData()` reads from here.
|
|
766
|
+
4. Pre-loads the current route's async component chunk synchronously (before `mount()`) to ensure the client VDOM matches the SSR HTML byte-for-byte, preventing hydration mismatches.
|
|
767
|
+
5. Mounts the app — Vue reconciles the virtual DOM against the real DOM without re-rendering anything.
|
|
768
|
+
6. On subsequent SPA navigations, `router.beforeEach` fetches JSON, updates the reactive store in-place, and calls `syncSEO()`.
|