@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/server.ts
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Vono · server.ts
|
|
3
|
+
// Hono app factory · Vue 3 streaming SSR · SEO head · asset manifest
|
|
4
|
+
// Vite plugin (dual-bundle: server SSR + client SPA)
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono'
|
|
8
|
+
import { createSSRApp, defineComponent, h, type Component } from 'vue'
|
|
9
|
+
import { createRouter, createMemoryHistory, RouterView } from 'vue-router'
|
|
10
|
+
import { renderToString, renderToWebStream } from '@vue/server-renderer'
|
|
11
|
+
import {
|
|
12
|
+
resolveRoutes, compilePath, matchPath, toVueRouterPath, isAsyncLoader,
|
|
13
|
+
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
|
|
14
|
+
type AppConfig, type ResolvedRoute, type LayoutDef, type SEOMeta,
|
|
15
|
+
} from './core'
|
|
16
|
+
import { build, type Plugin, type InlineConfig, type UserConfig } from 'vite'
|
|
17
|
+
|
|
18
|
+
// ── HTML helpers ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function esc(s: string): string {
|
|
21
|
+
return s
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── SEO → <head> HTML ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function buildHeadMeta(seo: SEOMeta, extraHead = ''): string {
|
|
31
|
+
const m = (n: string, v?: string) => v ? `<meta name="${n}" content="${esc(v)}">` : ''
|
|
32
|
+
const p = (pr: string, v?: string) => v ? `<meta property="${pr}" content="${esc(v)}">` : ''
|
|
33
|
+
const lk = (rel: string, href: string) => `<link rel="${rel}" href="${esc(href)}">`
|
|
34
|
+
const parts: string[] = []
|
|
35
|
+
|
|
36
|
+
if (seo.description) parts.push(m('description', seo.description))
|
|
37
|
+
if (seo.keywords) parts.push(m('keywords', seo.keywords))
|
|
38
|
+
if (seo.author) parts.push(m('author', seo.author))
|
|
39
|
+
if (seo.robots) parts.push(m('robots', seo.robots))
|
|
40
|
+
if (seo.themeColor) parts.push(m('theme-color', seo.themeColor))
|
|
41
|
+
if (seo.canonical) parts.push(lk('canonical', seo.canonical))
|
|
42
|
+
|
|
43
|
+
if (seo.ogTitle) parts.push(p('og:title', seo.ogTitle))
|
|
44
|
+
if (seo.ogDescription) parts.push(p('og:description', seo.ogDescription))
|
|
45
|
+
if (seo.ogImage) parts.push(p('og:image', seo.ogImage))
|
|
46
|
+
if (seo.ogImageAlt) parts.push(p('og:image:alt', seo.ogImageAlt))
|
|
47
|
+
if (seo.ogUrl) parts.push(p('og:url', seo.ogUrl))
|
|
48
|
+
if (seo.ogType) parts.push(p('og:type', seo.ogType))
|
|
49
|
+
if (seo.ogSiteName) parts.push(p('og:site_name', seo.ogSiteName))
|
|
50
|
+
|
|
51
|
+
if (seo.twitterCard) parts.push(m('twitter:card', seo.twitterCard))
|
|
52
|
+
if (seo.twitterSite) parts.push(m('twitter:site', seo.twitterSite))
|
|
53
|
+
if (seo.twitterTitle) parts.push(m('twitter:title', seo.twitterTitle))
|
|
54
|
+
if (seo.twitterDescription) parts.push(m('twitter:description', seo.twitterDescription))
|
|
55
|
+
if (seo.twitterImage) parts.push(m('twitter:image', seo.twitterImage))
|
|
56
|
+
|
|
57
|
+
const ld = seo.jsonLd
|
|
58
|
+
if (ld) {
|
|
59
|
+
const schemas = Array.isArray(ld) ? ld : [ld]
|
|
60
|
+
for (const s of schemas) {
|
|
61
|
+
parts.push(`<script type="application/ld+json">${JSON.stringify(s)}</script>`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (extraHead) parts.push(extraHead)
|
|
66
|
+
return parts.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeSEO(base?: SEOMeta, override?: SEOMeta): SEOMeta {
|
|
70
|
+
return { ...(base ?? {}), ...(override ?? {}) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Asset resolution ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export interface AssetConfig {
|
|
76
|
+
scripts?: string[]
|
|
77
|
+
styles?: string[]
|
|
78
|
+
/** Directory containing the Vite-built assets and .vite/manifest.json. */
|
|
79
|
+
manifestDir?: string
|
|
80
|
+
manifestEntry?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ResolvedAssets { scripts: string[]; styles: string[] }
|
|
84
|
+
|
|
85
|
+
// Process-level cache — resolved once on first production request.
|
|
86
|
+
let _assetsCache: ResolvedAssets | null = null
|
|
87
|
+
|
|
88
|
+
async function resolveAssets(cfg: AssetConfig, defaultEntry: string): Promise<ResolvedAssets> {
|
|
89
|
+
if (_assetsCache) return _assetsCache
|
|
90
|
+
|
|
91
|
+
if (cfg.manifestDir) {
|
|
92
|
+
try {
|
|
93
|
+
const [{ readFileSync }, { join }] = await Promise.all([
|
|
94
|
+
import('node:fs'),
|
|
95
|
+
import('node:path'),
|
|
96
|
+
])
|
|
97
|
+
// Vite 5+ writes manifest to <outDir>/.vite/manifest.json
|
|
98
|
+
const raw = readFileSync(join(cfg.manifestDir, '.vite', 'manifest.json'), 'utf-8')
|
|
99
|
+
const manifest = JSON.parse(raw) as Record<string, { file: string; css?: string[] }>
|
|
100
|
+
const key = cfg.manifestEntry
|
|
101
|
+
?? Object.keys(manifest).find(k => k.endsWith(defaultEntry))
|
|
102
|
+
?? defaultEntry
|
|
103
|
+
const entry = manifest[key]
|
|
104
|
+
if (entry) {
|
|
105
|
+
_assetsCache = {
|
|
106
|
+
scripts: [`/assets/${entry.file}`],
|
|
107
|
+
styles: (entry.css ?? []).map((f: string) => `/assets/${f}`),
|
|
108
|
+
}
|
|
109
|
+
return _assetsCache
|
|
110
|
+
}
|
|
111
|
+
} catch { /* manifest missing or malformed — fall through */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_assetsCache = {
|
|
115
|
+
scripts: cfg.scripts ?? ['/assets/client.js'],
|
|
116
|
+
styles: cfg.styles ?? [],
|
|
117
|
+
}
|
|
118
|
+
return _assetsCache
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── HTML shell parts ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface ShellParts {
|
|
124
|
+
head: string // everything up to and including the opening <div id="vono-app">
|
|
125
|
+
tail: string // everything after the closing </div>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildShellParts(
|
|
129
|
+
title: string,
|
|
130
|
+
metaHtml: string,
|
|
131
|
+
stateJson: string,
|
|
132
|
+
paramsJson: string,
|
|
133
|
+
seoJson: string,
|
|
134
|
+
scripts: string[],
|
|
135
|
+
styles: string[],
|
|
136
|
+
htmlAttrs?: Record<string, string>,
|
|
137
|
+
): ShellParts {
|
|
138
|
+
const attrs = Object.entries(htmlAttrs ?? { lang: 'en' })
|
|
139
|
+
.map(([k, v]) => `${k}="${esc(v)}"`)
|
|
140
|
+
.join(' ')
|
|
141
|
+
const styleLinks = styles.map(href => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')
|
|
142
|
+
const scriptTags = scripts.map(src => `<script type="module" src="${esc(src)}"></script>`).join('\n')
|
|
143
|
+
|
|
144
|
+
const head = [
|
|
145
|
+
'<!DOCTYPE html>',
|
|
146
|
+
`<html ${attrs}>`,
|
|
147
|
+
'<head>',
|
|
148
|
+
'<meta charset="UTF-8">',
|
|
149
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
150
|
+
`<title>${esc(title)}</title>`,
|
|
151
|
+
metaHtml,
|
|
152
|
+
styleLinks,
|
|
153
|
+
'</head>',
|
|
154
|
+
'<body>',
|
|
155
|
+
'<div id="vono-app">',
|
|
156
|
+
].filter(Boolean).join('\n')
|
|
157
|
+
|
|
158
|
+
const tail = [
|
|
159
|
+
'</div>',
|
|
160
|
+
'<script>',
|
|
161
|
+
`window.${STATE_KEY}=${stateJson};`,
|
|
162
|
+
`window.${PARAMS_KEY}=${paramsJson};`,
|
|
163
|
+
`window.${SEO_KEY}=${seoJson};`,
|
|
164
|
+
'</script>',
|
|
165
|
+
scriptTags,
|
|
166
|
+
'</body>',
|
|
167
|
+
'</html>',
|
|
168
|
+
].join('\n')
|
|
169
|
+
|
|
170
|
+
return { head, tail }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Async component resolution ────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** On the server: await the loader to get the real component before rendering. */
|
|
176
|
+
async function resolveComponent(comp: Component | ((...a: unknown[]) => unknown)): Promise<Component> {
|
|
177
|
+
if (isAsyncLoader(comp)) {
|
|
178
|
+
const mod = await (comp as () => Promise<unknown>)()
|
|
179
|
+
return ((mod as any).default ?? mod) as Component
|
|
180
|
+
}
|
|
181
|
+
return comp as Component
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Vue SSR renderer (streaming) ──────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Builds a fresh Vue SSR app + router per request (required — no shared state
|
|
188
|
+
* across requests) and streams HTML output.
|
|
189
|
+
*
|
|
190
|
+
* The memory history is initialised at the request URL *before* the router is
|
|
191
|
+
* created. This ensures the router's internal startup navigation resolves
|
|
192
|
+
* against the correct route and never emits a spurious
|
|
193
|
+
* "[Vue Router warn]: No match found for location with path '/'" warning.
|
|
194
|
+
*/
|
|
195
|
+
async function renderPage(
|
|
196
|
+
route: ResolvedRoute,
|
|
197
|
+
data: object,
|
|
198
|
+
url: string,
|
|
199
|
+
params: Record<string, string>,
|
|
200
|
+
appLayout: LayoutDef | undefined,
|
|
201
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
202
|
+
const layout = route.layout !== undefined ? route.layout : appLayout
|
|
203
|
+
|
|
204
|
+
// Resolve async component loaders — critical for SSR correctness
|
|
205
|
+
const PageComp = await resolveComponent(route.page.component)
|
|
206
|
+
|
|
207
|
+
const routeComp: Component = layout
|
|
208
|
+
? defineComponent({
|
|
209
|
+
name: 'VonoRoute',
|
|
210
|
+
setup: () => () => h(layout.component as Component, null, {
|
|
211
|
+
default: () => h(PageComp),
|
|
212
|
+
}),
|
|
213
|
+
})
|
|
214
|
+
: PageComp
|
|
215
|
+
|
|
216
|
+
// Create a fresh app + router per request (SSR safety — no shared state)
|
|
217
|
+
const app = createSSRApp({ render: () => h(RouterView) })
|
|
218
|
+
app.provide(DATA_KEY, data)
|
|
219
|
+
|
|
220
|
+
// ── Vue Router warning fix ────────────────────────────────────────────────
|
|
221
|
+
// createMemoryHistory() initialises its location to '/'. When the router
|
|
222
|
+
// is constructed it performs an internal navigation to that initial location.
|
|
223
|
+
// If the only registered route is e.g. '/about', no match is found and
|
|
224
|
+
// Vue Router emits a warning even though the subsequent router.push('/about')
|
|
225
|
+
// succeeds perfectly.
|
|
226
|
+
//
|
|
227
|
+
// Fix: call history.replace(url) BEFORE constructing the router. The router
|
|
228
|
+
// then sees the correct initial location and its startup navigation succeeds
|
|
229
|
+
// without warnings. No separate router.push() is required.
|
|
230
|
+
const memHistory = createMemoryHistory()
|
|
231
|
+
memHistory.replace(url)
|
|
232
|
+
|
|
233
|
+
const router = createRouter({
|
|
234
|
+
history: memHistory,
|
|
235
|
+
routes: [{ path: toVueRouterPath(route.fullPath), component: routeComp }],
|
|
236
|
+
})
|
|
237
|
+
app.use(router)
|
|
238
|
+
|
|
239
|
+
// router.isReady() resolves once the initial navigation (to `url`) completes.
|
|
240
|
+
await router.isReady()
|
|
241
|
+
|
|
242
|
+
// renderToWebStream streams body chunks as Uint8Array — lower TTFB vs
|
|
243
|
+
// renderToString (which buffers the entire body before responding).
|
|
244
|
+
return renderToWebStream(app)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Prepend `head` and append `tail` around Vue's streaming body. */
|
|
248
|
+
function buildResponseStream(
|
|
249
|
+
headHtml: string,
|
|
250
|
+
bodyStream: ReadableStream<Uint8Array>,
|
|
251
|
+
tailHtml: string,
|
|
252
|
+
): ReadableStream<Uint8Array> {
|
|
253
|
+
const enc = new TextEncoder()
|
|
254
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
|
|
255
|
+
|
|
256
|
+
;(async () => {
|
|
257
|
+
const writer = writable.getWriter()
|
|
258
|
+
try {
|
|
259
|
+
await writer.write(enc.encode(headHtml))
|
|
260
|
+
const reader = bodyStream.getReader()
|
|
261
|
+
while (true) {
|
|
262
|
+
const { done, value } = await reader.read()
|
|
263
|
+
if (done) break
|
|
264
|
+
await writer.write(value)
|
|
265
|
+
}
|
|
266
|
+
await writer.write(enc.encode(tailHtml))
|
|
267
|
+
await writer.close()
|
|
268
|
+
} catch (err) {
|
|
269
|
+
await writer.abort(err)
|
|
270
|
+
}
|
|
271
|
+
})()
|
|
272
|
+
|
|
273
|
+
return readable
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── createVono ──────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
export interface VonoOptions extends AppConfig {
|
|
279
|
+
assets?: AssetConfig
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface VonoApp {
|
|
283
|
+
/** The Hono instance — attach extra routes, error handlers, middleware. */
|
|
284
|
+
app: Hono
|
|
285
|
+
/** WinterCG-compatible fetch handler for edge runtimes. */
|
|
286
|
+
handler: typeof Hono.prototype.fetch
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function createVono(config: VonoOptions): VonoApp {
|
|
290
|
+
const app = new Hono()
|
|
291
|
+
|
|
292
|
+
// Global middleware (runs before every route)
|
|
293
|
+
for (const mw of config.middleware ?? []) app.use('*', mw)
|
|
294
|
+
|
|
295
|
+
const { pages, apis } = resolveRoutes(config.routes, {
|
|
296
|
+
...(config.layout !== undefined && { layout: config.layout }),
|
|
297
|
+
middleware: [],
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Pre-compile path patterns — avoids recompiling on every request
|
|
301
|
+
const compiled = pages.map(r => ({ route: r, cp: compilePath(r.fullPath) }))
|
|
302
|
+
|
|
303
|
+
// Register API sub-apps before the catch-all page handler
|
|
304
|
+
for (const api of apis) {
|
|
305
|
+
const sub = new Hono()
|
|
306
|
+
api.register(sub, config.middleware ?? [])
|
|
307
|
+
app.route(api.path, sub)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
app.all('*', async (c) => {
|
|
311
|
+
const url = new URL(c.req.url)
|
|
312
|
+
const pathname = url.pathname
|
|
313
|
+
const isSPA = c.req.header(SPA_HEADER) === '1'
|
|
314
|
+
const isDev = process.env['NODE_ENV'] !== 'production'
|
|
315
|
+
|
|
316
|
+
// Route matching
|
|
317
|
+
let matched: { route: ResolvedRoute; params: Record<string, string> } | null = null
|
|
318
|
+
for (const { route, cp } of compiled) {
|
|
319
|
+
const params = matchPath(cp, pathname)
|
|
320
|
+
if (params !== null) { matched = { route, params }; break }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!matched) {
|
|
324
|
+
if (config.notFound) {
|
|
325
|
+
const html = await renderToString(createSSRApp(config.notFound))
|
|
326
|
+
return c.html(`<!DOCTYPE html><html lang="en"><body>${html}</body></html>`, 404)
|
|
327
|
+
}
|
|
328
|
+
return c.text('Not Found', 404)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { route, params } = matched
|
|
332
|
+
|
|
333
|
+
// Expose dynamic params through c.req.param()
|
|
334
|
+
const origParam = c.req.param.bind(c.req);
|
|
335
|
+
(c.req as any)['param'] = (key?: string) =>
|
|
336
|
+
key != null
|
|
337
|
+
? (params[key] ?? origParam(key))
|
|
338
|
+
: { ...origParam(), ...params }
|
|
339
|
+
|
|
340
|
+
// Route-level middleware chain (run in order, short-circuit on early response)
|
|
341
|
+
let earlyResponse: Response | undefined
|
|
342
|
+
let idx = 0
|
|
343
|
+
const runNext = async (): Promise<void> => {
|
|
344
|
+
const mw = route.middleware[idx++]
|
|
345
|
+
if (!mw) return
|
|
346
|
+
const res = await mw(c, runNext)
|
|
347
|
+
if (res instanceof Response && !earlyResponse) earlyResponse = res
|
|
348
|
+
}
|
|
349
|
+
await runNext()
|
|
350
|
+
if (earlyResponse) return earlyResponse
|
|
351
|
+
|
|
352
|
+
// Run loader
|
|
353
|
+
const rawData = route.page.loader ? await route.page.loader(c) : {}
|
|
354
|
+
const data = (rawData ?? {}) as object
|
|
355
|
+
|
|
356
|
+
// ── SPA navigation: return JSON only ─────────────────────────────────────
|
|
357
|
+
if (isSPA) {
|
|
358
|
+
const pageSEO = typeof route.page.seo === 'function'
|
|
359
|
+
? route.page.seo(data as any, params)
|
|
360
|
+
: route.page.seo
|
|
361
|
+
return c.json({
|
|
362
|
+
state: data,
|
|
363
|
+
params,
|
|
364
|
+
url: pathname,
|
|
365
|
+
seo: mergeSEO(config.seo, pageSEO),
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Full SSR: stream HTML response ────────────────────────────────────────
|
|
370
|
+
const clientEntry = config.assets?.manifestEntry ?? 'client.ts'
|
|
371
|
+
const assets = isDev
|
|
372
|
+
? { scripts: [`/${clientEntry}`], styles: [] as string[] }
|
|
373
|
+
: await resolveAssets(config.assets ?? {}, clientEntry)
|
|
374
|
+
|
|
375
|
+
const pageSEO = typeof route.page.seo === 'function'
|
|
376
|
+
? route.page.seo(data as any, params)
|
|
377
|
+
: route.page.seo
|
|
378
|
+
const seo = mergeSEO(config.seo, pageSEO)
|
|
379
|
+
const title = seo.title ?? 'Vono'
|
|
380
|
+
|
|
381
|
+
const { head, tail } = buildShellParts(
|
|
382
|
+
title,
|
|
383
|
+
buildHeadMeta(seo, config.head),
|
|
384
|
+
JSON.stringify({ [pathname]: data }),
|
|
385
|
+
JSON.stringify(params),
|
|
386
|
+
JSON.stringify(seo),
|
|
387
|
+
assets.scripts,
|
|
388
|
+
assets.styles,
|
|
389
|
+
config.htmlAttrs,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// Render the body asynchronously while the head is already on the wire
|
|
393
|
+
const bodyStream = await renderPage(route, data, pathname, params, config.layout)
|
|
394
|
+
const stream = buildResponseStream(head, bodyStream, tail)
|
|
395
|
+
|
|
396
|
+
return c.body(stream, 200, {
|
|
397
|
+
'Content-Type': 'text/html; charset=UTF-8',
|
|
398
|
+
'Transfer-Encoding': 'chunked',
|
|
399
|
+
'X-Content-Type-Options': 'nosniff',
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
return { app, handler: app.fetch.bind(app) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── serve() ───────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
export type Runtime = 'node' | 'bun' | 'deno' | 'edge'
|
|
409
|
+
|
|
410
|
+
export function detectRuntime(): Runtime {
|
|
411
|
+
if (typeof (globalThis as any)['Bun'] !== 'undefined') return 'bun'
|
|
412
|
+
if (typeof (globalThis as any)['Deno'] !== 'undefined') return 'deno'
|
|
413
|
+
if (typeof process !== 'undefined' && process.versions?.node) return 'node'
|
|
414
|
+
return 'edge'
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface ServeOptions {
|
|
418
|
+
app: VonoApp
|
|
419
|
+
port?: number
|
|
420
|
+
hostname?: string
|
|
421
|
+
runtime?: Runtime
|
|
422
|
+
/** Root directory that contains the built assets and public files. */
|
|
423
|
+
staticDir?: string
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function serve(opts: ServeOptions): Promise<void> {
|
|
427
|
+
const runtime = opts.runtime ?? detectRuntime()
|
|
428
|
+
const port = opts.port ?? Number(process?.env?.['PORT'] ?? 3000)
|
|
429
|
+
const hostname = opts.hostname ?? '0.0.0.0'
|
|
430
|
+
const staticDir = opts.staticDir ?? './dist'
|
|
431
|
+
const displayHost = hostname === '0.0.0.0' ? 'localhost' : hostname
|
|
432
|
+
const logReady = () => console.log(`\n🔥 Vono [${runtime}] → http://${displayHost}:${port}\n`)
|
|
433
|
+
|
|
434
|
+
switch (runtime) {
|
|
435
|
+
case 'node': {
|
|
436
|
+
const [{ serve: nodeServe }, { serveStatic }] = await Promise.all([
|
|
437
|
+
import('@hono/node-server'),
|
|
438
|
+
import('@hono/node-server/serve-static'),
|
|
439
|
+
])
|
|
440
|
+
opts.app.app.use('/assets/*', serveStatic({ root: staticDir }))
|
|
441
|
+
opts.app.app.use('/*', serveStatic({ root: './public' }))
|
|
442
|
+
nodeServe({ fetch: opts.app.handler, port, hostname })
|
|
443
|
+
logReady()
|
|
444
|
+
break
|
|
445
|
+
}
|
|
446
|
+
case 'bun':
|
|
447
|
+
;(globalThis as any)['Bun'].serve({ fetch: opts.app.handler, port, hostname })
|
|
448
|
+
logReady()
|
|
449
|
+
break
|
|
450
|
+
case 'deno':
|
|
451
|
+
;(globalThis as any)['Deno'].serve({ port, hostname }, opts.app.handler)
|
|
452
|
+
logReady()
|
|
453
|
+
break
|
|
454
|
+
default:
|
|
455
|
+
console.warn('[vono] serve() is a no-op on edge — export vono.handler instead.')
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Vite plugin ───────────────────────────────────────────────────────────────
|
|
460
|
+
//
|
|
461
|
+
// Design:
|
|
462
|
+
// • The user's vite.config.ts already includes vue() from @vitejs/plugin-vue.
|
|
463
|
+
// That plugin handles .vue transforms in both dev mode and the server build.
|
|
464
|
+
// • vonoVitePlugin() only handles build orchestration:
|
|
465
|
+
// - `vite build` → server SSR bundle (dist/server/server.js)
|
|
466
|
+
// - `closeBundle` → client SPA bundle (dist/assets/… + .vite/manifest.json)
|
|
467
|
+
//
|
|
468
|
+
// This keeps the plugin simple and avoids fragile hook-proxying.
|
|
469
|
+
|
|
470
|
+
const NODE_BUILTINS =
|
|
471
|
+
/^node:|^(assert|buffer|child_process|cluster|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|perf_hooks|process|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|trace_events|tty|url|util|v8|vm|worker_threads|zlib)$/
|
|
472
|
+
|
|
473
|
+
export interface VonoPluginOptions {
|
|
474
|
+
/** Server entry file. @default 'server.ts' */
|
|
475
|
+
serverEntry?: string
|
|
476
|
+
/** Client entry file. @default 'client.ts' */
|
|
477
|
+
clientEntry?: string
|
|
478
|
+
/** Server bundle output dir. @default 'dist/server' */
|
|
479
|
+
serverOutDir?: string
|
|
480
|
+
/** Client assets output dir. @default 'dist/assets' */
|
|
481
|
+
clientOutDir?: string
|
|
482
|
+
/** Extra packages external to the server bundle. */
|
|
483
|
+
serverExternal?: string[]
|
|
484
|
+
/** Options forwarded to @vitejs/plugin-vue in the client build. */
|
|
485
|
+
vueOptions?: Record<string, unknown>
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function vonoVitePlugin(opts: VonoPluginOptions = {}): Plugin {
|
|
489
|
+
const {
|
|
490
|
+
serverEntry = 'server.ts',
|
|
491
|
+
clientEntry = 'client.ts',
|
|
492
|
+
serverOutDir = 'dist/server',
|
|
493
|
+
clientOutDir = 'dist/assets',
|
|
494
|
+
serverExternal = [],
|
|
495
|
+
vueOptions = {},
|
|
496
|
+
} = opts
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
name: 'vono:build',
|
|
500
|
+
apply: 'build',
|
|
501
|
+
enforce: 'pre',
|
|
502
|
+
|
|
503
|
+
// Server (SSR) bundle configuration.
|
|
504
|
+
//
|
|
505
|
+
// target: 'node18' is essential — it tells esbuild to emit ES2022+ syntax
|
|
506
|
+
// which includes top-level await. Without it, esbuild defaults to a
|
|
507
|
+
// browser-compatible target ("chrome87", "es2020", …) that does NOT support
|
|
508
|
+
// top-level await, causing the build to fail with:
|
|
509
|
+
// "Top-level await is not available in the configured target environment"
|
|
510
|
+
config(): Omit<UserConfig, 'plugins'> {
|
|
511
|
+
return {
|
|
512
|
+
build: {
|
|
513
|
+
ssr: serverEntry,
|
|
514
|
+
outDir: serverOutDir,
|
|
515
|
+
// ↓ CRITICAL — enables top-level await in the server bundle
|
|
516
|
+
target: 'node18',
|
|
517
|
+
rollupOptions: {
|
|
518
|
+
input: serverEntry,
|
|
519
|
+
output: { format: 'es', entryFileNames: 'server.js' },
|
|
520
|
+
external: (id: string) =>
|
|
521
|
+
NODE_BUILTINS.test(id)
|
|
522
|
+
|| id === 'vue' || id.startsWith('vue/')
|
|
523
|
+
|| id === 'vue-router'
|
|
524
|
+
|| id === '@vue/server-renderer'
|
|
525
|
+
|| id === '@vitejs/plugin-vue'
|
|
526
|
+
|| id === '@hono/node-server'
|
|
527
|
+
|| id === '@hono/node-server/serve-static'
|
|
528
|
+
|| serverExternal.includes(id),
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
// After the server bundle is written, trigger the client SPA build
|
|
535
|
+
async closeBundle() {
|
|
536
|
+
console.log('\n⚡ Vono: building client bundle…\n')
|
|
537
|
+
|
|
538
|
+
let vuePlugin: Plugin | Plugin[]
|
|
539
|
+
try {
|
|
540
|
+
const mod = await import('@vitejs/plugin-vue' as string)
|
|
541
|
+
const factory = (mod.default ?? mod) as (opts?: Record<string, unknown>) => Plugin | Plugin[]
|
|
542
|
+
vuePlugin = factory(vueOptions)
|
|
543
|
+
} catch {
|
|
544
|
+
throw new Error(
|
|
545
|
+
'[vono] @vitejs/plugin-vue is required for the client build.\n' +
|
|
546
|
+
' Install: npm i -D @vitejs/plugin-vue',
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const plugins = (
|
|
551
|
+
Array.isArray(vuePlugin) ? vuePlugin : [vuePlugin]
|
|
552
|
+
) as NonNullable<InlineConfig['plugins']>
|
|
553
|
+
|
|
554
|
+
await build({
|
|
555
|
+
configFile: false as const,
|
|
556
|
+
plugins,
|
|
557
|
+
build: {
|
|
558
|
+
outDir: clientOutDir,
|
|
559
|
+
// Vite 5+ writes manifest to <outDir>/.vite/manifest.json
|
|
560
|
+
manifest: true,
|
|
561
|
+
rollupOptions: {
|
|
562
|
+
input: clientEntry,
|
|
563
|
+
output: {
|
|
564
|
+
format: 'es',
|
|
565
|
+
entryFileNames: '[name]-[hash].js',
|
|
566
|
+
chunkFileNames: '[name]-[hash].js',
|
|
567
|
+
assetFileNames: '[name]-[hash][extname]',
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
console.log('✅ Vono: both bundles ready\n')
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Re-exports ────────────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
export {
|
|
581
|
+
definePage, defineGroup, defineLayout, defineApiRoute, isAsyncLoader,
|
|
582
|
+
resolveRoutes, compilePath, matchPath, toVueRouterPath,
|
|
583
|
+
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
|
|
584
|
+
} from './core'
|
|
585
|
+
|
|
586
|
+
export type {
|
|
587
|
+
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
|
|
588
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
589
|
+
ClientMiddleware, AsyncLoader, InferPageData,
|
|
590
|
+
} from './core'
|